commit 7f59a8e82af242212601283276de01300103cbf2 Author: s8n-ru <279801990+s8n-ru@users.noreply.github.com> Date: Thu Apr 30 10:59:04 2026 +0100 Initial release: racked Minecraft launcher (PrismLauncher fork) v0.1.0 diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..005a100 --- /dev/null +++ b/.clang-format @@ -0,0 +1,19 @@ +--- +BasedOnStyle: Chromium +IndentWidth: 4 +AllowShortIfStatementsOnASingleLine: false +ColumnLimit: 140 +--- +Language: Cpp +AccessModifierOffset: -1 +AlignConsecutiveMacros: None +AlignConsecutiveAssignments: None +BraceWrapping: + AfterFunction: true + SplitEmptyFunction: false + SplitEmptyRecord: false + SplitEmptyNamespace: false +BreakBeforeBraces: Custom +BreakConstructorInitializers: BeforeComma +Cpp11BracedListStyle: false +QualifierAlignment: Left diff --git a/.clang-tidy b/.clang-tidy new file mode 100644 index 0000000..c5eb095 --- /dev/null +++ b/.clang-tidy @@ -0,0 +1,32 @@ +FormatStyle: file + +Checks: + "bugprone-*,clang-analyzer-*,cppcoreguidelines-*,hicpp-*,misc-*,modernize-*,performance-*,portability-*,readability-*, + -*-magic-numbers, + -*-non-private-member-variables-in-classes, + -*-special-member-functions, + -bugprone-easily-swappable-parameters, + -cppcoreguidelines-owning-memory, + -cppcoreguidelines-pro-type-static-cast-downcast, + -modernize-use-nodiscard, + -modernize-use-trailing-return-type, + -portability-avoid-pragma-once, + -readability-avoid-unconditional-preprocessor-if, + -readability-function-cognitive-complexity, + -readability-identifier-length, + -readability-redundant-access-specifiers" + +CheckOptions: + misc-include-cleaner.MissingIncludes: false + readability-identifier-naming.DefaultCase: "camelBack" + readability-identifier-naming.NamespaceCase: "CamelCase" + readability-identifier-naming.ClassCase: "CamelCase" + readability-identifier-naming.ClassConstantCase: "CamelCase" + readability-identifier-naming.EnumCase: "CamelCase" + readability-identifier-naming.EnumConstantCase: "CamelCase" + readability-identifier-naming.MacroDefinitionCase: "UPPER_CASE" + readability-identifier-naming.ClassMemberPrefix: "m_" + readability-identifier-naming.StaticConstantPrefix: "s_" + readability-identifier-naming.StaticVariablePrefix: "s_" + readability-identifier-naming.GlobalConstantPrefix: "g_" + readability-implicit-bool-conversion.AllowPointerConditions: true diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..03b9937 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,22 @@ +# EditorConfig specs and documentation: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{yml,nix}] +indent_size = 2 + +# C++ Code Style settings +[*.{c++,cc,cpp,cppm,cxx,h,h++,hh,hpp,hxx,inl,ipp,ixx,tlh,tli}] +cpp_generate_documentation_comments = doxygen_slash_star + +[CMakeLists.txt] +ij_continuation_indent_size = 4 diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..1d11c53 --- /dev/null +++ b/.envrc @@ -0,0 +1,2 @@ +use nix +watch_file nix/*.nix diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..c7d36db --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,13 @@ +# .git-blame-ignore-revs + +# tabs -> spaces +bbb3b3e6f6e3c0f95873f22e6d0a4aaf350f49d9 + +# (nix) alejandra -> nixfmt +4c81d8c53d09196426568c4a31a4e752ed05397a + +# reformat codebase +1d468ac35ad88d8c77cc83f25e3704d9bd7df01b + +# format a part of codebase +5c8481a118c8fefbfe901001d7828eaf6866eac4 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c9c0d50 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +*.pem -crlf +**/testdata/** -text -diff diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..f074ac3 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,145 @@ +name: Build + +on: + push: + tags: + - 'v*.*.*' + workflow_dispatch: + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + name: linux-x64 + archive: tar.gz + - os: windows-latest + name: windows-x64 + archive: zip + - os: macos-14 + name: macos-arm64 + archive: zip + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + - name: Set up Java 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + - name: Set up Qt 6 + uses: jurplel/install-qt-action@v4 + with: + version: '6.7.2' + modules: 'qt5compat qtimageformats qtnetworkauth' + cache: true + + # ---------- Linux ---------- + - name: Linux deps + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y \ + extra-cmake-modules \ + libarchive-dev \ + libqrencode-dev \ + libtomlplusplus-dev \ + libvulkan-dev \ + gamemode-dev \ + zlib1g-dev \ + libgl1-mesa-dev \ + ninja-build \ + scdoc + + - name: Build cmark from source (Linux) + if: runner.os == 'Linux' + run: | + git clone --depth=1 --branch 0.31.0 https://github.com/commonmark/cmark.git /tmp/cmark + cmake -B /tmp/cmark/build -S /tmp/cmark -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr/local -DCMARK_TESTS=OFF + sudo cmake --build /tmp/cmark/build -j --target install + + # ---------- macOS ---------- + - name: macOS deps + if: runner.os == 'macOS' + run: | + brew install \ + extra-cmake-modules \ + cmark \ + libarchive \ + qrencode \ + tomlplusplus \ + ninja + echo "PKG_CONFIG_PATH=$(brew --prefix libarchive)/lib/pkgconfig" >> $GITHUB_ENV + echo "LibArchive_ROOT=$(brew --prefix libarchive)" >> $GITHUB_ENV + + # ---------- Windows ---------- + - name: Windows — set up vcpkg + if: runner.os == 'Windows' + uses: lukka/run-vcpkg@v11 + with: + vcpkgGitCommitId: '2d6a6cf3ac9a7cc93942c3d289a2f9c661a6f4a7' + + # ---------- Configure / Build ---------- + - name: Configure (Linux/macOS) + if: runner.os != 'Windows' + run: cmake -B build -S . -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=install -DLauncher_BUILD_PLATFORM=racked-portable -DBUILD_TESTING=OFF + + - name: Configure (Windows) + if: runner.os == 'Windows' + run: cmake -B build -S . -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=install -DLauncher_BUILD_PLATFORM=racked-portable -DBUILD_TESTING=OFF -DCMAKE_TOOLCHAIN_FILE="${{ github.workspace }}/vcpkg/scripts/buildsystems/vcpkg.cmake" + + - name: Build + run: cmake --build build -j --config Release + + - name: Install + run: cmake --install build --config Release + + # ---------- Package ---------- + - name: Package (Linux) + if: runner.os == 'Linux' + run: | + mv install minecraft-launcher + tar czf minecraft-launcher-${{ matrix.name }}.tar.gz minecraft-launcher + + - name: Package (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + Move-Item install minecraft-launcher + Compress-Archive -Path minecraft-launcher -DestinationPath minecraft-launcher-${{ matrix.name }}.zip + + - name: Package (macOS) + if: runner.os == 'macOS' + run: | + mv install minecraft-launcher + zip -r minecraft-launcher-${{ matrix.name }}.zip minecraft-launcher + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: minecraft-launcher-${{ matrix.name }} + path: minecraft-launcher-${{ matrix.name }}.${{ matrix.archive }} + + release: + needs: build + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + path: artifacts + merge-multiple: true + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + files: artifacts/* + generate_release_notes: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7302901 --- /dev/null +++ b/.gitignore @@ -0,0 +1,80 @@ +Thumbs.db +*.kdev4 +.user +.directory +resources/CMakeFiles +*~ +*.swp +html/ + +# Project Files +*.pro.user +CMakeLists.txt.user +CMakeLists.txt.user.* +CMakeSettings.json +/CMakeFiles +CMakeCache.txt +CMakeUserPresets.json +/.project +/.settings +/.idea +/.vscode +/.vs +cmake-build-*/ +Debug +compile_commands.json + +# Build dirs +build +/build-* + +# Install dirs +install +/install-* + +# Ctags File +tags + +# YouCompleteMe config stuff. +.ycm_extra_conf.* + +#OSX Stuff +.DS_Store + +branding/ +secrets/ +run/ + +.cache/ + +# Nix/NixOS +.direnv/ +## Used when manually invoking stdenv phases +outputs/ +## Regular artifacts +result +result-* +repl-result-* + +# Flatpak +.flatpak-builder +flatbuild + +# Snap +*.snap + +# Release archives (local builds) +release/ + +# Windows build artifacts +*.vcxproj* +*.sln +*.sdf +*.opensdf +x64/ +Win32/ + +# macOS artifacts +*.dmg +*.app/ + diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..42c566f --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "libraries/libnbtplusplus"] + path = libraries/libnbtplusplus + url = https://github.com/PrismLauncher/libnbtplusplus.git diff --git a/.markdownlint.yaml b/.markdownlint.yaml new file mode 100644 index 0000000..5781edb --- /dev/null +++ b/.markdownlint.yaml @@ -0,0 +1,12 @@ +# MD013/line-length - Line length +MD013: false + +# MD024/no-duplicate-heading/no-duplicate-header - Multiple headings with the same content +MD024: + siblings-only: true + +# MD033/no-inline-html Inline HTML +MD033: false + +# MD041/first-line-heading/first-line-h1 First line in a file should be a top-level heading +MD041: false diff --git a/.markdownlintignore b/.markdownlintignore new file mode 100644 index 0000000..96f627a --- /dev/null +++ b/.markdownlintignore @@ -0,0 +1 @@ +libraries/nbtplusplus diff --git a/BUILD_AND_DEPLOY_V1.sh b/BUILD_AND_DEPLOY_V1.sh new file mode 100755 index 0000000..6fdf6aa --- /dev/null +++ b/BUILD_AND_DEPLOY_V1.sh @@ -0,0 +1,141 @@ +#!/bin/bash +# Build and deploy portable Linux launcher as linux-racked-launcher-build-v1 + +set -e + +echo "==============================================" +echo "Building Racked.ru PrismLauncher for Linux" +echo "==============================================" +echo "" + +# Navigate to build directory +cd /home/admin/ai-lab/_project_minecraft/prismlauncher-racked + +# Check if cmake is installed +if ! command -v cmake &> /dev/null; then + echo "❌ CMake is not installed!" + echo "" + echo "Please run this first:" + echo " bash INSTALL_DEPS.sh" + echo "" + echo "Or install manually:" + echo " sudo dnf install cmake gcc-c++ make qt6-qtbase-devel" + echo "" + exit 1 +fi + +# Check if Qt6 is installed +if ! command -v qmake6 &> /dev/null; then + echo "❌ Qt6 is not installed!" + echo "" + echo "Please run this first:" + echo " bash INSTALL_DEPS.sh" + echo "" + exit 1 +fi + +echo "✅ Dependencies found" +echo "" + +# Clean old builds +echo "Cleaning old builds..." +rm -rf build-linux-portable install-linux-portable + +# Build +echo "Building launcher..." +bash scripts/build-linux-portable.sh + +echo "" +echo "==============================================" +echo "Creating Portable Installation" +echo "==============================================" +echo "" + +# Create the v1 folder +V1_DIR="../linux-racked-launcher-build-v1" +rm -rf "$V1_DIR" +mkdir -p "$V1_DIR" + +# Copy built files +echo "Copying files to $V1_DIR..." +cp -r release/Racked.ru-PrismLauncher-Linux-Portable/* "$V1_DIR"/ + +# Create a simple launcher script +cat > "$V1_DIR/start.sh" <<'EOF' +#!/bin/bash +# Racked.ru PrismLauncher - Portable Linux Launcher +# Version 1 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +echo "Starting Racked.ru PrismLauncher..." +echo "" + +# Set environment +export LD_LIBRARY_PATH="$SCRIPT_DIR/lib64:$SCRIPT_DIR/lib:$LD_LIBRARY_PATH" +export QT_QPA_PLATFORM_PLUGIN_PATH="$SCRIPT_DIR/plugins" +export QT_PLUGIN_PATH="$SCRIPT_DIR/plugins" + +# Run launcher +exec "$SCRIPT_DIR/bin/prismlauncher" "$@" +EOF + +chmod +x "$V1_DIR/start.sh" + +# Create a README +cat > "$V1_DIR/README.txt" <<'EOF' +============================================== +Racked.ru PrismLauncher - Linux Portable +Version 1 +============================================== + +This is a portable Minecraft launcher with custom +racked.ru theme (black/red minimalist design). + +HOW TO RUN: +----------- +1. Open terminal in this folder +2. Run: bash start.sh + OR + Run: chmod +x start.sh && ./start.sh + +FEATURES: +--------- +- Portable: All data stored in this folder +- Custom racked.ru theme (black/red) +- USB-friendly: Works from any location +- No installation required + +CONFIGURATION: +-------------- +Edit prismlauncher.cfg to change settings + +YOUR DATA: +---------- +- Minecraft instances: instances/ +- Settings: prismlauncher.cfg +- Cache: cache/ + +All data travels with this folder! + +SUPPORT: +-------- +Website: https://racked.ru/ +============================================== +EOF + +echo "" +echo "==============================================" +echo "✅ Build Complete!" +echo "==============================================" +echo "" +echo "Your launcher is ready at:" +echo " /home/admin/ai-lab/_project_minecraft/linux-racked-launcher-build-v1/" +echo "" +echo "To run it:" +echo " cd /home/admin/ai-lab/_project_minecraft/linux-racked-launcher-build-v1/" +echo " bash start.sh" +echo "" +echo "==============================================" + +exit 0 diff --git a/BUILD_GUIDE.md b/BUILD_GUIDE.md new file mode 100644 index 0000000..aee0cdb --- /dev/null +++ b/BUILD_GUIDE.md @@ -0,0 +1,159 @@ +# Racked.ru PrismLauncher - Build Guide + +Minimal, custom-themed PrismLauncher build with racked.ru branding and portable mode support. + +## Features + +- **Minimal theme**: Black and red racked.ru theme +- **Portable mode**: USB-friendly, no installation required +- **Stripped resources**: Removed unused themes to reduce size +- **Cross-platform**: Windows, macOS, and Linux support +- **Custom background**: racked_ru catpack background + +## Prerequisites + +### Windows +- Visual Studio 2022 (Community Edition or higher) +- Qt 6.5.3 or later (MSVC 2019 64-bit) +- CMake 3.25 or later +- Git + +Install Qt using the online installer: +``` +1. Download Qt Online Installer from qt.io +2. Install Qt 6.5.3 -> MSVC 2019 64-bit +3. Make sure to include Qt Network Authentication and Qt SVG modules +``` + +### Linux (Ubuntu/Debian) +```bash +sudo apt update +sudo apt install build-essential cmake qt6-base-dev qt6-tools-dev \ + qt6-image-formats-plugins qt6-networkauth-dev zlib1g-dev libgl1-mesa-dev +``` + +### Linux (Fedora) +```bash +sudo dnf install gcc-c++ cmake qt6-qtbase-devel qt6-qttools-devel \ + qt6-qtimageformats qt6-qtnetworkauth-devel zlib-devel mesa-libGL-devel +``` + +### macOS +```bash +# Install Xcode Command Line Tools +xcode-select --install + +# Install Homebrew +/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + +# Install dependencies +brew install cmake qt6 +``` + +## Building + +### Quick Build (Current Platform) + +```bash +# Clone the repository +git clone +cd prismlauncher-racked + +# Run the build script +bash scripts/build-all-platforms.sh +``` + +### Platform-Specific Builds + +#### Windows (Run in Developer Command Prompt or PowerShell) +```batch +scripts\build-windows-portable.bat +``` + +#### Linux/macOS +```bash +chmod +x scripts/build-linux-portable.sh # Linux only +chmod +x scripts/build-macos-portable.sh # macOS only +bash scripts/build-linux-portable.sh # Linux +bash scripts/build-macos-portable.sh # macOS +``` + +## Output + +Builds are placed in the `release/` directory: +- `release/Racked.ru-PrismLauncher-Windows-Portable/` - Windows portable +- `release/Racked.ru-PrismLauncher-Linux-Portable/` - Linux portable +- `release/Racked.ru-PrismLauncher-macOS-Portable/` - macOS portable + +### Creating Distribution Archives + +**Windows (PowerShell):** +```powershell +cd release\Racked.ru-PrismLauncher-Windows-Portable +Compress-Archive -Path * -DestinationPath ..\racked-prismlauncher-windows-portable.zip +``` + +**Linux:** +```bash +cd release/Racked.ru-PrismLauncher-Linux-Portable +tar czf ../racked-prismlauncher-linux-portable.tar.gz . +``` + +**macOS:** +```bash +cd release/Racked.ru-PrismLauncher-macOS-Portable +tar czf ../racked-prismlauncher-macos-portable.tar.gz PrismLauncher.app run.sh +``` + +## Portable Mode + +The launcher includes `portable.txt` which enables portable mode. This makes the launcher store all data (instances, settings, etc.) in the same directory as the executable, making it perfect for USB drives. + +To disable portable mode, simply delete `portable.txt` from the installation directory. + +## Custom Theme + +The launcher uses a custom `racked.ru` theme with: +- Black background (#000000) +- White text (#ffffff) +- Red accents (#ff0000, #CD001F) +- Minimal UI elements +- Custom catpack background (racked_ru.png) + +## Stripped Components + +To minimize size, the following have been removed: +- All default icon themes (except flat_white) +- All default application themes (except Fusion/system defaults) +- Unnecessary Qt plugins + +## Troubleshooting + +### Windows: "Qt6Core.dll not found" +Ensure Qt6 bin directory is in your PATH or copy all required Qt DLLs to the output directory (the build script does this automatically). + +### Linux: "Qt6 not found" +Install Qt6 development packages via your package manager. Ensure `qmake6` or `qt6-cmake` is in your PATH. + +### macOS: "App cannot be opened because the developer cannot be verified" +Right-click the app, select "Open", then click "Open" again in the security dialog. Or codesign the app: +```bash +codesign --deep --force --sign "-" release/Racked.ru-PrismLauncher-macOS-Portable/PrismLauncher.app +``` + +### Build fails with "CMake error" +Ensure CMake version is 3.25 or higher: +```bash +cmake --version +``` + +## License + +This project is based on PrismLauncher and follows the same licensing terms (GPL-3.0-only). + +## Credits + +- **Original Project**: PrismLauncher +- **Upstream fork**: Diegiwg +- **Custom Theme**: racked.ru +- **Build System**: Custom portable build scripts diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9fd6cae --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,112 @@ +# Changelog + +All notable changes to the racked.ru launcher (PrismLauncher fork). + +## [Unreleased] — 2026-04-30 + +### Branding +- Window title: `racked.ru launcher` (was `Prism Launcher 11.0.0-develop`) +- `Launcher_DisplayName` → `racked.ru launcher` (`program_info/CMakeLists.txt`) +- `setApplicationDisplayName()` no longer appends version string +- Per-file copyright headers + `Launcher_Copyright` cmake var preserved (GPL-3.0 §5c compliance) + +### Removed "Cracked" branding leak +Spell-check had mangled `racked.ru` → `cracked` across docs/configs. Cleaned all 7 files: +- `CMakeLists.txt`, `program_info/CMakeLists.txt`, `program_info/org.prismlauncher.PrismLauncher.metainfo.xml.in` +- `README_RELEASE.md`, `PROJECT_SUMMARY.md`, `BUILD_GUIDE.md`, `scripts/create-release.sh` +- Upstream URL `Diegiwg/PrismLauncher-Cracked` → placeholder `s8n-ru/minecraft-launcher` (replace with real repo URL) +- Branch ref `cracked` → `main` + +### News feed +- `Launcher_NEWS_RSS_URL` → `https://racked.ru/feed.xml` (was `prismlauncher.org/feed/feed.xml`) +- `Launcher_NEWS_OPEN_URL` → `https://racked.ru/news` +- News toolbar **hidden by default** (`Application.cpp` post-restoreState `findChild("newsToolBar")->hide()`). Re-enable via View menu + +### Default settings (ported runtime cfg → source defaults) +| Setting | Old | New | +|---|---|---| +| `MinMemAlloc` | 512 | 384 | +| `MaxMemAlloc` | `SysInfo::defaultMaxJvmMem()` | 4096 | +| `MenuBarInsteadOfToolBar` | false | true | + +### Window geometry baked defaults +First-run windows open at user-tested sizes: +- Main: 346×265 +- New Instance dialog: 1112×507 +- News dialog: 799×499 +- Console: 782×705 +- Paged settings: 871×649 + +(`Application.cpp` registerSetting blobs replace empty-string defaults.) + +### Status bar redesign +**Per-instance (left) — `MinecraftInstance::getStatusbarDescription()`:** +- Dropped `Minecraft ` prefix (already shown on instance card) +- Dropped redundant per-instance "total played for X" (global widget covers it) +- Absolute timestamp `28/04/2026 14:15` → relative `2d ago` +- Comma separator → em dash `—` +- Duration: `3h 24min` → minutes only `204 min` (server parity) + +**Global (center):** +- `Total playtime: 16h 26min` → `Total: 986 min` (minutes only) + +**Result:** +``` +Before: Minecraft 1.21.10, last played on 28/04/2026 14:15 for 3h 24min, total played for 16h 26min Total playtime: 16h 26min +After: last played 2d ago — 204 min Total: 986 min +``` + +**New helper:** `Time::relativePast(QDateTime)` in `MMCTime.{h,cpp}` — buckets `just now`, `Nm/h/d/w/mo/y ago`. + +`Time::prettifyDuration` "min"/"m" suffix shortened to `m` (only call site is status bar). + +### Help menu cleanup +Reddit + Discord + Matrix entries → single **Website** entry pointing at `https://racked.ru`. +- `MainWindow.ui`, `MainWindow.h`, `MainWindow.cpp` — actions/slots/handlers consolidated + +### First-launch UX +If `accounts->count() == 0` on startup, show offline-username dialog (skip-able). Hooked at end of `MainWindow` ctor via `QTimer::singleShot(0, ...)`. + +### ChooseOfflineNameDialog cleanup +- Window title: `Choose Offline Name` → `Pick a username` +- Removed `Allow invalid usernames` checkbox + slot +- Removed `Message label placeholder` body label +- Constructor still takes `message` arg for ABI compat (now unused via `Q_UNUSED`) +- Validator unchanged: `[A-Za-z0-9_]{3,16}` + +### Bloat strips +**Source-side (~4M removed, ~2M off compiled binary):** +- `.github/` (136K) +- `tests/` + `BUILD_TESTING` block in root `CMakeLists.txt` (928K) +- `nix/`, `flake.nix`, `flake.lock` (28K) +- `launcher/resources/multimc/` icon theme (3M) + +**multimc → racked_ru migration:** +- 71 instance icons (32x32, 50x50, 128x128, scalable) copied into `launcher/resources/racked_ru/` +- `racked_ru.qrc` extended with `prefix="/icons/racked_ru"` resource block +- `Application.cpp:950` instance icon paths repointed `multimc` → `racked_ru` +- `ThemeManager.h:94` `builtinIcons{"flat_white", "multimc"}` → `{"flat_white", "racked_ru"}` +- `launcher/CMakeLists.txt:1284` dropped `resources/multimc/multimc.qrc` from `qt_add_resources()` +- `launcher/main.cpp:56` dropped `Q_INIT_RESOURCE(multimc);` + +**Runtime-side (`/home/admin/ai-lab/_projects/_minecraft/launcher/`, ~200M removed):** +- `java/java21.tar.gz` (extracted leftover): 198M +- Syncthing `*sync-conflict*` files: ~340K +- `cache/*` wipe: 5.5M + +### Project relocation +Moved source from `_projects/_minecraft/source/` → `_github/minecraft-launcher/` (sibling pattern to `_github/minecraft-server`). Runtime build target stays at `_projects/_minecraft/launcher/` for daily-driver testing. Existing `.gitignore` excludes `build-*`, `install-*`, `release/`. + +### Documentation +New docs in `docs/`: +- `SETTINGS_AUDIT.md` — runtime cfg vs source defaults diff +- `NETWORK_AUDIT.md` — full endpoint inventory; zero telemetry confirmed +- `BLOAT_AUDIT.md` — strippability tiers +- `CHANGES_2026-04-30.md` — pre-changelog session notes (superseded by this file) + +--- + +## Pending +- Replace placeholder `s8n-ru/minecraft-launcher` repo URL with real GitHub slug +- Stand up `https://racked.ru/feed.xml` (RSS feed) so news pane populates +- Decide: ship `accounts.json`-free portable release (already clean in `release/`) diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..72d2f5f --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,504 @@ +cmake_minimum_required(VERSION 3.25) # Required for features like `CMAKE_MSVC_DEBUG_INFORMATION_FORMAT` + +project(Launcher LANGUAGES C CXX) +if(APPLE) + enable_language(OBJC OBJCXX) +endif() + +string(COMPARE EQUAL "${CMAKE_SOURCE_DIR}" "${CMAKE_BUILD_DIR}" IS_IN_SOURCE_BUILD) +if(IS_IN_SOURCE_BUILD) + message(FATAL_ERROR "You are building the Launcher in-source. Please separate the build tree from the source tree.") +endif() + +##################################### Set CMake options ##################################### +set(CMAKE_AUTOMOC ON) +set(CMAKE_AUTORCC ON) +set(CMAKE_INCLUDE_CURRENT_DIR ON) + +set(CMAKE_MODULE_PATH "${PROJECT_SOURCE_DIR}/cmake/") + +# Output all executables and shared libs in the main build folder, not in subfolders. +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${PROJECT_BINARY_DIR}) +if(UNIX) + set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${PROJECT_BINARY_DIR}) +endif() +set(CMAKE_JAVA_TARGET_OUTPUT_DIR ${PROJECT_BINARY_DIR}/jars) + +######## Set compiler flags ######## +set(CMAKE_CXX_STANDARD_REQUIRED true) +set(CMAKE_C_STANDARD_REQUIRED true) +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_C_STANDARD 11) +include(GenerateExportHeader) + +add_compile_definitions($<$>:QT_NO_DEBUG>) +add_compile_definitions(QT_WARN_DEPRECATED_UP_TO=0x060400) +add_compile_definitions(QT_DISABLE_DEPRECATED_UP_TO=0x060400) + +if(CMAKE_CXX_COMPILER_FRONTEND_VARIANT STREQUAL "MSVC") + add_compile_options( + # /GS Adds buffer security checks, default on but included anyway to mirror gcc's fstack-protector flag + "$<$:/GS>" + + # /Gw helps reduce binary size + # /Gy allows the compiler to package individual functions + # /guard:cf enables control flow guard + "$<$,$>:/Gw;/Gy;/guard:cf>" + ) + + add_link_options( + # /LTCG allows for linking wholy optimizated programs + # /MANIFEST:NO disables generating a manifest file, we instead provide our own + # /STACK sets the stack reserve size, ATL's pack list needs 3-4 MiB as of November 2022, provide 8 MiB + "$<$:/LTCG;/MANIFEST:NO;/STACK:8388608>" + ) + + # /GL enables whole program optimizations + # NOTE: With Clang, this is implemented as regular LTO and only used during linking + if(NOT CMAKE_CXX_COMPILER_ID STREQUAL "Clang") + add_compile_options("$<$,$>:/GL>") + endif() + + # See https://github.com/ccache/ccache/issues/1040 + # TODO(@getchoo): Is sccache affected by this? Would be nice to use `ProgramDatabase`.... + set(CMAKE_MSVC_DEBUG_INFORMATION_FORMAT "$<$:Embedded>") + + if(CMAKE_MSVC_RUNTIME_LIBRARY STREQUAL "MultiThreadedDLL") + set(CMAKE_MAP_IMPORTED_CONFIG_DEBUG Release "") + set(CMAKE_MAP_IMPORTED_CONFIG_RELWITHDEBINFO Release "") + endif() +else() + add_compile_options("$<$:-fstack-protector-strong;--param=ssp-buffer-size=4>") + + # Avoid re-defining _FORTIFY_SOURCE, as it can cause redefinition errors in setups that use it by default (i.e., package builds) + if(NOT (CMAKE_C_FLAGS MATCHES "-D_FORTIFY_SOURCE" OR CMAKE_CXX_FLAGS MATCHES "-D_FORTIFY_SOURCE")) + # NOTE: _FORTIFY_SOURCE requires optimizations in most newer versions of glibc + add_compile_options("$<$,$>:-D_FORTIFY_SOURCE=2>") + endif() + + # ATL's pack list needs more than the default 1 Mib stack on windows + if(CMAKE_SYSTEM_NAME STREQUAL "Windows") + add_link_options("$<$:-Wl,--stack,8388608>") + + # -ffunction-sections and -fdata-sections help reduce binary size + # -mguard=cf enables Control Flow Guard + # TODO: Look into -gc-sections to further reduce binary size + add_compile_options("$<$,$>:-ffunction-sections;-fdata-sections;-mguard=cf>") + endif() +endif() + +# Export compile commands for debug builds if we can (useful in LSPs like clangd) +# https://cmake.org/cmake/help/v3.31/variable/CMAKE_EXPORT_COMPILE_COMMANDS.html +if(CMAKE_GENERATOR STREQUAL "Unix Makefiles" OR CMAKE_GENERATOR MATCHES "^Ninja") + set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +endif() + +option(USE_CLANG_TIDY "Enable the use of clang-tidy during compilation" OFF) + +if(USE_CLANG_TIDY) + find_program(CLANG_TIDY clang-tidy OPTIONAL) + if(CLANG_TIDY) + message(STATUS "Using clang-tidy during compilation") + set(CLANG_TIDY_COMMAND "${CLANG_TIDY}" "--config-file=${CMAKE_SOURCE_DIR}/.clang-tidy") + set(CMAKE_CXX_CLANG_TIDY ${CLANG_TIDY_COMMAND}) + else() + message(WARNING "Unable to find `clang-tidy`. Not using during compilation") + endif() +endif() + +option(DEBUG_ADDRESS_SANITIZER "Enable Address Sanitizer in Debug builds" OFF) + +# If this is a Debug build turn on address sanitiser +if (DEBUG_ADDRESS_SANITIZER) + message(STATUS "Address Sanitizer enabled for Debug builds, Turn it off with -DDEBUG_ADDRESS_SANITIZER=off") + + set(USE_ASAN_COMPILE_OPTIONS $,$>) + if (CMAKE_CXX_COMPILER_FRONTEND_VARIANT STREQUAL "MSVC") + message(STATUS "Using Address Sanitizer compile options for MSVC frontend") + add_compile_options( + $<${USE_ASAN_COMPILE_OPTIONS}:/fsanitize=address> + $<${USE_ASAN_COMPILE_OPTIONS}:/Oy-> + ) + elseif ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "GNU" OR "${CMAKE_CXX_COMPILER_ID}" MATCHES "Clang") + message(STATUS "Using Address Sanitizer compile options for GCC/Clang") + add_compile_options( + $<${USE_ASAN_COMPILE_OPTIONS}:-fsanitize=address,undefined> + $<${USE_ASAN_COMPILE_OPTIONS}:-fno-omit-frame-pointer> + $<${USE_ASAN_COMPILE_OPTIONS}:-fno-sanitize-recover=null> + ) + link_libraries("asan" "ubsan") + else() + message(STATUS "Address Sanitizer not available on compiler ${CMAKE_CXX_COMPILER_ID}") + endif() +endif() + + +option(ENABLE_LTO "Enable Link Time Optimization" off) + +if(ENABLE_LTO) + include(CheckIPOSupported) + check_ipo_supported(RESULT ipo_supported OUTPUT ipo_error) + + if(ipo_supported) + set(CMAKE_INTERPROCEDURAL_OPTIMIZATION_RELEASE TRUE) + set(CMAKE_INTERPROCEDURAL_OPTIMIZATION_MINSIZEREL TRUE) + set(CMAKE_INTERPROCEDURAL_OPTIMIZATION_RELWITHDEBINFO TRUE) + if(CMAKE_BUILD_TYPE) + if(NOT CMAKE_BUILD_TYPE STREQUAL "Debug") + message(STATUS "IPO / LTO enabled") + else() + message(STATUS "Not enabling IPO / LTO on debug builds") + endif() + else() + message(STATUS "IPO / LTO will only be enabled for release builds") + endif() + else() + message(STATUS "IPO / LTO not supported: <${ipo_error}>") + endif() +endif() + +option(BUILD_TESTING "Build the testing tree." ON) + +find_package(ECM NO_MODULE REQUIRED) +set(CMAKE_MODULE_PATH "${ECM_MODULE_PATH};${CMAKE_MODULE_PATH}") + +include(CTest) +include(ECMAddTests) +if(BUILD_TESTING) + enable_testing() +endif() + +##################################### Set Application options ##################################### + +######## Set URLs ######## +set(Launcher_NEWS_RSS_URL "https://racked.ru/feed.xml" CACHE STRING "URL to fetch racked.ru news RSS feed from.") +set(Launcher_NEWS_OPEN_URL "https://racked.ru/news" CACHE STRING "URL that gets opened when the user clicks 'More News'") +set(Launcher_WIKI_URL "https://prismlauncher.org/wiki/" CACHE STRING "URL that gets opened when the user clicks 'Launcher Help'") +set(Launcher_HELP_URL "https://prismlauncher.org/wiki/help-pages/%1" CACHE STRING "URL (with arg %1 to be substituted with page-id) that gets opened when the user requests help in a dialog window") +set(Launcher_LOGIN_CALLBACK_URL "https://prismlauncher.org/successful-login" CACHE STRING "URL that gets opened when the user successfully logins.") +set(Launcher_LEGACY_FMLLIBS_BASE_URL "https://files.prismlauncher.org/fmllibs/" CACHE STRING "URL for legacy (<=1.5.2) FML Libraries.") + +######## Set version numbers ######## +set(Launcher_VERSION_MAJOR 11) +set(Launcher_VERSION_MINOR 0) +set(Launcher_VERSION_PATCH 0) + +set(Launcher_VERSION_NAME "${Launcher_VERSION_MAJOR}.${Launcher_VERSION_MINOR}.${Launcher_VERSION_PATCH}") +set(Launcher_VERSION_NAME4 "${Launcher_VERSION_MAJOR}.${Launcher_VERSION_MINOR}.${Launcher_VERSION_PATCH}.0") +set(Launcher_VERSION_NAME4_COMMA "${Launcher_VERSION_MAJOR},${Launcher_VERSION_MINOR},${Launcher_VERSION_PATCH},0") + +# Build platform. +set(Launcher_BUILD_PLATFORM "unknown" CACHE STRING "A short string identifying the platform that this build was built for. Only used to display in the about dialog.") + +# Github repo URL with releases for updater +set(Launcher_UPDATER_GITHUB_REPO "https://github.com/s8n-ru/minecraft-launcher" CACHE STRING "Base github URL for the updater.") + +# Name to help updater identify valid artifacts +set(Launcher_BUILD_ARTIFACT "" CACHE STRING "Artifact name to help the updater identify valid artifacts.") + +# The metadata server +set(Launcher_META_URL "https://meta.prismlauncher.org/v1/" CACHE STRING "URL to fetch Launcher's meta files from.") + +# Imgur API Client ID +set(Launcher_IMGUR_CLIENT_ID "5b97b0713fba4a3" CACHE STRING "Client ID you can get from Imgur when you register an application") + +# Bug tracker URL +set(Launcher_BUG_TRACKER_URL "https://github.com/s8n-ru/minecraft-launcher/issues" CACHE STRING "URL for the bug tracker.") + +# Translations Platform URL +set(Launcher_TRANSLATIONS_URL "https://hosted.weblate.org/projects/prismlauncher/launcher/" CACHE STRING "URL for the translations platform.") +set(Launcher_TRANSLATION_FILES_URL "https://i18n.prismlauncher.org/" CACHE STRING "URL for the translations files.") + +# Matrix Space +set(Launcher_MATRIX_URL "https://prismlauncher.org/matrix" CACHE STRING "URL to the Matrix Space") + +# Discord URL +set(Launcher_DISCORD_URL "https://prismlauncher.org/discord" CACHE STRING "URL for the Discord guild.") + +# Subreddit URL +set(Launcher_SUBREDDIT_URL "https://prismlauncher.org/reddit" CACHE STRING "URL for the subreddit.") + +# Builds +set(Launcher_QT_VERSION_MAJOR "6" CACHE STRING "Major Qt version to build against") + +option(Launcher_USE_PCH "Use precompiled headers where possible" ON) + +# Java downloader +set(Launcher_ENABLE_JAVA_DOWNLOADER_DEFAULT ON) + +# Although we recommend enabling this, we cannot guarantee binary compatibility on +# differing Linux/BSD/etc distributions. Downstream packagers should be explicitly opt-ing into this +# feature if they know it will work with their distribution. +if(UNIX AND NOT APPLE) + set(Launcher_ENABLE_JAVA_DOWNLOADER_DEFAULT OFF) +endif() + +# Java downloader +option(Launcher_ENABLE_JAVA_DOWNLOADER "Build the java downloader feature" ${Launcher_ENABLE_JAVA_DOWNLOADER_DEFAULT}) + +# Native libraries +if(UNIX AND APPLE) + set(Launcher_GLFW_LIBRARY_NAME "libglfw.dylib" CACHE STRING "Name of native glfw library") + set(Launcher_OPENAL_LIBRARY_NAME "libopenal.dylib" CACHE STRING "Name of native openal library") +elseif(UNIX) + set(Launcher_GLFW_LIBRARY_NAME "libglfw.so" CACHE STRING "Name of native glfw library") + set(Launcher_OPENAL_LIBRARY_NAME "libopenal.so" CACHE STRING "Name of native openal library") +elseif(WIN32) + set(Launcher_GLFW_LIBRARY_NAME "glfw.dll" CACHE STRING "Name of native glfw library") + set(Launcher_OPENAL_LIBRARY_NAME "OpenAL.dll" CACHE STRING "Name of native openal library") +endif() + +# API Keys +# NOTE: These API keys are here for convenience. If you rebrand this software or intend to break the terms of service +# of these platforms, please change these API keys beforehand. +# Be aware that if you were to use these API keys for malicious purposes they might get revoked, which might cause +# breakage to thousands of users. +# If you don't plan to use these features of this software, you can just remove these values. + +# By using this key in your builds you accept the terms of use laid down in +# https://docs.microsoft.com/en-us/legal/microsoft-identity-platform/terms-of-use +set(Launcher_MSA_CLIENT_ID "c36a9fb6-4f2a-41ff-90bd-ae7cc92031eb" CACHE STRING "Client ID you can get from Microsoft Identity Platform when you register an application") + +# By using this key in your builds you accept the terms and conditions laid down in +# https://support.curseforge.com/en/support/solutions/articles/9000207405-curse-forge-3rd-party-api-terms-and-conditions +# NOTE: CurseForge requires you to change this if you make any kind of derivative work. +# This key was issued specifically for Prism Launcher +set(Launcher_CURSEFORGE_API_KEY "$2a$10$wuAJuNZuted3NORVmpgUC.m8sI.pv1tOPKZyBgLFGjxFp/br0lZCC" CACHE STRING "API key for the CurseForge platform") + +set(Launcher_COMPILER_NAME ${CMAKE_CXX_COMPILER_ID}) +set(Launcher_COMPILER_VERSION ${CMAKE_CXX_COMPILER_VERSION}) +set(Launcher_COMPILER_TARGET_SYSTEM ${CMAKE_SYSTEM_NAME}) +set(Launcher_COMPILER_TARGET_SYSTEM_VERSION ${CMAKE_SYSTEM_VERSION}) +set(Launcher_COMPILER_TARGET_PROCESSOR ${CMAKE_SYSTEM_PROCESSOR}) + +#### Check the current Git commit and branch +include(GetGitRevisionDescription) +git_get_exact_tag(Launcher_GIT_TAG) +get_git_head_revision(Launcher_GIT_REFSPEC Launcher_GIT_COMMIT) + +message(STATUS "Git commit: ${Launcher_GIT_COMMIT}") +message(STATUS "Git tag: ${Launcher_GIT_TAG}") +message(STATUS "Git refspec: ${Launcher_GIT_REFSPEC}") + +string(TIMESTAMP TODAY "%Y-%m-%d") +set(Launcher_BUILD_TIMESTAMP "${TODAY}") + +################################ 3rd Party Libs ################################ + +# Find the required Qt parts +if(Launcher_QT_VERSION_MAJOR EQUAL 6) + set(QT_VERSION_MAJOR 6) + find_package(Qt6 6.4 REQUIRED COMPONENTS Core CoreTools Widgets Concurrent Network Test Xml NetworkAuth OpenGL) + find_package(Qt6 COMPONENTS DBus) + list(APPEND Launcher_QT_DBUS Qt6::DBus) +else() + message(FATAL_ERROR "Qt version ${Launcher_QT_VERSION_MAJOR} is not supported") +endif() + +if(Launcher_QT_VERSION_MAJOR EQUAL 6) + set(QT_PLUGINS_DIR ${QT${QT_VERSION_MAJOR}_INSTALL_PREFIX}/${QT${QT_VERSION_MAJOR}_INSTALL_PLUGINS}) + set(QT_LIBS_DIR ${QT${QT_VERSION_MAJOR}_INSTALL_PREFIX}/${QT${QT_VERSION_MAJOR}_INSTALL_LIBS}) + set(QT_LIBEXECS_DIR ${QT${QT_VERSION_MAJOR}_INSTALL_PREFIX}/${QT${QT_VERSION_MAJOR}_INSTALL_LIBEXECS}) +endif() + +find_package(cmark REQUIRED) + +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + find_package(PkgConfig REQUIRED) + pkg_check_modules(gamemode REQUIRED IMPORTED_TARGET gamemode) +endif() + +# Find libqrencode +## NOTE(@getchoo): Never use pkg-config with MSVC since the vcpkg port makes our install bundle fail to find the dll +if(MSVC) + find_path(LIBQRENCODE_INCLUDE_DIR qrencode.h REQUIRED) + find_library(LIBQRENCODE_LIBRARY_RELEASE qrencode REQUIRED) + find_library(LIBQRENCODE_LIBRARY_DEBUG qrencoded) + set(LIBQRENCODE_LIBRARIES optimized ${LIBQRENCODE_LIBRARY_RELEASE} debug ${LIBQRENCODE_LIBRARY_DEBUG}) +else() + find_package(PkgConfig REQUIRED) + pkg_check_modules(libqrencode REQUIRED IMPORTED_TARGET libqrencode) +endif() + +# Find libarchive through CMake packages, mainly for vcpkg +find_package(LibArchive) +# CMake packages aren't available in most distributions of libarchive, so fallback to pkg-config +if(NOT LibArchive_FOUND) + find_package(PkgConfig REQUIRED) + pkg_check_modules(libarchive REQUIRED IMPORTED_TARGET libarchive) +endif() + +find_package(tomlplusplus 3.2.0) +# fallback to pkgconfig, important especially as many distros package toml++ built with meson +if(NOT tomlplusplus_FOUND) + find_package(PkgConfig REQUIRED) + pkg_check_modules(tomlplusplus REQUIRED IMPORTED_TARGET tomlplusplus>=3.2.0) +endif() + +find_package(ZLIB REQUIRED) + + +include(ECMQtDeclareLoggingCategory) + +####################################### Program Info ####################################### + +set(Launcher_APP_BINARY_NAME "prismlauncher" CACHE STRING "Name of the Launcher binary") +add_subdirectory(program_info) + +####################################### Install layout ####################################### + +set(Launcher_ENABLE_UPDATER NO) +set(Launcher_BUILD_UPDATER NO) + +if (NOT APPLE AND (NOT Launcher_UPDATER_GITHUB_REPO STREQUAL "" AND NOT Launcher_BUILD_ARTIFACT STREQUAL "")) + set(Launcher_BUILD_UPDATER YES) +endif() + +if(NOT (UNIX AND APPLE)) + # Install "portable.txt" if selected component is "portable" + install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/${Launcher_Portable_File}" DESTINATION "." COMPONENT portable EXCLUDE_FROM_ALL) +endif() + +if(UNIX AND APPLE) + set(BINARY_DEST_DIR "${Launcher_Name}.app/Contents/MacOS") + set(LIBRARY_DEST_DIR "${Launcher_Name}.app/Contents/MacOS") + set(PLUGIN_DEST_DIR "${Launcher_Name}.app/Contents/MacOS") + set(FRAMEWORK_DEST_DIR "${Launcher_Name}.app/Contents/Frameworks") + set(RESOURCES_DEST_DIR "${Launcher_Name}.app/Contents/Resources") + set(JARS_DEST_DIR "${Launcher_Name}.app/Contents/MacOS/jars") + + # Mac bundle settings + set(MACOSX_BUNDLE_BUNDLE_NAME "${Launcher_DisplayName}") + set(MACOSX_BUNDLE_INFO_STRING "${Launcher_DisplayName}: A custom launcher for Minecraft that allows you to easily manage multiple installations of Minecraft at once.") + set(MACOSX_BUNDLE_GUI_IDENTIFIER "${Launcher_AppID}") + set(MACOSX_BUNDLE_BUNDLE_VERSION "${Launcher_VERSION_NAME}") + set(MACOSX_BUNDLE_SHORT_VERSION_STRING "${Launcher_VERSION_NAME}") + set(MACOSX_BUNDLE_LONG_VERSION_STRING "${Launcher_VERSION_NAME}") + set(MACOSX_BUNDLE_ICON_FILE ${Launcher_Name}.icns) + set(MACOSX_BUNDLE_COPYRIGHT "${Launcher_Copyright_Mac}") + set(MACOSX_SPARKLE_UPDATE_PUBLIC_KEY "v55ZWWD6QlPoXGV6VLzOTZxZUggWeE51X8cRQyQh6vA=" CACHE STRING "Public key for Sparkle update feed") + set(MACOSX_SPARKLE_UPDATE_FEED_URL "https://prismlauncher.org/feed/appcast.xml" CACHE STRING "URL for Sparkle update feed") + + set(MACOSX_SPARKLE_DOWNLOAD_URL "https://github.com/sparkle-project/Sparkle/releases/download/2.8.0/Sparkle-2.8.0.tar.xz" CACHE STRING "URL to Sparkle release archive") + set(MACOSX_SPARKLE_SHA256 "fd5681ee92bf238aaac2d08214ceaf0cc8976e452d7f882d80bac1e61581f3b1" CACHE STRING "SHA256 checksum for Sparkle release archive") + set(MACOSX_SPARKLE_DIR "${CMAKE_BINARY_DIR}/frameworks/Sparkle") + + if(NOT MACOSX_SPARKLE_UPDATE_PUBLIC_KEY STREQUAL "" AND NOT MACOSX_SPARKLE_UPDATE_FEED_URL STREQUAL "") + set(Launcher_ENABLE_UPDATER YES) + endif() + + # Add the icon + install(FILES ${Launcher_Branding_ICNS} DESTINATION ${RESOURCES_DEST_DIR} RENAME ${Launcher_Name}.icns) + + find_program(ACTOOL_EXE actool DOC "Path to the apple asset catalog compiler") + if(ACTOOL_EXE) + execute_process( + COMMAND xcodebuild -version + OUTPUT_VARIABLE XCODE_VERSION_OUTPUT + OUTPUT_STRIP_TRAILING_WHITESPACE + ) + + string(REGEX MATCH "Xcode ([0-9]+\.[0-9]+)" XCODE_VERSION_MATCH "${XCODE_VERSION_OUTPUT}") + if(XCODE_VERSION_MATCH) + set(XCODE_VERSION ${CMAKE_MATCH_1}) + else() + set(XCODE_VERSION 0.0) + endif() + + if(XCODE_VERSION VERSION_GREATER_EQUAL 26.0) + set(ASSETS_OUT_DIR "${CMAKE_BINARY_DIR}/program_info") + set(GENERATED_ASSETS_CAR "${ASSETS_OUT_DIR}/Assets.car") + set(ICON_SOURCE "${CMAKE_CURRENT_SOURCE_DIR}/${Launcher_Branding_MAC_ICON}") + + add_custom_command( + OUTPUT "${GENERATED_ASSETS_CAR}" + COMMAND ${ACTOOL_EXE} "${ICON_SOURCE}" + --compile "${ASSETS_OUT_DIR}" + --output-partial-info-plist /dev/null + --app-icon ${Launcher_Name} + --enable-on-demand-resources NO + --target-device mac + --minimum-deployment-target ${CMAKE_OSX_DEPLOYMENT_TARGET} + --platform macosx + DEPENDS "${ICON_SOURCE}" + COMMENT "Compiling asset catalog (${ICON_SOURCE})" + VERBATIM + ) + add_custom_target(compile_assets ALL DEPENDS "${GENERATED_ASSETS_CAR}") + install(FILES "${GENERATED_ASSETS_CAR}" DESTINATION "${RESOURCES_DEST_DIR}") + else() + message(WARNING "Xcode ${XCODE_VERSION} is too old. Minimum required version is 26.0. Not compiling liquid glass icons.") + endif() + + else() + message(WARNING "actool not found. Cannot compile macOS app icons.\n" + "Install Xcode command line tools: 'xcode-select --install'") + endif() + + +elseif(UNIX) + include(KDEInstallDirs) + + set(BINARY_DEST_DIR "bin") + set(LIBRARY_DEST_DIR "lib${LIB_SUFFIX}") + set(JARS_DEST_DIR "share/${Launcher_Name}") + + # Set RPATH + SET(Launcher_BINARY_RPATH "$ORIGIN/") + + install(FILES ${CMAKE_CURRENT_BINARY_DIR}/${Launcher_Desktop} DESTINATION ${KDE_INSTALL_APPDIR}) + install(FILES ${CMAKE_CURRENT_BINARY_DIR}/${Launcher_MetaInfo} DESTINATION ${KDE_INSTALL_METAINFODIR}) + install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/${Launcher_SVG} DESTINATION "${KDE_INSTALL_ICONDIR}/hicolor/scalable/apps") + install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/${Launcher_PNG_256} DESTINATION "${KDE_INSTALL_ICONDIR}/hicolor/256x256/apps" RENAME "${Launcher_AppID}.png") + install(FILES ${CMAKE_CURRENT_BINARY_DIR}/${Launcher_MIMEInfo} DESTINATION ${KDE_INSTALL_MIMEDIR}) + + install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/launcher/qtlogging.ini" DESTINATION "share/${Launcher_Name}") + + set(PLUGIN_DEST_DIR "plugins") + set(BUNDLE_DEST_DIR ".") + set(RESOURCES_DEST_DIR ".") + + if(Launcher_ManPage) + install(FILES ${CMAKE_CURRENT_BINARY_DIR}/${Launcher_ManPage} DESTINATION "${KDE_INSTALL_MANDIR}/man6") + endif() + + # Install basic runner script if component "portable" is selected + configure_file(launcher/Launcher.in "${CMAKE_CURRENT_BINARY_DIR}/LauncherScript" @ONLY) + install(PROGRAMS "${CMAKE_CURRENT_BINARY_DIR}/LauncherScript" DESTINATION "." RENAME ${Launcher_Name} COMPONENT portable EXCLUDE_FROM_ALL) + +elseif(WIN32) + set(BINARY_DEST_DIR ".") + set(LIBRARY_DEST_DIR ".") + set(PLUGIN_DEST_DIR ".") + set(RESOURCES_DEST_DIR ".") + set(JARS_DEST_DIR "jars") +else() + message(FATAL_ERROR "Platform not supported") +endif() + + + +################################ Included Libs ################################ + +include(ExternalProject) +set_directory_properties(PROPERTIES EP_BASE External) + +option(NBT_BUILD_SHARED "Build NBT shared library" OFF) +option(NBT_USE_ZLIB "Build NBT library with zlib support" OFF) +option(NBT_BUILD_TESTS "Build NBT library tests" OFF) #FIXME: fix unit tests. +add_subdirectory(libraries/libnbtplusplus) + +add_subdirectory(libraries/launcher) # java based launcher part for Minecraft +add_subdirectory(libraries/javacheck) # java compatibility checker + +add_subdirectory(libraries/rainbow) # Qt extension for colors +add_subdirectory(libraries/LocalPeer) # fork of a library from Qt solutions +add_subdirectory(libraries/murmur2) # Hash for usage with the CurseForge API +add_subdirectory(libraries/qdcss) # css parser + +############################### Built Artifacts ############################### + +add_subdirectory(buildconfig) + +# NOTE: this must always be last to appease the CMake deity of quirky install command evaluation order. +add_subdirectory(launcher) diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 0000000..f8496ac --- /dev/null +++ b/CMakePresets.json @@ -0,0 +1,222 @@ +{ + "$schema": "https://cmake.org/cmake/help/latest/_downloads/3e2d73bff478d88a7de0de736ba5e361/schema.json", + "version": 8, + "cmakeMinimumRequired": { + "major": 3, + "minor": 28 + }, + "configurePresets": [ + { + "name": "base", + "hidden": true, + "binaryDir": "build", + "installDir": "install", + "generator": "Ninja Multi-Config", + "cacheVariables": { + "Launcher_BUILD_ARTIFACT": "$penv{ARTIFACT_NAME}", + "Launcher_BUILD_PLATFORM": "$penv{BUILD_PLATFORM}", + "Launcher_ENABLE_JAVA_DOWNLOADER": "ON", + "ENABLE_LTO": "ON" + } + }, + { + "name": "linux", + "displayName": "Linux", + "inherits": [ + "base" + ], + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Linux" + } + }, + { + "name": "macos", + "displayName": "macOS", + "inherits": [ + "base" + ], + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Darwin" + }, + "cacheVariables": { + "CMAKE_TOOLCHAIN_FILE": "$penv{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake" + } + }, + { + "name": "macos_universal", + "displayName": "macOS (Universal Binary)", + "inherits": [ + "macos" + ], + "cacheVariables": { + "CMAKE_TOOLCHAIN_FILE": "$penv{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake", + "CMAKE_OSX_ARCHITECTURES": "x86_64;arm64", + "VCPKG_TARGET_TRIPLET": "universal-osx" + } + }, + { + "name": "windows_mingw", + "displayName": "Windows (MinGW)", + "inherits": [ + "base" + ], + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Windows" + } + }, + { + "name": "windows_msvc", + "displayName": "Windows (MSVC)", + "inherits": [ + "base" + ], + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Windows" + }, + "cacheVariables": { + "CMAKE_TOOLCHAIN_FILE": "$penv{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake" + } + } + ], + "buildPresets": [ + { + "name": "linux", + "displayName": "Linux", + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Linux" + }, + "configurePreset": "linux" + }, + { + "name": "macos", + "displayName": "macOS", + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Darwin" + }, + "configurePreset": "macos" + }, + { + "name": "macos_universal", + "displayName": "macOS (Universal Binary)", + "inherits": [ + "macos" + ], + "configurePreset": "macos_universal" + }, + { + "name": "windows_mingw", + "displayName": "Windows (MinGW)", + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Windows" + }, + "configurePreset": "windows_mingw" + }, + { + "name": "windows_msvc", + "displayName": "Windows (MSVC)", + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Windows" + }, + "configurePreset": "windows_msvc" + } + ], + "testPresets": [ + { + "name": "base", + "hidden": true, + "output": { + "outputOnFailure": true, + "verbosity": "extra" + }, + "execution": { + "noTestsAction": "error" + }, + "filter": { + "exclude": { + "name": "^example64|example$" + } + } + }, + { + "name": "linux", + "displayName": "Linux", + "inherits": [ + "base" + ], + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Linux" + }, + "configurePreset": "linux" + }, + { + "name": "macos", + "displayName": "macOS", + "inherits": [ + "base" + ], + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Darwin" + }, + "configurePreset": "macos" + }, + { + "name": "macos_universal", + "displayName": "macOS (Universal Binary)", + "inherits": [ + "base" + ], + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Darwin" + }, + "configurePreset": "macos_universal" + }, + { + "name": "windows_mingw", + "displayName": "Windows (MinGW)", + "inherits": [ + "base" + ], + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Windows" + }, + "configurePreset": "windows_mingw" + }, + { + "name": "windows_msvc", + "displayName": "Windows (MSVC)", + "inherits": [ + "base" + ], + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Windows" + }, + "configurePreset": "windows_msvc" + } + ] +} diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..defa217 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,136 @@ +# Contributor Covenant Code of Conduct + +This is a modified version of the Contributor Covenant. +See commit history to see our changes. + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling (antagonistic, inflammatory, insincere behaviour), insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement via email at +[coc@scrumplex.net](mailto:coc@scrumplex.net) (Email +address subject to change). +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f4b12d0 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,165 @@ +# Contributions Guidelines + +## Restrictions on Generative AI Usage (AI Policy) + +> [!NOTE] +> The following is adapted from [matplotlib's contributing guide](https://matplotlib.org/devdocs/devel/contribute.html#generative-ai) and the [Linux Kernel policy guide](https://www.kernel.org/doc./html/next/process/coding-assistants.html) + +We expect authentic engagement in our community. + +- Do not post output from Large Language Models or similar generative AI as comments on GitHub or our discord server, as such comments tend to be formulaic and low-quality content. +- If you use generative AI tools as an aid in developing code or documentation changes, ensure that you fully understand the proposed changes and can explain why they are the correct approach. + +Make sure you have added value based on your personal competency to your contributions. +Just taking some input, feeding it to an AI and posting the result is not of value to the project. +To preserve precious core developer capacity, we reserve the right to rigorously reject seemingly AI generated low-value contributions. + +### Signed-off-by and Developer Certificate of Origin + +AI agents MUST NOT add Signed-off-by tags. Only humans can legally certify the Developer Certificate of Origin (DCO). The human submitter is responsible for: + +- Reviewing all AI-generated code +- Ensuring compliance with licensing requirements +- Adding their own Signed-off-by tag to certify the DCO +- Taking full responsibility for the contribution + +See [Signing your work](#signing-your-work) for more information. + +### Attribution + +When AI tools contribute to development, proper attribution helps track the evolving role of AI in the development process. Contributions should include an Assisted-by tag in the commit message with the following format: + +```text +Assisted-by: AGENT_NAME:MODEL_VERSION [TOOL1] [TOOL2] +``` + +Where: + +- `AGENT_NAME` is the name of the AI tool or framework +- `MODEL_VERSION` is the specific model version used +- `[TOOL1] [TOOL2]` are optional specialized analysis tools used (e.g., coccinelle, sparse, smatch, clang-tidy) + +Basic development tools (git, gcc, make, editors) should not be listed. + +Example: + +```text +Assisted-by: Claude:claude-3-opus coccinelle sparse +``` + +## Code style + +All files are formatted with `clang-format` using the configuration in `.clang-format`. Ensure it is run on changed files before committing! + +Please also follow the project's conventions for C++: + +- Class and type names should be formatted as `PascalCase`: `MyClass`. +- Private or protected class data members should be formatted as `camelCase` prefixed with `m_`: `m_myCounter`. +- Private or protected `static` class data members should be formatted as `camelCase` prefixed with `s_`: `s_instance`. +- Public class data members should be formatted as `camelCase` without the prefix: `dateOfBirth`. +- Public, private or protected `static const` class data members should be formatted as `SCREAMING_SNAKE_CASE`: `MAX_VALUE`. +- Class function members should be formatted as `camelCase` without a prefix: `incrementCounter`. +- Global functions and non-`const` global variables should be formatted as `camelCase` without a prefix: `globalData`. +- `const` global variables and macros should be formatted as `SCREAMING_SNAKE_CASE`: `LIGHT_GRAY`. +- enum constants should be formatted as `PascalCase`: `CamelusBactrianus` +- Avoid inventing acronyms or abbreviations especially for a name of multiple words - like `tp` for `texturePack`. +- Avoid using `[[nodiscard]]` unless ignoring the return value is likely to cause a bug in cases such as: + - A function allocates memory or another resource and the caller needs to clean it up. + - A function has side effects and an error status is returned. + - A function is likely be mistaken for having side effects. +- A plain getter is unlikely to cause confusion and adding `[[nodiscard]]` can create clutter and inconsistency. + +Most of these rules are included in the `.clang-tidy` file, so you can run `clang-tidy` to check for any violations. + +Here is what these conventions with the formatting configuration look like: + +```c++ +#define AWESOMENESS 10 + +constexpr double PI = 3.14159; + +enum class PizzaToppings { HamAndPineapple, OreoAndKetchup }; + +struct Person { + QString name; + QDateTime dateOfBirth; + + long daysOld() const { return dateOfBirth.daysTo(QDateTime::currentDateTime()); } +}; + +class ImportantClass { + public: + void incrementCounter() + { + if (m_counter + 1 > MAX_COUNTER_VALUE) + throw std::runtime_error("Counter has reached limit!"); + + ++m_counter; + } + + int counter() const { return m_counter; } + + private: + static constexpr int MAX_COUNTER_VALUE = 100; + int m_counter; +}; + +ImportantClass importantClassInstance; +``` + +If you see any names which do not follow these conventions, it is preferred that you leave them be - renames increase the number of changes therefore make reviewing harder and make your PR more prone to conflicts. However, if you're refactoring a whole class anyway, it's fine. + +## Signing your work + +In an effort to ensure that the code you contribute is actually compatible with the licenses in this codebase, we require you to sign-off all your contributions. + +This can be done by appending `-s` to your `git commit` call, or by manually appending the following text to your commit message: + +```text + + +Signed-off-by: Author name +``` + +By signing off your work, you agree to the terms below: + +```text +Developer's Certificate of Origin 1.1 + +By making a contribution to this project, I certify that: + +(a) The contribution was created in whole or in part by me and I + have the right to submit it under the open source license + indicated in the file; or + +(b) The contribution is based upon previous work that, to the best + of my knowledge, is covered under an appropriate open source + license and I have the right under that license to submit that + work with modifications, whether created in whole or in part + by me, under the same open source license (unless I am + permitted to submit under a different license), as indicated + in the file; or + +(c) The contribution was provided directly to me by some other + person who certified (a), (b) or (c) and I have not modified + it. + +(d) I understand and agree that this project and the contribution + are public and that a record of the contribution (including all + personal information I submit with it, including my sign-off) is + maintained indefinitely and may be redistributed consistent with + this project or the open source license(s) involved. +``` + +These terms will be enforced once you create a pull request, and you will be informed automatically if any of your commits aren't signed-off by you. + +As a bonus, you can also [cryptographically sign your commits][gh-signing-commits] and enable [vigilant mode][gh-vigilant-mode] on GitHub. + +[gh-signing-commits]: https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits +[gh-vigilant-mode]: https://docs.github.com/en/authentication/managing-commit-signature-verification/displaying-verification-statuses-for-all-of-your-commits + +## Backporting to Release Branches + +We use [automated backports](https://github.com/PrismLauncher/PrismLauncher/blob/develop/.github/workflows/backport.yml) to merge specific contributions from develop into `release` branches. + +This is done when pull requests are merged and have labels such as `backport release-7.x` - which should be added along with the milestone for the release. diff --git a/COPYING.md b/COPYING.md new file mode 100644 index 0000000..52f29f2 --- /dev/null +++ b/COPYING.md @@ -0,0 +1,422 @@ +## Prism Launcher + + Prism Launcher - Minecraft Launcher + Copyright (C) 2022-2026 Prism Launcher Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, version 3. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + + This file incorporates work covered by the following copyright and + permission notice: + + Copyright 2013-2021 MultiMC Contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +## PolyMC + + PolyMC - Minecraft Launcher + Copyright (C) 2021-2022 PolyMC Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, version 3. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + + This file incorporates work covered by the following copyright and + permission notice: + + Copyright 2013-2021 MultiMC Contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +## MinGW-w64 runtime (Windows) + + Copyright (c) 2009, 2010, 2011, 2012, 2013 by the mingw-w64 project + + This license has been certified as open source. It has also been designated + as GPL compatible by the Free Software Foundation (FSF). + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + 1. Redistributions in source code must retain the accompanying copyright + notice, this list of conditions, and the following disclaimer. + 2. Redistributions in binary form must reproduce the accompanying + copyright notice, this list of conditions, and the following disclaimer + in the documentation and/or other materials provided with the + distribution. + 3. Names of the copyright holders must not be used to endorse or promote + products derived from this software without prior written permission + from the copyright holders. + 4. The right to distribute this software or to use it for any purpose does + not give you the right to use Servicemarks (sm) or Trademarks (tm) of + the copyright holders. Use of them is covered by separate agreement + with the copyright holders. + 5. If any files are modified, you must cause the modified files to carry + prominent notices stating that you changed the files and the date of + any change. + + Disclaimer + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY EXPRESSED + OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY DIRECT, INDIRECT, + INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, + OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + Information on third party licenses used in MinGW-w64 can be found in its COPYING.MinGW-w64-runtime.txt. + +## Qt 6 + + Copyright (C) 2022 The Qt Company Ltd and other contributors. + Contact: https://www.qt.io/licensing + + Licensed under LGPL v3 + +## libnbt++ + + libnbt++ - A library for the Minecraft Named Binary Tag format. + Copyright (C) 2013, 2015 ljfa-ag + + libnbt++ is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + libnbt++ is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with libnbt++. If not, see . + +## rainbow (KGuiAddons) + + Copyright (C) 2007 Matthew Woehlke + Copyright (C) 2007 Olaf Schmidt + Copyright (C) 2007 Thomas Zander + Copyright (C) 2007 Zack Rusin + Copyright (C) 2015 Petr Mrazek + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Library General Public + License as published by the Free Software Foundation; either + version 2 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Library General Public License for more details. + + You should have received a copy of the GNU Library General Public License + along with this library; see the file COPYING.LIB. If not, write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA 02110-1301, USA. + +## cmark + + Copyright (c) 2014, John MacFarlane + + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +## Batch icon set + + You are free to use Batch (the "icon set") or any part thereof (the "icons") + in any personal, open-source or commercial work without obligation of payment + (monetary or otherwise) or attribution. Do not sell the icon set, host + the icon set or rent the icon set (either in existing or modified form). + + While attribution is optional, it is always appreciated. + + Intellectual property rights are not transferred with the download of the icons. + + EXCEPT TO THE EXTENT REQUIRED BY APPLICABLE LAW, IN NO EVENT WILL ADAM WHITCROFT + BE LIABLE TO YOU ON ANY LEGAL THEORY FOR ANY SPECIAL, INCIDENTAL, CONSEQUENTIAL, + PUNITIVE OR EXEMPLARY DAMAGES ARISING OUT OF THE USE OF THE ICONS, + EVEN IF LICENSOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +## Material Design Icons + + Copyright (c) 2014, Austin Andrews (http://materialdesignicons.com/), + with Reserved Font Name Material Design Icons. + Copyright (c) 2014, Google (http://www.google.com/design/) + uses the license at https://github.com/google/material-design-icons/blob/master/LICENSE + + This Font Software is licensed under the SIL Open Font License, Version 1.1. + This license is copied below, and is also available with a FAQ at: + http://scripts.sil.org/OFL + +## launcher (`libraries/launcher`) + + PolyMC - Minecraft Launcher + Copyright (C) 2021-2022 PolyMC Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, version 3. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + Linking this library statically or dynamically with other modules is + making a combined work based on this library. Thus, the terms and + conditions of the GNU General Public License cover the whole + combination. + + As a special exception, the copyright holders of this library give + you permission to link this library with independent modules to + produce an executable, regardless of the license terms of these + independent modules, and to copy and distribute the resulting + executable under terms of your choice, provided that you also meet, + for each linked independent module, the terms and conditions of the + license of that module. An independent module is a module which is + not derived from or based on this library. If you modify this + library, you may extend this exception to your version of the + library, but you are not obliged to do so. If you do not wish to do + so, delete this exception statement from your version. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + + This file incorporates work covered by the following copyright and + permission notice: + + Copyright 2013-2021 MultiMC Contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +## lionshead + + Code has been taken from https://github.com/natefoo/lionshead and loosely + translated to C++ laced with Qt. + + MIT License + + Copyright (c) 2017 Nate Coraor + + 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. + +## tomlplusplus + + MIT License + + Copyright (c) Mark Gillard + + 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. + +## Gamemode + + Copyright (c) 2017-2022, Feral Interactive + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Feral Interactive nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. + +## Breeze icons + + Copyright (C) 2014 Uri Herrera and others + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 3 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library. If not, see . + +## Oxygen Icons + + The Oxygen Icon Theme + Copyright (C) 2007 Nuno Pinheiro + Copyright (C) 2007 David Vignoni + Copyright (C) 2007 David Miller + Copyright (C) 2007 Johann Ollivier Lapeyre + Copyright (C) 2007 Kenneth Wimer + Copyright (C) 2007 Riccardo Iaconelli + + and others + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 3 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library. If not, see . + +## libqrencode (`fukuchi/libqrencode`) + + Copyright (C) 2020 libqrencode Authors + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +## vcpkg (`cmake/vcpkg-ports`) + + MIT License + + Copyright (c) Microsoft Corporation + + 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. diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..8ee4330 --- /dev/null +++ b/Containerfile @@ -0,0 +1,74 @@ +ARG DEBIAN_VERSION=stable-slim + +FROM docker.io/library/debian:${DEBIAN_VERSION} + +ARG QT_ABI=gcc_64 +ARG QT_ARCH= +ARG QT_HOST=linux +ARG QT_TARGET=desktop +ARG QT_VERSION=6.10.2 + +ARG DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get --assume-yes upgrade \ + && apt-get --assume-yes autopurge + +# Use Adoptium for Java 17 +RUN apt-get --assume-yes --no-install-recommends install \ + apt-transport-https ca-certificates curl gpg +RUN curl -L https://packages.adoptium.net/artifactory/api/gpg/key/public | gpg --dearmor | tee /etc/apt/trusted.gpg.d/adoptium.gpg +RUN echo "deb https://packages.adoptium.net/artifactory/deb $(awk -F= '/^VERSION_CODENAME/{print$2}' /etc/os-release) main" | tee /etc/apt/sources.list.d/adoptium.list +RUN apt-get update + +# Install base dependencies +RUN apt-get --assume-yes --no-install-recommends install \ + # Compilers + clang lld llvm temurin-17-jdk \ + # Build system + cmake ninja-build extra-cmake-modules pkg-config \ + # Dependencies + cmark gamemode-dev libarchive-dev libcmark-dev libgamemode0 libgl1-mesa-dev libqrencode-dev libtomlplusplus-dev libvulkan-dev scdoc zlib1g-dev \ + # Tooling + clang-format clang-tidy git + +# Use LLD by default for faster linking +ENV CMAKE_LINKER_TYPE=lld + +# Prepare and install Qt +## Setup UTF-8 locale (required, apparently) +RUN apt-get --assume-yes --no-install-recommends install locales +RUN echo "C.UTF-8 UTF-8" > /etc/locale.gen +RUN locale-gen +ENV LC_ALL=C.UTF-8 + +## Some libraries are required for the official binaries +RUN apt-get --assume-yes --no-install-recommends install \ + libglib2.0-0t64 libxkbcommon0 python3-pip + +RUN pip3 install --break-system-packages aqtinstall +RUN aqt install-qt \ + ${QT_HOST} ${QT_TARGET} ${QT_VERSION} ${QT_ARCH} \ + --outputdir /opt/qt \ + --modules qtimageformats qtnetworkauth + +ENV PATH=/opt/qt/${QT_VERSION}/${QT_ABI}/bin:$PATH +ENV QT_PLUGIN_PATH=/opt/qt/${QT_VERSION}/${QT_ABI}/plugins/ + +## We don't use these. Nuke them +RUN rm -rf \ + "$QT_PLUGIN_PATH"/designer \ + "$QT_PLUGIN_PATH"/help \ + # "$QT_PLUGIN_PATH"/platformthemes/libqgtk3.so \ + "$QT_PLUGIN_PATH"/printsupport \ + "$QT_PLUGIN_PATH"/qmllint \ + "$QT_PLUGIN_PATH"/qmlls \ + "$QT_PLUGIN_PATH"/qmltooling \ + "$QT_PLUGIN_PATH"/sqldrivers + +# Setup workspace +RUN mkdir /work +WORKDIR /work + +ENTRYPOINT ["bash"] +CMD ["-i"] diff --git a/INSTALL_DEPS.sh b/INSTALL_DEPS.sh new file mode 100755 index 0000000..e424a8a --- /dev/null +++ b/INSTALL_DEPS.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# Install dependencies for building Racked.ru PrismLauncher on Fedora + +echo "==============================================" +echo "Installing Build Dependencies" +echo "==============================================" +echo "" +echo "This will install the required packages to build the launcher." +echo "You will need to enter your sudo password." +echo "" + +read -p "Continue? [y/N] " -n 1 -r +echo + +if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "Installation cancelled." + exit 1 +fi + +sudo dnf install -y \ + cmake \ + gcc-c++ \ + make \ + extra-cmake-modules \ + qt6-qtbase-devel \ + qt6-qttools-devel \ + qt6-qtsvg-devel \ + qt6-qtnetworkauth-devel \ + qt6-qtimageformats \ + zlib-devel \ + mesa-libGL-devel \ + cmark-devel \ + gamemode-devel \ + git \ + pkgconfig + +echo "" +echo "==============================================" +echo "Verifying Installation" +echo "==============================================" +echo "" + +if command -v cmake &> /dev/null; then + echo "✓ CMake installed: $(cmake --version | head -n1)" +else + echo "✗ CMake installation failed" + exit 1 +fi + +if command -v qmake6 &> /dev/null; then + echo "✓ Qt6 installed: $(qmake6 -query QT_VERSION)" +else + echo "✗ Qt6 installation failed" + exit 1 +fi + +echo "" +echo "==============================================" +echo "All dependencies installed successfully!" +echo "==============================================" +echo "" +echo "You can now build the launcher by running:" +echo " bash scripts/build-linux-portable.sh" +echo "" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/PROJECT_SUMMARY.md b/PROJECT_SUMMARY.md new file mode 100644 index 0000000..7eb449f --- /dev/null +++ b/PROJECT_SUMMARY.md @@ -0,0 +1,266 @@ +# Racked.ru PrismLauncher - Project Summary + +## What Was Done + +Your custom PrismLauncher has been successfully merged with the upstream PrismLauncher fork and configured for minimal, portable builds across all platforms. + +## Project Structure + +``` +prismlauncher-racked/ +├── launcher/ # Source code +│ ├── resources/ +│ │ ├── racked_ru/ # Your custom theme +│ │ │ ├── theme.json # Black/red color scheme +│ │ │ ├── themeStyle.css # Qt stylesheet +│ │ │ └── racked_ru.qrc # Qt resource file +│ │ └── backgrounds/ +│ │ └── backgrounds.qrc # Updated with racked_ru background +│ ├── Application.cpp # Default theme set to racked.ru +│ └── main.cpp # Only loads required resources +├── scripts/ +│ ├── build-windows-portable.bat # Windows build script +│ ├── build-linux-portable.sh # Linux build script +│ ├── build-macos-portable.sh # macOS build script +│ ├── build-all-platforms.sh # Master build script +│ └── create-release.sh # Release automation +├── BUILD_GUIDE.md # Comprehensive build instructions +├── README_RELEASE.md # Quick start guide +└── .gitignore # Updated for this project +``` + +## Key Changes Made + +### 1. Theme Integration +- Created `racked_ru` theme resource directory +- Added your custom theme.json with black/red color scheme +- Set racked.ru as default application theme +- Set flat_white as default icon theme +- Added racked_ru background cat image + +### 2. Resource Stripping +Removed these themes from the build to reduce size: +- pe_dark, pe_light, pe_blue, pe_colored +- breeze_dark, breeze_light +- OSX, iOS +- flat (kept only flat_white) + +### 3. Build Configuration +- Updated CMakeLists.txt to only include racked_ru and flat_white +- Updated main.cpp to only load required Qt resources +- Updated ThemeManager.h to remove unused icon themes from defaults +- Added portable.txt support for USB-friendly operation + +### 4. Portable Mode +All builds are configured for portable operation: +- No installation required +- All data stored in launcher directory +- Perfect for USB drives +- No system registry or appdata usage + +### 5. Cross-Platform Build Scripts +Created automated build scripts for: +- **Windows**: Batch script with MSVC support +- **Linux**: Bash script with Qt6 detection +- **macOS**: Bash script with .app bundle creation + +## How to Build + +### Prerequisites + +**Windows:** +- Visual Studio 2022 +- Qt 6.5.3+ (MSVC 2019 64-bit) +- CMake 3.25+ + +**Linux:** +```bash +# Ubuntu/Debian +sudo apt install build-essential cmake qt6-base-dev qt6-tools-dev qt6-networkauth-dev + +# Fedora +sudo dnf install gcc-c++ cmake qt6-qtbase-devel qt6-qtnetworkauth-devel +``` + +**macOS:** +```bash +xcode-select --install +brew install cmake qt6 +``` + +### Build Commands + +Quick build (auto-detects platform): +```bash +cd prismlauncher-racked +bash scripts/build-all-platforms.sh +``` + +Platform-specific: +```bash +# Windows (in Developer Command Prompt) +scripts\build-windows-portable.bat + +# Linux +bash scripts/build-linux-portable.sh + +# macOS +bash scripts/build-macos-portable.sh +``` + +Create release: +```bash +bash scripts/create-release.sh 1.0.0 +``` + +## Output Structure + +After building, you'll have: +``` +release/ +├── Racked.ru-PrismLauncher-Windows-Portable/ +│ ├── prismlauncher.exe +│ ├── portable.txt +│ ├── Qt6*.dll +│ ├── platforms/ +│ ├── iconengines/ +│ └── imageformats/ +├── Racked.ru-PrismLauncher-Linux-Portable/ +│ ├── bin/prismlauncher +│ ├── portable.txt +│ └── run.sh +└── Racked.ru-PrismLauncher-macOS-Portable/ + ├── PrismLauncher.app + ├── portable.txt + └── run.sh +``` + +## Distribution Packages + +After running create-release.sh: +``` +release/ +├── racked-prismlauncher-1.0.0-windows-portable.zip +├── racked-prismlauncher-1.0.0-linux-portable.tar.gz +└── racked-prismlauncher-1.0.0-macos-portable.tar.gz +``` + +## Custom Theme Files + +### theme.json +```json +{ + "colors": { + "AlternateBase": "#000000", + "Base": "#000000", + "BrightText": "#ff0000", + "Button": "#000000", + "ButtonText": "#ffffff", + "Highlight": "#4C4C4C", + "HighlightedText": "#CCCCCC", + "Link": "#CD001F", + "Text": "#ffffff", + "ToolTipBase": "#ffffff", + "ToolTipText": "#ffffff", + "Window": "#000000", + "WindowText": "#ffffff" + }, + "name": "racked.ru", + "widgets": "Fusion" +} +``` + +### themeStyle.css +```css +QToolTip { color: #ffffff; background-color: #2a82da; border: 1px solid white; } +``` + +## Default Settings + +The launcher is pre-configured with: +- Application Theme: racked.ru +- Icon Theme: flat_white +- Background: racked_ru +- Portable Mode: Enabled (via portable.txt) + +## File Size Comparison + +Your minimal build should be significantly smaller than a full PrismLauncher: +- **Before (full themes)**: ~150MB (Windows) +- **After (minimal)**: ~80-100MB (estimated, depending on Qt version) + +## USB Portability Features + +1. **No Installation**: Copy and run from any location +2. **Self-Contained**: All data stays in launcher folder +3. **Cross-Machine**: Works on any computer without setup +4. **Preserved Settings**: Your theme and instances travel with you + +## Next Steps + +1. **Build** the launcher using the provided scripts +2. **Test** on each platform you support +3. **Customize** the theme further if desired (edit theme.json) +4. **Distribute** via https://racked.ru/ +5. **Update** periodically by merging upstream changes + +## Updating from Upstream + +To get new features from upstream: +```bash +cd prismlauncher-racked +git remote add upstream https://github.com/s8n-ru/minecraft-launcher.git +git fetch upstream +git merge upstream/main +# Resolve any conflicts, keeping your theme changes +``` + +## Troubleshooting + +### Build fails on Linux with Qt6 errors +Install complete Qt6 development packages: +```bash +sudo apt install qt6-base-dev qt6-tools-dev qt6-svg-dev qt6-networkauth-dev +``` + +### Windows build missing DLLs +The build script automatically copies required Qt DLLs. If still missing, ensure Qt bin directory is in PATH. + +### Theme not loading +Ensure `portable.txt` exists in the root directory, and check `prismlauncher.cfg` for: +``` +ApplicationTheme=racked.ru +IconTheme=flat_white +BackgroundCat=racked_ru +``` + +### macOS "App is damaged" +Codesign the app: +```bash +codesign --deep --force --sign "-" release/Racked.ru-PrismLauncher-macOS-Portable/PrismLauncher.app +``` + +## Repository Structure + +This project is organized as: +- **prismlauncher-racked/**: Modified upstream repository with your theme +- ** prismlauncher-upstream/**: Clean upstream copy (reference) +- **_project_minecraft/**: Your original launcher (backup) + +## License + +Based on PrismLauncher - licensed under GPL-3.0-only. +Your modifications maintain the same license. + +## Credits + +- **PrismLauncher Team**: Original launcher +- **Diegiwg**: Upstream fork base +- **racked.ru**: Custom theme and branding +- **Build System**: Custom portable cross-platform scripts + +--- + +Project created: 2026-04-13 +Based on: PrismLauncher upstream fork (latest commit as of date) +Build system: CMake 3.25+, Qt 6.5.3+ diff --git a/README.md b/README.md new file mode 100644 index 0000000..5f1538e --- /dev/null +++ b/README.md @@ -0,0 +1,141 @@ +
+ +# minecraft-launcher + +**Privacy-first Minecraft launcher.** +No telemetry. No Microsoft account requirement. Runs smooth on hardware that stock Minecraft chokes on. + +[![Linux](https://img.shields.io/badge/Linux-x64-black?style=flat-square&logo=linux&logoColor=white)](https://github.com/s8n-ru/minecraft-launcher/releases/latest) +[![Windows](https://img.shields.io/badge/Windows-x64-black?style=flat-square&logo=windows&logoColor=white)](https://github.com/s8n-ru/minecraft-launcher/releases/latest) +[![macOS](https://img.shields.io/badge/macOS-arm64-black?style=flat-square&logo=apple&logoColor=white)](https://github.com/s8n-ru/minecraft-launcher/releases/latest) +[![License: GPL-3.0](https://img.shields.io/badge/License-GPL_3.0-black?style=flat-square)](LICENSE) + +[**Download**](https://github.com/s8n-ru/minecraft-launcher/releases/latest) · [Changelog](CHANGELOG.md) · [Audits](docs/) + +racked.ru launcher + +
+ +--- + +## Why this fork exists + +Stock Minecraft phones home. Stock launcher pushes Microsoft accounts. Stock client struggles on low-end machines. + +This fork fixes all three. + +| | Stock launcher | This fork | +|---|---|---| +| Telemetry | Yes | None | +| Microsoft account | Required | Optional, never enforced | +| News fetch on launch | Yes | Hidden, no startup call | +| Plays on weak hardware | Often won't | Yes | +| Window title | Mojang branding | `racked.ru launcher` | + +Full diff: [CHANGELOG.md](CHANGELOG.md). + +--- + +## Features + +### Privacy + +- **No telemetry, anywhere.** Audited every endpoint — see [docs/NETWORK_AUDIT.md](docs/NETWORK_AUDIT.md) +- **No accounts required.** Pick a username, play offline. Sign in later if you want +- **No analytics, no tracking.** Doesn't matter who you are + +### Performance + +- Tuned for low-end hardware +- Bundled Java 21 (no system install) +- Portable — all data in one folder, USB-friendly + +### Functionality + +- Modrinth, CurseForge, FTB, ATLauncher, Technic platforms +- Custom monochrome theme +- Hide news feed by default — no startup network call + +--- + +## Download + +Pre-built binaries for Linux, Windows, macOS: + +→ [**Latest release**](https://github.com/s8n-ru/minecraft-launcher/releases/latest) + +Linux: +```bash +tar xzf minecraft-launcher-linux-x64.tar.gz +cd minecraft-launcher +./bin/prismlauncher +``` + +Windows: +``` +unzip minecraft-launcher-windows-x64.zip +cd minecraft-launcher +prismlauncher.exe +``` + +macOS (unsigned — right-click → Open → Open anyway): +```bash +unzip minecraft-launcher-macos-arm64.zip +cd minecraft-launcher +./prismlauncher.app/Contents/MacOS/prismlauncher +``` + +Pick a username on first launch. Done. + +--- + +## Build from source + +```bash +git clone https://github.com/s8n-ru/minecraft-launcher.git +cd minecraft-launcher + +# Fedora 43 +sudo dnf install cmake gcc-c++ ninja-build extra-cmake-modules \ + qt6-qtbase-devel qt6-qttools-devel qt6-qtsvg-devel qt6-qtnetworkauth-devel \ + libarchive-devel cmark-devel qrencode-devel tomlplusplus + +# Ubuntu / Debian +sudo apt install cmake g++ ninja-build extra-cmake-modules \ + qt6-base-dev qt6-tools-dev qt6-svg-dev libqt6networkauth6-dev \ + libarchive-dev libcmark-dev libqrencode-dev libtomlplusplus-dev gamemode-dev libvulkan-dev + +JAVA_HOME=/path/to/jdk-21 cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release +cmake --build build -j$(nproc) +./build/prismlauncher +``` + +CI builds via [GitHub Actions](.github/workflows/build.yml) for all 3 platforms on every tag. + +--- + +## Trust + +This is open-source. Verify the privacy claims yourself: + +- [docs/NETWORK_AUDIT.md](docs/NETWORK_AUDIT.md) — every network endpoint listed, telemetry verdict +- [docs/SETTINGS_AUDIT.md](docs/SETTINGS_AUDIT.md) — default-value diffs vs upstream +- [docs/BLOAT_AUDIT.md](docs/BLOAT_AUDIT.md) — what was stripped, why + +You don't have to trust me. Read the source. + +--- + +## Status + +Personal project. No support guarantees. Bugs may bite. Use at own risk. + +Pull requests welcome but not promised to merge. + +--- + +## License + +GPL-3.0-only. Per-file copyright headers preserved. + +Based on [PrismLauncher](https://github.com/PrismLauncher/PrismLauncher) (GPL-3.0), itself a fork of [PolyMC](https://github.com/PolyMC/PolyMC) and [MultiMC](https://github.com/MultiMC/Launcher). diff --git a/README_RELEASE.md b/README_RELEASE.md new file mode 100644 index 0000000..9a7cf59 --- /dev/null +++ b/README_RELEASE.md @@ -0,0 +1,68 @@ +# Racked.ru PrismLauncher - Quick Start + +## For Users (Download & Run) + +### Windows +1. Download `racked-prismlauncher-*-windows-portable.zip` +2. Extract to any folder (even USB drive) +3. Run `prismlauncher.exe` +4. Enjoy your minimal black/red themed launcher! + +### Linux +```bash +# Download +tar xzf racked-prismlauncher-*-linux-portable.tar.gz +cd Racked.ru-PrismLauncher-Linux-Portable +./run.sh +``` + +### macOS +```bash +# Download +tar xzf racked-prismlauncher-*-macos-portable.tar.gz +cd Racked.ru-PrismLauncher-macOS-Portable +./run.sh +``` + +## For Developers (Build from Source) + +```bash +# Clone +git clone +cd prismlauncher-racked + +# Build +bash scripts/build-all-platforms.sh + +# Create release +bash scripts/create-release.sh 1.0.0 +``` + +## Configuration + +Your launcher configuration is stored in: +- `prismlauncher.cfg` (in the launcher directory for portable mode) +- `instances/` (your Minecraft instances) +- `cache/` (download cache) + +All these files travel with the launcher in portable mode! + +## Custom Theme Files + +- `themes/racked.ru/theme.json` - Theme colors +- `themes/racked.ru/themeStyle.css` - Qt stylesheet +- `catpacks/racked_ru.png` - Background cat image + +## Support + +- Website: https://racked.ru/ +- Issue Tracker: GitHub Issues +- Based on: PrismLauncher upstream fork (Diegiwg) + +## Updating + +To update to a newer version: +1. Download the new release +2. Backup your `instances/` folder and `prismlauncher.cfg` +3. Replace all other files +4. Restore your backup diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md new file mode 100644 index 0000000..2736e66 --- /dev/null +++ b/RELEASE_CHECKLIST.md @@ -0,0 +1,168 @@ +# Racked.ru PrismLauncher - Release Checklist + +## Pre-Release Verification + +### Build Verification +- [ ] Windows build completes without errors +- [ ] Linux build completes without errors +- [ ] macOS build completes without errors +- [ ] All builds produce executable launcher +- [ ] Portable mode works (data stored locally) +- [ ] No missing DLLs or dependencies + +### Theme Verification +- [ ] Application theme is racked.ru (black/red) +- [ ] Icon theme is flat_white +- [ ] Background shows racked_ru cat +- [ ] UI elements are readable +- [ ] No theme-related crashes + +### Functionality Tests +- [ ] Launcher starts successfully +- [ ] Can create new instance +- [ ] Can launch Minecraft +- [ ] Settings persist between launches +- [ ] Portable mode stores data correctly +- [ ] No console errors on startup + +### Size Verification +- [ ] Windows package is < 120MB +- [ ] Linux package is < 100MB +- [ ] macOS package is < 110MB +- [ ] Size reduction from full build is noticeable + +### File Structure +- [ ] portable.txt present in root +- [ ] All Qt DLLs included (Windows) +- [ ] Platform plugins included +- [ ] Image format plugins included +- [ ] No unnecessary theme files + +### Documentation +- [ ] BUILD_GUIDE.md is accurate +- [ ] README_RELEASE.md is clear +- [ ] PROJECT_SUMMARY.md covers all changes +- [ ] Build scripts are executable (Linux/macOS) + +### Platform-Specific + +#### Windows +- [ ] Runs on Windows 10/11 +- [ ] No UAC errors +- [ ] Works from USB drive +- [ ] MSVC runtime DLLs included +- [ ] Qt bindings work correctly + +#### Linux +- [ ] Runs on Ubuntu 22.04+ +- [ ] Runs on Fedora 38+ +- [ ] run.sh is executable +- [ ] LD_LIBRARY_PATH set correctly +- [ ] No missing shared libraries + +#### macOS +- [ ] Runs on macOS 12+ +- [ ] .app bundle is valid +- [ ] Codesigned (or instructions provided) +- [ ] No quarantine issues +- [ ] Intel and Apple Silicon support + +## Release Creation + +### Version Numbering +Use semantic versioning: MAJOR.MINOR.PATCH +- MAJOR: Breaking changes +- MINOR: New features +- PATCH: Bug fixes + +### Create Release Package +```bash +# From project root +bash scripts/create-release.sh 1.0.0 +``` + +### Verify Release Packages +- [ ] All three platform archives created +- [ ] Archives extract correctly +- [ ] Version number in filenames +- [ ] Release notes generated +- [ ] Archives are not corrupted + +### Testing Release Packages +For each platform: +1. Extract archive to fresh directory +2. Run launcher +3. Verify theme loads +4. Create test instance +5. Verify portable mode +6. Delete test directory + +## Distribution + +### GitHub Release +1. Go to repository releases page +2. Create new release +3. Tag: v1.0.0 +4. Title: "Racked.ru PrismLauncher v1.0.0" +5. Description: Copy from release notes +6. Upload all three archives +7. Mark as latest release + +### Website (racked.ru) +Upload archives to your server: +```bash +# Example upload command +scp release/racked-prismlauncher-1.0.0-* user@racked.ru:/var/www/html/downloads/ +``` + +### Update Download Links +Update https://racked.ru/ with new version links + +## Post-Release + +### Documentation +- [ ] Update CHANGELOG.md +- [ ] Update version in README +- [ ] Note any known issues +- [ ] Document platform-specific quirks + +### Communication +- [ ] Announce on your website +- [ ] Update Discord/forums if applicable +- [ ] Tag upstream PrismLauncher if sharing back + +### Backup +- [ ] Tag git repository +- [ ] Backup source code +- [ ] Backup build artifacts +- [ ] Keep release packages + +## Known Limitations + +These components were intentionally removed: +- Alternative icon themes (only flat_white kept) +- Alternative application themes (only racked.ru kept) +- Some built-in backgrounds (only racked_ru kept) + +## Success Criteria + +Release is successful when: +1. All three platforms build without errors +2. Launcher runs with correct theme +3. Portable mode works correctly +4. Package sizes are reasonable +5. No crash reports in first 24 hours + +## Rollback Plan + +If issues are found: +1. Keep previous version available +2. Document the issue +3. Fix in development branch +4. Increment patch version +5. Create new release + +--- + +Last updated: 2026-04-13 +Version: 1.0.0 (initial release) diff --git a/buildconfig/BuildConfig.cpp.in b/buildconfig/BuildConfig.cpp.in new file mode 100644 index 0000000..14d8236 --- /dev/null +++ b/buildconfig/BuildConfig.cpp.in @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include "BuildConfig.h" + +const Config BuildConfig; + +Config::Config() +{ + // Name and copyright + LAUNCHER_NAME = "@Launcher_Name@"; + LAUNCHER_APP_BINARY_NAME = "@Launcher_APP_BINARY_NAME@"; + LAUNCHER_DISPLAYNAME = "@Launcher_DisplayName@"; + LAUNCHER_COPYRIGHT = "@Launcher_Copyright@"; + LAUNCHER_DOMAIN = "@Launcher_Domain@"; + LAUNCHER_CONFIGFILE = "@Launcher_ConfigFile@"; + LAUNCHER_GIT = "@Launcher_Git@"; + LAUNCHER_APPID = "@Launcher_AppID@"; + LAUNCHER_SVGFILENAME = "@Launcher_SVGFileName@"; + LAUNCHER_ENVNAME = "@Launcher_ENVName@"; + + USER_AGENT = "@Launcher_UserAgent@"; + + // Version information + VERSION_MAJOR = @Launcher_VERSION_MAJOR@; + VERSION_MINOR = @Launcher_VERSION_MINOR@; + VERSION_PATCH = @Launcher_VERSION_PATCH@; + + BUILD_PLATFORM = "@Launcher_BUILD_PLATFORM@"; + BUILD_ARTIFACT = "@Launcher_BUILD_ARTIFACT@"; + BUILD_DATE = "@Launcher_BUILD_TIMESTAMP@"; + UPDATER_GITHUB_REPO = "@Launcher_UPDATER_GITHUB_REPO@"; + + COMPILER_NAME = "@Launcher_COMPILER_NAME@"; + COMPILER_VERSION = "@Launcher_COMPILER_VERSION@"; + + COMPILER_TARGET_SYSTEM = "@Launcher_COMPILER_TARGET_SYSTEM@"; + COMPILER_TARGET_SYSTEM_VERSION = "@Launcher_COMPILER_TARGET_SYSTEM_VERSION@"; + COMPILER_TARGET_SYSTEM_PROCESSOR = "@Launcher_COMPILER_TARGET_PROCESSOR@"; + + MAC_SPARKLE_PUB_KEY = "@MACOSX_SPARKLE_UPDATE_PUBLIC_KEY@"; + MAC_SPARKLE_APPCAST_URL = "@MACOSX_SPARKLE_UPDATE_FEED_URL@"; + + if (!MAC_SPARKLE_PUB_KEY.isEmpty() && !MAC_SPARKLE_APPCAST_URL.isEmpty()) { + UPDATER_ENABLED = true; + } else if (!UPDATER_GITHUB_REPO.isEmpty() && !BUILD_ARTIFACT.isEmpty()) { + UPDATER_ENABLED = true; + } + +#cmakedefine01 Launcher_ENABLE_JAVA_DOWNLOADER + JAVA_DOWNLOADER_ENABLED = Launcher_ENABLE_JAVA_DOWNLOADER; + + GIT_COMMIT = "@Launcher_GIT_COMMIT@"; + GIT_TAG = "@Launcher_GIT_TAG@"; + GIT_REFSPEC = "@Launcher_GIT_REFSPEC@"; + + // Assume that builds outside of Git repos are "stable" + if (GIT_REFSPEC == QStringLiteral("GITDIR-NOTFOUND") || GIT_TAG == QStringLiteral("GITDIR-NOTFOUND") || + GIT_REFSPEC == QStringLiteral("") || GIT_TAG == QStringLiteral("GIT-NOTFOUND")) { + GIT_REFSPEC = "refs/heads/stable"; + GIT_TAG = versionString(); + GIT_COMMIT = ""; + } + + if (GIT_REFSPEC.startsWith("refs/heads/")) { + VERSION_CHANNEL = GIT_REFSPEC; + VERSION_CHANNEL.remove("refs/heads/"); + } else if (!GIT_COMMIT.isEmpty()) { + VERSION_CHANNEL = GIT_COMMIT.mid(0, 8); + } else { + VERSION_CHANNEL = "unknown"; + } + + NEWS_RSS_URL = "@Launcher_NEWS_RSS_URL@"; + NEWS_OPEN_URL = "@Launcher_NEWS_OPEN_URL@"; + WIKI_URL = "@Launcher_WIKI_URL@"; + HELP_URL = "@Launcher_HELP_URL@"; + LOGIN_CALLBACK_URL = "@Launcher_LOGIN_CALLBACK_URL@"; + IMGUR_CLIENT_ID = "@Launcher_IMGUR_CLIENT_ID@"; + MSA_CLIENT_ID = "@Launcher_MSA_CLIENT_ID@"; + FLAME_API_KEY = "@Launcher_CURSEFORGE_API_KEY@"; + META_URL = "@Launcher_META_URL@"; + LEGACY_FMLLIBS_BASE_URL = "@Launcher_LEGACY_FMLLIBS_BASE_URL@"; + + GLFW_LIBRARY_NAME = "@Launcher_GLFW_LIBRARY_NAME@"; + OPENAL_LIBRARY_NAME = "@Launcher_OPENAL_LIBRARY_NAME@"; + + BUG_TRACKER_URL = "@Launcher_BUG_TRACKER_URL@"; + TRANSLATIONS_URL = "@Launcher_TRANSLATIONS_URL@"; + TRANSLATION_FILES_URL = "@Launcher_TRANSLATION_FILES_URL@"; + MATRIX_URL = "@Launcher_MATRIX_URL@"; + DISCORD_URL = "@Launcher_DISCORD_URL@"; + SUBREDDIT_URL = "@Launcher_SUBREDDIT_URL@"; +} + +QString Config::versionString() const +{ + return QString("%1.%2.%3").arg(VERSION_MAJOR).arg(VERSION_MINOR).arg(VERSION_PATCH); +} + +QString Config::printableVersionString() const +{ + QString vstr = versionString(); + + // If the build is not a main release, append the channel + if (VERSION_CHANNEL != "stable" && GIT_TAG != vstr) { + vstr += "-" + VERSION_CHANNEL; + } + return vstr; +} + +QString Config::compilerID() const +{ + if (COMPILER_VERSION.isEmpty()) + return COMPILER_NAME; + return QStringLiteral("%1 - %2").arg(COMPILER_NAME).arg(COMPILER_VERSION); +} + +QString Config::systemID() const +{ + return QStringLiteral("%1 %2 %3").arg(COMPILER_TARGET_SYSTEM, COMPILER_TARGET_SYSTEM_VERSION, COMPILER_TARGET_SYSTEM_PROCESSOR); +} diff --git a/buildconfig/BuildConfig.h b/buildconfig/BuildConfig.h new file mode 100644 index 0000000..d430622 --- /dev/null +++ b/buildconfig/BuildConfig.h @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once +#include +#include + +/** + * \brief The Config class holds all the build-time information passed from the build system. + */ +class Config { + public: + Config(); + QString LAUNCHER_NAME; + QString LAUNCHER_APP_BINARY_NAME; + QString LAUNCHER_DISPLAYNAME; + QString LAUNCHER_COPYRIGHT; + QString LAUNCHER_DOMAIN; + QString LAUNCHER_CONFIGFILE; + QString LAUNCHER_GIT; + QString LAUNCHER_APPID; + QString LAUNCHER_SVGFILENAME; + QString LAUNCHER_ENVNAME; + + /// The major version number. + int VERSION_MAJOR; + /// The minor version number. + int VERSION_MINOR; + /// The patch version number. + int VERSION_PATCH; + + /** + * The version channel + * This is used by the updater to determine what channel the current version came from. + */ + QString VERSION_CHANNEL; + + bool UPDATER_ENABLED = false; + bool JAVA_DOWNLOADER_ENABLED = false; + + /// A short string identifying this build's platform or distribution. + QString BUILD_PLATFORM; + + /// A short string identifying this build's valid artifacts int he updater. For example, "lin64" or "win32". + QString BUILD_ARTIFACT; + + /// A string containing the build timestamp + QString BUILD_DATE; + + /// A string identifying the compiler use to build + QString COMPILER_NAME; + + /// A string identifying the compiler version used to build + QString COMPILER_VERSION; + + /// A string identifying the compiler target system os + QString COMPILER_TARGET_SYSTEM; + + /// A String identifying the compiler target system version + QString COMPILER_TARGET_SYSTEM_VERSION; + + /// A String identifying the compiler target processor + QString COMPILER_TARGET_SYSTEM_PROCESSOR; + + /// URL for the updater's channel + QString UPDATER_GITHUB_REPO; + + /// The public key used to sign releases for the Sparkle updater appcast + QString MAC_SPARKLE_PUB_KEY; + + /// URL for the Sparkle updater's appcast + QString MAC_SPARKLE_APPCAST_URL; + + /// User-Agent to use. + QString USER_AGENT; + + /// The git commit hash of this build + QString GIT_COMMIT; + + /// The git tag of this build + QString GIT_TAG; + + /// The git refspec of this build + QString GIT_REFSPEC; + + /** + * This is used to fetch the news RSS feed. + * It defaults in CMakeLists.txt to "https://multimc.org/rss.xml" + */ + QString NEWS_RSS_URL; + + /** + * URL that gets opened when the user clicks "More News" + */ + QString NEWS_OPEN_URL; + + /** + * URL that gets opened when the user clicks 'Launcher Help' + */ + QString WIKI_URL; + + /** + * URL (with arg %1 to be substituted with page-id) that gets opened when the user requests help in a dialog window + */ + QString HELP_URL; + + /** + * URL that gets opened when the user succesfully logins. + */ + QString LOGIN_CALLBACK_URL; + + /** + * Client ID you can get from Imgur when you register an application + */ + QString IMGUR_CLIENT_ID; + + /** + * Client ID you can get from Microsoft Identity Platform when you register an application + */ + QString MSA_CLIENT_ID; + + /** + * Client API key for CurseForge + */ + QString FLAME_API_KEY; + + /** + * Metadata repository URL prefix + */ + QString META_URL; + + QString GLFW_LIBRARY_NAME; + QString OPENAL_LIBRARY_NAME; + + QString BUG_TRACKER_URL; + QString TRANSLATIONS_URL; + QString MATRIX_URL; + QString DISCORD_URL; + QString SUBREDDIT_URL; + + QString DEFAULT_RESOURCE_BASE = "https://resources.download.minecraft.net/"; + QString LIBRARY_BASE = "https://libraries.minecraft.net/"; + QString IMGUR_BASE_URL = "https://api.imgur.com/3/"; + QString LEGACY_FMLLIBS_BASE_URL; + QString TRANSLATION_FILES_URL; + + QString FTB_API_BASE_URL = "https://api.feed-the-beast.com/v1/modpacks/public"; + + QString LEGACY_FTB_CDN_BASE_URL = "https://dist.creeper.host/FTB2/"; + + QString ATL_DOWNLOAD_SERVER_URL = "https://download.nodecdn.net/containers/atl/"; + QString ATL_API_BASE_URL = "https://api.atlauncher.com/v1/"; + + QString TECHNIC_API_BASE_URL = "https://api.technicpack.net/"; + /** + * The build that is reported to the Technic API. + */ + QString TECHNIC_API_BUILD = "multimc"; + + QString MODRINTH_STAGING_URL = "https://staging-api.modrinth.com/v2"; + QString MODRINTH_PROD_URL = "https://api.modrinth.com/v2"; + QStringList MODRINTH_MRPACK_HOSTS{ "cdn.modrinth.com", "github.com", "raw.githubusercontent.com", "gitlab.com" }; + + QString FLAME_BASE_URL = "https://api.curseforge.com/v1"; + + QString versionString() const; + /** + * \brief Converts the Version to a string. + * \return The version number in string format (major.minor.revision.build). + */ + QString printableVersionString() const; + + /** + * \brief Compiler ID String + * \return a string of the form "Name - Version" of just "Name" if the version is empty + */ + QString compilerID() const; + + /** + * \brief System ID String + * \return a string of the form "OS Verison Processor" + */ + QString systemID() const; +}; + +extern const Config BuildConfig; diff --git a/buildconfig/CMakeLists.txt b/buildconfig/CMakeLists.txt new file mode 100644 index 0000000..cd09bdc --- /dev/null +++ b/buildconfig/CMakeLists.txt @@ -0,0 +1,11 @@ +######## Configure the file with build properties ######## + +configure_file("${CMAKE_CURRENT_SOURCE_DIR}/BuildConfig.cpp.in" "${CMAKE_CURRENT_BINARY_DIR}/BuildConfig.cpp") + +add_library(BuildConfig STATIC + BuildConfig.h + ${CMAKE_CURRENT_BINARY_DIR}/BuildConfig.cpp +) + +target_link_libraries(BuildConfig Qt${QT_VERSION_MAJOR}::Core) +target_include_directories(BuildConfig PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}") diff --git a/cmake/GetGitRevisionDescription.cmake b/cmake/GetGitRevisionDescription.cmake new file mode 100644 index 0000000..4fbd90d --- /dev/null +++ b/cmake/GetGitRevisionDescription.cmake @@ -0,0 +1,284 @@ +# - Returns a version string from Git +# +# These functions force a re-configure on each git commit so that you can +# trust the values of the variables in your build system. +# +# get_git_head_revision( [ALLOW_LOOKING_ABOVE_CMAKE_SOURCE_DIR]) +# +# Returns the refspec and sha hash of the current head revision +# +# git_describe( [ ...]) +# +# Returns the results of git describe on the source tree, and adjusting +# the output so that it tests false if an error occurs. +# +# git_describe_working_tree( [ ...]) +# +# Returns the results of git describe on the working tree (--dirty option), +# and adjusting the output so that it tests false if an error occurs. +# +# git_get_exact_tag( [ ...]) +# +# Returns the results of git describe --exact-match on the source tree, +# and adjusting the output so that it tests false if there was no exact +# matching tag. +# +# git_local_changes() +# +# Returns either "CLEAN" or "DIRTY" with respect to uncommitted changes. +# Uses the return code of "git diff-index --quiet HEAD --". +# Does not regard untracked files. +# +# Requires CMake 2.6 or newer (uses the 'function' command) +# +# Original Author: +# 2009-2020 Ryan Pavlik +# http://academic.cleardefinition.com +# +# Copyright 2009-2013, Iowa State University. +# Copyright 2013-2020, Ryan Pavlik +# Copyright 2013-2020, Contributors +# SPDX-License-Identifier: BSL-1.0 +# Distributed under the Boost Software License, Version 1.0. +# (See accompanying file LICENSE_1_0.txt or copy at +# http://www.boost.org/LICENSE_1_0.txt) + +if(__get_git_revision_description) + return() +endif() +set(__get_git_revision_description YES) + +# We must run the following at "include" time, not at function call time, +# to find the path to this module rather than the path to a calling list file +get_filename_component(_gitdescmoddir ${CMAKE_CURRENT_LIST_FILE} PATH) + +# Function _git_find_closest_git_dir finds the next closest .git directory +# that is part of any directory in the path defined by _start_dir. +# The result is returned in the parent scope variable whose name is passed +# as variable _git_dir_var. If no .git directory can be found, the +# function returns an empty string via _git_dir_var. +# +# Example: Given a path C:/bla/foo/bar and assuming C:/bla/.git exists and +# neither foo nor bar contain a file/directory .git. This wil return +# C:/bla/.git +# +function(_git_find_closest_git_dir _start_dir _git_dir_var) + set(cur_dir "${_start_dir}") + set(git_dir "${_start_dir}/.git") + while(NOT EXISTS "${git_dir}") + # .git dir not found, search parent directories + set(git_previous_parent "${cur_dir}") + get_filename_component(cur_dir "${cur_dir}" DIRECTORY) + if(cur_dir STREQUAL git_previous_parent) + # We have reached the root directory, we are not in git + set(${_git_dir_var} + "" + PARENT_SCOPE) + return() + endif() + set(git_dir "${cur_dir}/.git") + endwhile() + set(${_git_dir_var} + "${git_dir}" + PARENT_SCOPE) +endfunction() + +function(get_git_head_revision _refspecvar _hashvar) + _git_find_closest_git_dir("${CMAKE_CURRENT_SOURCE_DIR}" GIT_DIR) + + if("${ARGN}" STREQUAL "ALLOW_LOOKING_ABOVE_CMAKE_SOURCE_DIR") + set(ALLOW_LOOKING_ABOVE_CMAKE_SOURCE_DIR TRUE) + else() + set(ALLOW_LOOKING_ABOVE_CMAKE_SOURCE_DIR FALSE) + endif() + if(NOT "${GIT_DIR}" STREQUAL "") + file(RELATIVE_PATH _relative_to_source_dir "${CMAKE_SOURCE_DIR}" + "${GIT_DIR}") + if("${_relative_to_source_dir}" MATCHES "[.][.]" AND NOT ALLOW_LOOKING_ABOVE_CMAKE_SOURCE_DIR) + # We've gone above the CMake root dir. + set(GIT_DIR "") + endif() + endif() + if("${GIT_DIR}" STREQUAL "") + set(${_refspecvar} + "GITDIR-NOTFOUND" + PARENT_SCOPE) + set(${_hashvar} + "GITDIR-NOTFOUND" + PARENT_SCOPE) + return() + endif() + + # Check if the current source dir is a git submodule or a worktree. + # In both cases .git is a file instead of a directory. + # + if(NOT IS_DIRECTORY ${GIT_DIR}) + # The following git command will return a non empty string that + # points to the super project working tree if the current + # source dir is inside a git submodule. + # Otherwise the command will return an empty string. + # + execute_process( + COMMAND "${GIT_EXECUTABLE}" rev-parse + --show-superproject-working-tree + WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" + OUTPUT_VARIABLE out + ERROR_QUIET OUTPUT_STRIP_TRAILING_WHITESPACE) + if(NOT "${out}" STREQUAL "") + # If out is empty, GIT_DIR/CMAKE_CURRENT_SOURCE_DIR is in a submodule + file(READ ${GIT_DIR} submodule) + string(REGEX REPLACE "gitdir: (.*)$" "\\1" GIT_DIR_RELATIVE + ${submodule}) + string(STRIP ${GIT_DIR_RELATIVE} GIT_DIR_RELATIVE) + get_filename_component(SUBMODULE_DIR ${GIT_DIR} PATH) + get_filename_component(GIT_DIR ${SUBMODULE_DIR}/${GIT_DIR_RELATIVE} + ABSOLUTE) + set(HEAD_SOURCE_FILE "${GIT_DIR}/HEAD") + else() + # GIT_DIR/CMAKE_CURRENT_SOURCE_DIR is in a worktree + file(READ ${GIT_DIR} worktree_ref) + # The .git directory contains a path to the worktree information directory + # inside the parent git repo of the worktree. + # + string(REGEX REPLACE "gitdir: (.*)$" "\\1" git_worktree_dir + ${worktree_ref}) + string(STRIP ${git_worktree_dir} git_worktree_dir) + _git_find_closest_git_dir("${git_worktree_dir}" GIT_DIR) + set(HEAD_SOURCE_FILE "${git_worktree_dir}/HEAD") + endif() + else() + set(HEAD_SOURCE_FILE "${GIT_DIR}/HEAD") + endif() + set(GIT_DATA "${CMAKE_CURRENT_BINARY_DIR}/CMakeFiles/git-data") + if(NOT EXISTS "${GIT_DATA}") + file(MAKE_DIRECTORY "${GIT_DATA}") + endif() + + if(NOT EXISTS "${HEAD_SOURCE_FILE}") + return() + endif() + set(HEAD_FILE "${GIT_DATA}/HEAD") + configure_file("${HEAD_SOURCE_FILE}" "${HEAD_FILE}" COPYONLY) + + configure_file("${_gitdescmoddir}/GetGitRevisionDescription.cmake.in" + "${GIT_DATA}/grabRef.cmake" @ONLY) + include("${GIT_DATA}/grabRef.cmake") + + set(${_refspecvar} + "${HEAD_REF}" + PARENT_SCOPE) + set(${_hashvar} + "${HEAD_HASH}" + PARENT_SCOPE) +endfunction() + +function(git_describe _var) + if(NOT GIT_FOUND) + find_package(Git QUIET) + endif() + get_git_head_revision(refspec hash) + if(NOT GIT_FOUND) + set(${_var} + "GIT-NOTFOUND" + PARENT_SCOPE) + return() + endif() + if(NOT hash) + set(${_var} + "HEAD-HASH-NOTFOUND" + PARENT_SCOPE) + return() + endif() + + # TODO sanitize + #if((${ARGN}" MATCHES "&&") OR + # (ARGN MATCHES "||") OR + # (ARGN MATCHES "\\;")) + # message("Please report the following error to the project!") + # message(FATAL_ERROR "Looks like someone's doing something nefarious with git_describe! Passed arguments ${ARGN}") + #endif() + + #message(STATUS "Arguments to execute_process: ${ARGN}") + + execute_process( + COMMAND "${GIT_EXECUTABLE}" describe --tags --always ${hash} ${ARGN} + WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" + RESULT_VARIABLE res + OUTPUT_VARIABLE out + ERROR_QUIET OUTPUT_STRIP_TRAILING_WHITESPACE) + if(NOT res EQUAL 0) + set(out "${out}-${res}-NOTFOUND") + endif() + + set(${_var} + "${out}" + PARENT_SCOPE) +endfunction() + +function(git_describe_working_tree _var) + if(NOT GIT_FOUND) + find_package(Git QUIET) + endif() + if(NOT GIT_FOUND) + set(${_var} + "GIT-NOTFOUND" + PARENT_SCOPE) + return() + endif() + + execute_process( + COMMAND "${GIT_EXECUTABLE}" describe --dirty ${ARGN} + WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" + RESULT_VARIABLE res + OUTPUT_VARIABLE out + ERROR_QUIET OUTPUT_STRIP_TRAILING_WHITESPACE) + if(NOT res EQUAL 0) + set(out "${out}-${res}-NOTFOUND") + endif() + + set(${_var} + "${out}" + PARENT_SCOPE) +endfunction() + +function(git_get_exact_tag _var) + git_describe(out --exact-match ${ARGN}) + set(${_var} + "${out}" + PARENT_SCOPE) +endfunction() + +function(git_local_changes _var) + if(NOT GIT_FOUND) + find_package(Git QUIET) + endif() + get_git_head_revision(refspec hash) + if(NOT GIT_FOUND) + set(${_var} + "GIT-NOTFOUND" + PARENT_SCOPE) + return() + endif() + if(NOT hash) + set(${_var} + "HEAD-HASH-NOTFOUND" + PARENT_SCOPE) + return() + endif() + + execute_process( + COMMAND "${GIT_EXECUTABLE}" diff-index --quiet HEAD -- + WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" + RESULT_VARIABLE res + OUTPUT_VARIABLE out + ERROR_QUIET OUTPUT_STRIP_TRAILING_WHITESPACE) + if(res EQUAL 0) + set(${_var} + "CLEAN" + PARENT_SCOPE) + else() + set(${_var} + "DIRTY" + PARENT_SCOPE) + endif() +endfunction() diff --git a/cmake/GetGitRevisionDescription.cmake.in b/cmake/GetGitRevisionDescription.cmake.in new file mode 100644 index 0000000..116efc4 --- /dev/null +++ b/cmake/GetGitRevisionDescription.cmake.in @@ -0,0 +1,43 @@ +# +# Internal file for GetGitRevisionDescription.cmake +# +# Requires CMake 2.6 or newer (uses the 'function' command) +# +# Original Author: +# 2009-2010 Ryan Pavlik +# http://academic.cleardefinition.com +# Iowa State University HCI Graduate Program/VRAC +# +# Copyright 2009-2012, Iowa State University +# Copyright 2011-2015, Contributors +# Distributed under the Boost Software License, Version 1.0. +# (See accompanying file LICENSE_1_0.txt or copy at +# http://www.boost.org/LICENSE_1_0.txt) +# SPDX-License-Identifier: BSL-1.0 + +set(HEAD_HASH) + +file(READ "@HEAD_FILE@" HEAD_CONTENTS LIMIT 1024) + +string(STRIP "${HEAD_CONTENTS}" HEAD_CONTENTS) +if(HEAD_CONTENTS MATCHES "ref") + # named branch + string(REPLACE "ref: " "" HEAD_REF "${HEAD_CONTENTS}") + if(EXISTS "@GIT_DIR@/${HEAD_REF}") + configure_file("@GIT_DIR@/${HEAD_REF}" "@GIT_DATA@/head-ref" COPYONLY) + else() + configure_file("@GIT_DIR@/packed-refs" "@GIT_DATA@/packed-refs" COPYONLY) + file(READ "@GIT_DATA@/packed-refs" PACKED_REFS) + if(${PACKED_REFS} MATCHES "([0-9a-z]*) ${HEAD_REF}") + set(HEAD_HASH "${CMAKE_MATCH_1}") + endif() + endif() +else() + # detached HEAD + configure_file("@GIT_DIR@/HEAD" "@GIT_DATA@/head-ref" COPYONLY) +endif() + +if(NOT HEAD_HASH) + file(READ "@GIT_DATA@/head-ref" HEAD_HASH LIMIT 1024) + string(STRIP "${HEAD_HASH}" HEAD_HASH) +endif() diff --git a/cmake/GitFunctions.cmake b/cmake/GitFunctions.cmake new file mode 100644 index 0000000..a055b5d --- /dev/null +++ b/cmake/GitFunctions.cmake @@ -0,0 +1,37 @@ +if(__GITFUNCTIONS_CMAKE__) + return() +endif() +set(__GITFUNCTIONS_CMAKE__ TRUE) + +find_package(Git QUIET) + +include(CMakeParseArguments) + +if(GIT_FOUND) + function(git_run) + set(oneValueArgs OUTPUT_VAR DEFAULT) + set(multiValueArgs COMMAND) + cmake_parse_arguments(GIT_RUN "${options}" "${oneValueArgs}" "${multiValueArgs}" ${ARGN}) + + execute_process(COMMAND ${GIT_EXECUTABLE} ${GIT_RUN_COMMAND} + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + RESULT_VARIABLE GIT_RESULTVAR + OUTPUT_VARIABLE GIT_OUTVAR + OUTPUT_STRIP_TRAILING_WHITESPACE + ) + + if(GIT_RESULTVAR EQUAL 0) + set(${GIT_RUN_OUTPUT_VAR} "${GIT_OUTVAR}" PARENT_SCOPE) + else() + set(${GIT_RUN_OUTPUT_VAR} ${GIT_RUN_DEFAULT}) + message(STATUS "Failed to run Git: ${GIT_OUTVAR}") + endif() + endfunction() +else() + function(git_run) + set(oneValueArgs OUTPUT_VAR DEFAULT) + set(multiValueArgs COMMAND) + cmake_parse_arguments(GIT_RUN "${options}" "${oneValueArgs}" "${multiValueArgs}" ${ARGN}) + set(${GIT_RUN_OUTPUT_VAR} ${GIT_RUN_DEFAULT}) + endfunction(git_run) +endif() diff --git a/cmake/MacOSXBundleInfo.plist.in b/cmake/MacOSXBundleInfo.plist.in new file mode 100644 index 0000000..eb40bac --- /dev/null +++ b/cmake/MacOSXBundleInfo.plist.in @@ -0,0 +1,99 @@ + + + + + NSCameraUsageDescription + A Minecraft mod wants to access your camera. + NSMicrophoneUsageDescription + A Minecraft mod wants to access your microphone. + NSDownloadsFolderUsageDescription + ${Launcher_DisplayName} uses access to your Downloads folder to help you more quickly add mods that can't be automatically downloaded to your instance. You can change where ${Launcher_DisplayName} scans for downloaded mods in Settings or the prompt that appears. + NSLocalNetworkUsageDescription + Minecraft uses the local network to find and connect to LAN servers. + NSPrincipalClass + NSApplication + NSHighResolutionCapable + True + CFBundleDevelopmentRegion + English + CFBundleExecutable + ${MACOSX_BUNDLE_EXECUTABLE_NAME} + CFBundleGetInfoString + ${MACOSX_BUNDLE_INFO_STRING} + CFBundleIconFile + ${Launcher_Name} + CFBundleIconName + ${Launcher_Name} + CFBundleIdentifier + ${MACOSX_BUNDLE_GUI_IDENTIFIER} + CFBundleInfoDictionaryVersion + 6.0 + CFBundleLongVersionString + ${MACOSX_BUNDLE_LONG_VERSION_STRING} + CFBundleName + ${MACOSX_BUNDLE_BUNDLE_NAME} + CFBundlePackageType + APPL + CFBundleShortVersionString + ${MACOSX_BUNDLE_SHORT_VERSION_STRING} + CFBundleSignature + ???? + CFBundleVersion + ${MACOSX_BUNDLE_BUNDLE_VERSION} + CSResourcesFileMapped + + LSRequiresCarbon + + LSApplicationCategoryType + public.app-category.games + NSHumanReadableCopyright + ${MACOSX_BUNDLE_COPYRIGHT} + SUPublicEDKey + ${MACOSX_SPARKLE_UPDATE_PUBLIC_KEY} + SUFeedURL + ${MACOSX_SPARKLE_UPDATE_FEED_URL} + CFBundleDocumentTypes + + + CFBundleTypeExtensions + + zip + mrpack + + CFBundleTypeName + ${Launcher_DisplayName} instance + CFBundleTypeOSTypes + + TEXT + utxt + TUTX + **** + + CFBundleTypeRole + Viewer + LSHandlerRank + Alternate + + + CFBundleURLTypes + + + CFBundleURLName + Curseforge + CFBundleURLSchemes + + curseforge + + + + CFBundleURLName + ${Launcher_Name} + CFBundleURLSchemes + + prismlauncher + ${MACOSX_BUNDLE_EXECUTABLE_NAME} + + + + + diff --git a/cmake/vcpkg-ports/vcpkg-tool-meson/README.md b/cmake/vcpkg-ports/vcpkg-tool-meson/README.md new file mode 100644 index 0000000..9047c80 --- /dev/null +++ b/cmake/vcpkg-ports/vcpkg-tool-meson/README.md @@ -0,0 +1,3 @@ +The only difference between this and the upstream vcpkg port is the addition of `universal-osx.patch`. It's very annoying we need to bundle this entire tree to do that. + +-@getchoo diff --git a/cmake/vcpkg-ports/vcpkg-tool-meson/adjust-args.patch b/cmake/vcpkg-ports/vcpkg-tool-meson/adjust-args.patch new file mode 100644 index 0000000..ad800aa --- /dev/null +++ b/cmake/vcpkg-ports/vcpkg-tool-meson/adjust-args.patch @@ -0,0 +1,13 @@ +diff --git a/mesonbuild/cmake/toolchain.py b/mesonbuild/cmake/toolchain.py +index 11a00be5d..89ae490ff 100644 +--- a/mesonbuild/cmake/toolchain.py ++++ b/mesonbuild/cmake/toolchain.py +@@ -202,7 +202,7 @@ class CMakeToolchain: + @staticmethod + def is_cmdline_option(compiler: 'Compiler', arg: str) -> bool: + if compiler.get_argument_syntax() == 'msvc': +- return arg.startswith('/') ++ return arg.startswith(('/','-')) + else: + if os.path.basename(compiler.get_exe()) == 'zig' and arg in {'ar', 'cc', 'c++', 'dlltool', 'lib', 'ranlib', 'objcopy', 'rc'}: + return True diff --git a/cmake/vcpkg-ports/vcpkg-tool-meson/adjust-python-dep.patch b/cmake/vcpkg-ports/vcpkg-tool-meson/adjust-python-dep.patch new file mode 100644 index 0000000..0cbfe71 --- /dev/null +++ b/cmake/vcpkg-ports/vcpkg-tool-meson/adjust-python-dep.patch @@ -0,0 +1,45 @@ +diff --git a/mesonbuild/dependencies/python.py b/mesonbuild/dependencies/python.py +index 883a29a..d9a82af 100644 +--- a/mesonbuild/dependencies/python.py ++++ b/mesonbuild/dependencies/python.py +@@ -232,8 +232,10 @@ class _PythonDependencyBase(_Base): + else: + if self.is_freethreaded: + libpath = Path('libs') / f'python{vernum}t.lib' ++ libpath = Path('libs') / f'..' / f'..' / f'..' / f'lib' / f'python{vernum}t.lib' + else: + libpath = Path('libs') / f'python{vernum}.lib' ++ libpath = Path('libs') / f'..' / f'..' / f'..' / f'lib' / f'python{vernum}.lib' + # For a debug build, pyconfig.h may force linking with + # pythonX_d.lib (see meson#10776). This cannot be avoided + # and won't work unless we also have a debug build of +@@ -250,6 +252,8 @@ class _PythonDependencyBase(_Base): + vscrt = self.env.coredata.optstore.get_value('b_vscrt') + if vscrt in {'mdd', 'mtd', 'from_buildtype', 'static_from_buildtype'}: + vscrt_debug = True ++ if is_debug_build: ++ libpath = Path('libs') / f'..' / f'..' / f'..' / f'debug/lib' / f'python{vernum}_d.lib' + if is_debug_build and vscrt_debug and not self.variables.get('Py_DEBUG'): + mlog.warning(textwrap.dedent('''\ + Using a debug build type with MSVC or an MSVC-compatible compiler +@@ -350,9 +354,10 @@ class PythonSystemDependency(SystemDependency, _PythonDependencyBase): + self.is_found = True + + # compile args ++ verdot = self.variables.get('py_version_short') + inc_paths = mesonlib.OrderedSet([ + self.variables.get('INCLUDEPY'), +- self.paths.get('include'), ++ self.paths.get('include') + f'/../../../include/python${verdot}', + self.paths.get('platinclude')]) + + self.compile_args += ['-I' + path for path in inc_paths if path] +@@ -416,7 +421,7 @@ def python_factory(env: 'Environment', for_machine: 'MachineChoice', + candidates.append(functools.partial(wrap_in_pythons_pc_dir, pkg_name, env, kwargs, installation)) + # We only need to check both, if a python install has a LIBPC. It might point to the wrong location, + # e.g. relocated / cross compilation, but the presence of LIBPC indicates we should definitely look for something. +- if pkg_libdir is not None: ++ if True or pkg_libdir is not None: + candidates.append(functools.partial(PythonPkgConfigDependency, pkg_name, env, kwargs, installation)) + else: + candidates.append(functools.partial(PkgConfigDependency, 'python3', env, kwargs)) diff --git a/cmake/vcpkg-ports/vcpkg-tool-meson/fix-libcpp-enable-assertions.patch b/cmake/vcpkg-ports/vcpkg-tool-meson/fix-libcpp-enable-assertions.patch new file mode 100644 index 0000000..394b064 --- /dev/null +++ b/cmake/vcpkg-ports/vcpkg-tool-meson/fix-libcpp-enable-assertions.patch @@ -0,0 +1,52 @@ +From a16ec8b0fb6d7035b669a13edd4d97ff0c307a0b Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Martin=20D=C3=B8rum?= +Date: Fri, 2 May 2025 10:56:28 +0200 +Subject: [PATCH] cpp: fix _LIBCPP_ENABLE_ASSERTIONS warning + +libc++ deprecated _LIBCPP_ENABLE_ASSERTIONS from version 18. +However, the libc++ shipped with Apple Clang backported that +deprecation in version 17 already, +which is the version which Apple currently ships for macOS. +This PR changes the _LIBCPP_ENABLE_ASSERTIONS deprecation check +to use version ">=17" on Apple Clang. +--- + mesonbuild/compilers/cpp.py | 12 ++++++++++-- + 1 file changed, 10 insertions(+), 2 deletions(-) + +diff --git a/mesonbuild/compilers/cpp.py b/mesonbuild/compilers/cpp.py +index 01b9bb9fa34f..f7dc150e8608 100644 +--- a/mesonbuild/compilers/cpp.py ++++ b/mesonbuild/compilers/cpp.py +@@ -311,6 +311,9 @@ def get_option_link_args(self, target: 'BuildTarget', env: 'Environment', subpro + return libs + return [] + ++ def is_libcpp_enable_assertions_deprecated(self) -> bool: ++ return version_compare(self.version, ">=18") ++ + def get_assert_args(self, disable: bool, env: 'Environment') -> T.List[str]: + if disable: + return ['-DNDEBUG'] +@@ -323,7 +326,7 @@ def get_assert_args(self, disable: bool, env: 'Environment') -> T.List[str]: + if self.language_stdlib_provider(env) == 'stdc++': + return ['-D_GLIBCXX_ASSERTIONS=1'] + else: +- if version_compare(self.version, '>=18'): ++ if self.is_libcpp_enable_assertions_deprecated(): + return ['-D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_FAST'] + elif version_compare(self.version, '>=15'): + return ['-D_LIBCPP_ENABLE_ASSERTIONS=1'] +@@ -343,7 +346,12 @@ class ArmLtdClangCPPCompiler(ClangCPPCompiler): + + + class AppleClangCPPCompiler(AppleCompilerMixin, AppleCPPStdsMixin, ClangCPPCompiler): +- pass ++ def is_libcpp_enable_assertions_deprecated(self) -> bool: ++ # Upstream libc++ deprecated _LIBCPP_ENABLE_ASSERTIONS ++ # in favor of _LIBCPP_HARDENING_MODE from version 18 onwards, ++ # but Apple Clang 17's libc++ has back-ported that change. ++ # See: https://github.com/mesonbuild/meson/issues/14440 ++ return version_compare(self.version, ">=17") + + + class EmscriptenCPPCompiler(EmscriptenMixin, ClangCPPCompiler): diff --git a/cmake/vcpkg-ports/vcpkg-tool-meson/install.cmake b/cmake/vcpkg-ports/vcpkg-tool-meson/install.cmake new file mode 100644 index 0000000..84201aa --- /dev/null +++ b/cmake/vcpkg-ports/vcpkg-tool-meson/install.cmake @@ -0,0 +1,5 @@ +file(MAKE_DIRECTORY "${CURRENT_PACKAGES_DIR}/tools/meson") +file(INSTALL "${SOURCE_PATH}/meson.py" + "${SOURCE_PATH}/mesonbuild" + DESTINATION "${CURRENT_PACKAGES_DIR}/tools/meson" +) diff --git a/cmake/vcpkg-ports/vcpkg-tool-meson/meson-intl.patch b/cmake/vcpkg-ports/vcpkg-tool-meson/meson-intl.patch new file mode 100644 index 0000000..8f2a029 --- /dev/null +++ b/cmake/vcpkg-ports/vcpkg-tool-meson/meson-intl.patch @@ -0,0 +1,13 @@ +diff --git a/mesonbuild/dependencies/misc.py b/mesonbuild/dependencies/misc.py +--- a/mesonbuild/dependencies/misc.py ++++ b/mesonbuild/dependencies/misc.py +@@ -593,7 +593,8 @@ iconv_factory = DependencyFactory( + + packages['intl'] = intl_factory = DependencyFactory( + 'intl', ++ [DependencyMethods.BUILTIN, DependencyMethods.SYSTEM, DependencyMethods.CMAKE], ++ cmake_name='Intl', +- [DependencyMethods.BUILTIN, DependencyMethods.SYSTEM], + builtin_class=IntlBuiltinDependency, + system_class=IntlSystemDependency, + ) diff --git a/cmake/vcpkg-ports/vcpkg-tool-meson/meson.template.in b/cmake/vcpkg-ports/vcpkg-tool-meson/meson.template.in new file mode 100644 index 0000000..df21b75 --- /dev/null +++ b/cmake/vcpkg-ports/vcpkg-tool-meson/meson.template.in @@ -0,0 +1,43 @@ +[binaries] +cmake = ['@CMAKE_COMMAND@'] +ninja = ['@NINJA@'] +pkg-config = ['@PKGCONFIG@'] +@MESON_MT@ +@MESON_AR@ +@MESON_RC@ +@MESON_C@ +@MESON_C_LD@ +@MESON_CXX@ +@MESON_CXX_LD@ +@MESON_OBJC@ +@MESON_OBJC_LD@ +@MESON_OBJCPP@ +@MESON_OBJCPP_LD@ +@MESON_FC@ +@MESON_FC_LD@ +@MESON_WINDRES@ +@MESON_ADDITIONAL_BINARIES@ +[properties] +cmake_toolchain_file = '@SCRIPTS@/buildsystems/vcpkg.cmake' +@MESON_ADDITIONAL_PROPERTIES@ +[cmake] +CMAKE_BUILD_TYPE = '@MESON_CMAKE_BUILD_TYPE@' +VCPKG_TARGET_TRIPLET = '@TARGET_TRIPLET@' +VCPKG_HOST_TRIPLET = '@_HOST_TRIPLET@' +VCPKG_CHAINLOAD_TOOLCHAIN_FILE = '@VCPKG_CHAINLOAD_TOOLCHAIN_FILE@' +VCPKG_CRT_LINKAGE = '@VCPKG_CRT_LINKAGE@' +_VCPKG_INSTALLED_DIR = '@_VCPKG_INSTALLED_DIR@' +@MESON_HOST_MACHINE@ +@MESON_BUILD_MACHINE@ +[built-in options] +default_library = '@MESON_DEFAULT_LIBRARY@' +werror = false +@MESON_CFLAGS@ +@MESON_CXXFLAGS@ +@MESON_FCFLAGS@ +@MESON_OBJCFLAGS@ +@MESON_OBJCPPFLAGS@ +# b_vscrt +@MESON_VSCRT_LINKAGE@ +# c_winlibs/cpp_winlibs +@MESON_WINLIBS@ \ No newline at end of file diff --git a/cmake/vcpkg-ports/vcpkg-tool-meson/portfile.cmake b/cmake/vcpkg-ports/vcpkg-tool-meson/portfile.cmake new file mode 100644 index 0000000..fdea886 --- /dev/null +++ b/cmake/vcpkg-ports/vcpkg-tool-meson/portfile.cmake @@ -0,0 +1,45 @@ +# This port represents a dependency on the Meson build system. +# In the future, it is expected that this port acquires and installs Meson. +# Currently is used in ports that call vcpkg_find_acquire_program(MESON) in order to force rebuilds. + +set(VCPKG_POLICY_CMAKE_HELPER_PORT enabled) + +set(patches + meson-intl.patch + adjust-python-dep.patch + adjust-args.patch + remove-freebsd-pcfile-specialization.patch + fix-libcpp-enable-assertions.patch # https://github.com/mesonbuild/meson/pull/14548, Remove in 1.8.3 + universal-osx.patch # NOTE(@getchoo): THIS IS THE ONLY CHANGE NEEDED FOR PRISM +) +set(scripts + vcpkg-port-config.cmake + vcpkg_configure_meson.cmake + vcpkg_install_meson.cmake + meson.template.in +) +set(to_hash + "${CMAKE_CURRENT_LIST_DIR}/vcpkg.json" + "${CMAKE_CURRENT_LIST_DIR}/portfile.cmake" +) +foreach(file IN LISTS patches scripts) + set(filepath "${CMAKE_CURRENT_LIST_DIR}/${file}") + list(APPEND to_hash "${filepath}") + file(COPY "${filepath}" DESTINATION "${CURRENT_PACKAGES_DIR}/share/${PORT}") +endforeach() + +set(meson_path_hash "") +foreach(filepath IN LISTS to_hash) + file(SHA1 "${filepath}" to_append) + string(APPEND meson_path_hash "${to_append}") +endforeach() +string(SHA512 meson_path_hash "${meson_path_hash}") + +string(SUBSTRING "${meson_path_hash}" 0 6 MESON_SHORT_HASH) +list(TRANSFORM patches REPLACE [[^(..*)$]] [["${CMAKE_CURRENT_LIST_DIR}/\0"]]) +list(JOIN patches "\n " PATCHES) +configure_file("${CMAKE_CURRENT_LIST_DIR}/vcpkg-port-config.cmake" "${CURRENT_PACKAGES_DIR}/share/${PORT}/vcpkg-port-config.cmake" @ONLY) + +vcpkg_install_copyright(FILE_LIST "${VCPKG_ROOT_DIR}/LICENSE.txt") + +include("${CURRENT_PACKAGES_DIR}/share/${PORT}/vcpkg-port-config.cmake") diff --git a/cmake/vcpkg-ports/vcpkg-tool-meson/remove-freebsd-pcfile-specialization.patch b/cmake/vcpkg-ports/vcpkg-tool-meson/remove-freebsd-pcfile-specialization.patch new file mode 100644 index 0000000..947345c --- /dev/null +++ b/cmake/vcpkg-ports/vcpkg-tool-meson/remove-freebsd-pcfile-specialization.patch @@ -0,0 +1,23 @@ +diff --git a/mesonbuild/modules/pkgconfig.py b/mesonbuild/modules/pkgconfig.py +index cc0450a52..13501466d 100644 +--- a/mesonbuild/modules/pkgconfig.py ++++ b/mesonbuild/modules/pkgconfig.py +@@ -701,16 +701,8 @@ class PkgConfigModule(NewExtensionModule): + pcfile = filebase + '.pc' + pkgroot = pkgroot_name = kwargs['install_dir'] or default_install_dir + if pkgroot is None: +- m = state.environment.machines.host +- if m.is_freebsd(): +- pkgroot = os.path.join(_as_str(state.environment.coredata.optstore.get_value_for(OptionKey('prefix'))), 'libdata', 'pkgconfig') +- pkgroot_name = os.path.join('{prefix}', 'libdata', 'pkgconfig') +- elif m.is_haiku(): +- pkgroot = os.path.join(_as_str(state.environment.coredata.optstore.get_value_for(OptionKey('prefix'))), 'develop', 'lib', 'pkgconfig') +- pkgroot_name = os.path.join('{prefix}', 'develop', 'lib', 'pkgconfig') +- else: +- pkgroot = os.path.join(_as_str(state.environment.coredata.optstore.get_value_for(OptionKey('libdir'))), 'pkgconfig') +- pkgroot_name = os.path.join('{libdir}', 'pkgconfig') ++ pkgroot = os.path.join(_as_str(state.environment.coredata.optstore.get_value_for(OptionKey('libdir'))), 'pkgconfig') ++ pkgroot_name = os.path.join('{libdir}', 'pkgconfig') + relocatable = state.get_option('pkgconfig.relocatable') + self._generate_pkgconfig_file(state, deps, subdirs, name, description, url, + version, pcfile, conflicts, variables, diff --git a/cmake/vcpkg-ports/vcpkg-tool-meson/universal-osx.patch b/cmake/vcpkg-ports/vcpkg-tool-meson/universal-osx.patch new file mode 100644 index 0000000..58b96d5 --- /dev/null +++ b/cmake/vcpkg-ports/vcpkg-tool-meson/universal-osx.patch @@ -0,0 +1,16 @@ +diff --git a/mesonbuild/compilers/detect.py b/mesonbuild/compilers/detect.py +index f57957f0b..a72e72a0b 100644 +--- a/mesonbuild/compilers/detect.py ++++ b/mesonbuild/compilers/detect.py +@@ -1472,6 +1472,11 @@ def _get_clang_compiler_defines(compiler: T.List[str], lang: str) -> T.Dict[str, + """ + from .mixins.clang import clang_lang_map + ++ # Filter out `-arch` flags passed to the compiler for Universal Binaries ++ # https://github.com/mesonbuild/meson/issues/5290 ++ # https://github.com/mesonbuild/meson/issues/8206 ++ compiler = [arg for i, arg in enumerate(compiler) if not (i > 0 and compiler[i - 1] == "-arch") and not arg == "-arch"] ++ + def _try_obtain_compiler_defines(args: T.List[str]) -> str: + mlog.debug(f'Running command: {join_args(args)}') + p, output, error = Popen_safe(compiler + args, write='', stdin=subprocess.PIPE) diff --git a/cmake/vcpkg-ports/vcpkg-tool-meson/vcpkg-port-config.cmake b/cmake/vcpkg-ports/vcpkg-tool-meson/vcpkg-port-config.cmake new file mode 100644 index 0000000..c0dee3a --- /dev/null +++ b/cmake/vcpkg-ports/vcpkg-tool-meson/vcpkg-port-config.cmake @@ -0,0 +1,62 @@ +include("${CURRENT_HOST_INSTALLED_DIR}/share/vcpkg-cmake-get-vars/vcpkg-port-config.cmake") +# Overwrite builtin scripts +include("${CMAKE_CURRENT_LIST_DIR}/vcpkg_configure_meson.cmake") +include("${CMAKE_CURRENT_LIST_DIR}/vcpkg_install_meson.cmake") + +set(meson_short_hash @MESON_SHORT_HASH@) + +# Setup meson: +set(program MESON) +set(program_version @VERSION@) +set(program_name meson) +set(search_names meson meson.py) +set(ref "${program_version}") +set(path_to_search "${DOWNLOADS}/tools/meson-${program_version}-${meson_short_hash}") +set(download_urls "https://github.com/mesonbuild/meson/archive/${ref}.tar.gz") +set(download_filename "meson-${ref}.tar.gz") +set(download_sha512 bd2e65f0863d9cb974e659ff502d773e937b8a60aaddfd7d81e34cd2c296c8e82bf214d790ac089ba441543059dfc2677ba95ed51f676df9da420859f404a907) + +find_program(SCRIPT_MESON NAMES ${search_names} PATHS "${path_to_search}" NO_DEFAULT_PATH) # NO_DEFAULT_PATH due top patching + +if(NOT SCRIPT_MESON) + vcpkg_download_distfile(archive_path + URLS ${download_urls} + SHA512 "${download_sha512}" + FILENAME "${download_filename}" + ) + file(REMOVE_RECURSE "${path_to_search}") + file(REMOVE_RECURSE "${path_to_search}-tmp") + file(MAKE_DIRECTORY "${path_to_search}-tmp") + file(ARCHIVE_EXTRACT INPUT "${archive_path}" + DESTINATION "${path_to_search}-tmp" + #PATTERNS "**/mesonbuild/*" "**/*.py" + ) + z_vcpkg_apply_patches( + SOURCE_PATH "${path_to_search}-tmp/meson-${ref}" + PATCHES + @PATCHES@ + ) + file(MAKE_DIRECTORY "${path_to_search}") + file(RENAME "${path_to_search}-tmp/meson-${ref}/meson.py" "${path_to_search}/meson.py") + file(RENAME "${path_to_search}-tmp/meson-${ref}/mesonbuild" "${path_to_search}/mesonbuild") + file(REMOVE_RECURSE "${path_to_search}-tmp") + set(SCRIPT_MESON "${path_to_search}/meson.py") +endif() + +# Check required python version +vcpkg_find_acquire_program(PYTHON3) +vcpkg_execute_in_download_mode( + COMMAND "${PYTHON3}" --version + OUTPUT_VARIABLE version_contents + WORKING_DIRECTORY "${CURRENT_BUILDTREES_DIR}" +) +string(REGEX MATCH [[[0-9]+\.[0-9]+\.[0-9]+]] python_ver "${version_contents}") + +set(min_required 3.7) +if(python_ver VERSION_LESS "${min_required}") + message(FATAL_ERROR "Found Python version '${python_ver} at ${PYTHON3}' is insufficient for meson. meson requires at least version '${min_required}'") +else() + message(STATUS "Found Python version '${python_ver} at ${PYTHON3}'") +endif() + +message(STATUS "Using meson: ${SCRIPT_MESON}") diff --git a/cmake/vcpkg-ports/vcpkg-tool-meson/vcpkg.json b/cmake/vcpkg-ports/vcpkg-tool-meson/vcpkg.json new file mode 100644 index 0000000..04a0cbb --- /dev/null +++ b/cmake/vcpkg-ports/vcpkg-tool-meson/vcpkg.json @@ -0,0 +1,11 @@ +{ + "name": "vcpkg-tool-meson", + "version": "1.8.2", + "description": "Meson build system", + "homepage": "https://github.com/mesonbuild/meson", + "license": "Apache-2.0", + "supports": "native", + "dependencies": [ + "vcpkg-cmake-get-vars" + ] +} diff --git a/cmake/vcpkg-ports/vcpkg-tool-meson/vcpkg_configure_meson.cmake b/cmake/vcpkg-ports/vcpkg-tool-meson/vcpkg_configure_meson.cmake new file mode 100644 index 0000000..6b00200 --- /dev/null +++ b/cmake/vcpkg-ports/vcpkg-tool-meson/vcpkg_configure_meson.cmake @@ -0,0 +1,480 @@ +function(z_vcpkg_meson_set_proglist_variables config_type) + if(VCPKG_TARGET_IS_WINDOWS) + set(proglist MT AR) + else() + set(proglist AR RANLIB STRIP NM OBJDUMP DLLTOOL MT) + endif() + foreach(prog IN LISTS proglist) + if(VCPKG_DETECTED_CMAKE_${prog}) + if(meson_${prog}) + string(TOUPPER "MESON_${meson_${prog}}" var_to_set) + set("${var_to_set}" "${meson_${prog}} = ['${VCPKG_DETECTED_CMAKE_${prog}}']" PARENT_SCOPE) + elseif(${prog} STREQUAL AR AND VCPKG_COMBINED_STATIC_LINKER_FLAGS_${config_type}) + # Probably need to move AR somewhere else + string(TOLOWER "${prog}" proglower) + z_vcpkg_meson_convert_compiler_flags_to_list(ar_flags "${VCPKG_COMBINED_STATIC_LINKER_FLAGS_${config_type}}") + list(PREPEND ar_flags "${VCPKG_DETECTED_CMAKE_${prog}}") + z_vcpkg_meson_convert_list_to_python_array(ar_flags ${ar_flags}) + set("MESON_AR" "${proglower} = ${ar_flags}" PARENT_SCOPE) + else() + string(TOUPPER "MESON_${prog}" var_to_set) + string(TOLOWER "${prog}" proglower) + set("${var_to_set}" "${proglower} = ['${VCPKG_DETECTED_CMAKE_${prog}}']" PARENT_SCOPE) + endif() + endif() + endforeach() + set(compilers "${arg_LANGUAGES}") + if(VCPKG_TARGET_IS_WINDOWS) + list(APPEND compilers RC) + endif() + set(meson_RC windres) + set(meson_Fortran fortran) + set(meson_CXX cpp) + foreach(prog IN LISTS compilers) + if(VCPKG_DETECTED_CMAKE_${prog}_COMPILER) + string(TOUPPER "MESON_${prog}" var_to_set) + if(meson_${prog}) + if(VCPKG_COMBINED_${prog}_FLAGS_${config_type}) + # Need compiler flags in prog vars for sanity check. + z_vcpkg_meson_convert_compiler_flags_to_list(${prog}flags "${VCPKG_COMBINED_${prog}_FLAGS_${config_type}}") + endif() + list(PREPEND ${prog}flags "${VCPKG_DETECTED_CMAKE_${prog}_COMPILER}") + list(FILTER ${prog}flags EXCLUDE REGEX "(-|/)nologo") # Breaks compiler detection otherwise + z_vcpkg_meson_convert_list_to_python_array(${prog}flags ${${prog}flags}) + set("${var_to_set}" "${meson_${prog}} = ${${prog}flags}" PARENT_SCOPE) + if (DEFINED VCPKG_DETECTED_CMAKE_${prog}_COMPILER_ID + AND NOT VCPKG_DETECTED_CMAKE_${prog}_COMPILER_ID MATCHES "^(GNU|Intel)$" + AND VCPKG_DETECTED_CMAKE_LINKER) + string(TOUPPER "MESON_${prog}_LD" var_to_set) + set(${var_to_set} "${meson_${prog}}_ld = ['${VCPKG_DETECTED_CMAKE_LINKER}']" PARENT_SCOPE) + endif() + else() + if(VCPKG_COMBINED_${prog}_FLAGS_${config_type}) + # Need compiler flags in prog vars for sanity check. + z_vcpkg_meson_convert_compiler_flags_to_list(${prog}flags "${VCPKG_COMBINED_${prog}_FLAGS_${config_type}}") + endif() + list(PREPEND ${prog}flags "${VCPKG_DETECTED_CMAKE_${prog}_COMPILER}") + list(FILTER ${prog}flags EXCLUDE REGEX "(-|/)nologo") # Breaks compiler detection otherwise + z_vcpkg_meson_convert_list_to_python_array(${prog}flags ${${prog}flags}) + string(TOLOWER "${prog}" proglower) + set("${var_to_set}" "${proglower} = ${${prog}flags}" PARENT_SCOPE) + if (DEFINED VCPKG_DETECTED_CMAKE_${prog}_COMPILER_ID + AND NOT VCPKG_DETECTED_CMAKE_${prog}_COMPILER_ID MATCHES "^(GNU|Intel)$" + AND VCPKG_DETECTED_CMAKE_LINKER) + string(TOUPPER "MESON_${prog}_LD" var_to_set) + set(${var_to_set} "${proglower}_ld = ['${VCPKG_DETECTED_CMAKE_LINKER}']" PARENT_SCOPE) + endif() + endif() + endif() + endforeach() +endfunction() + +function(z_vcpkg_meson_convert_compiler_flags_to_list out_var compiler_flags) + separate_arguments(cmake_list NATIVE_COMMAND "${compiler_flags}") + list(TRANSFORM cmake_list REPLACE ";" [[\\;]]) + set("${out_var}" "${cmake_list}" PARENT_SCOPE) +endfunction() + +function(z_vcpkg_meson_convert_list_to_python_array out_var) + z_vcpkg_function_arguments(flag_list 1) + vcpkg_list(REMOVE_ITEM flag_list "") # remove empty elements if any + vcpkg_list(JOIN flag_list "', '" flag_list) + set("${out_var}" "['${flag_list}']" PARENT_SCOPE) +endfunction() + +# Generates the required compiler properties for meson +function(z_vcpkg_meson_set_flags_variables config_type) + if(VCPKG_TARGET_IS_WINDOWS AND NOT VCPKG_TARGET_IS_MINGW) + set(libpath_flag /LIBPATH:) + else() + set(libpath_flag -L) + endif() + if(config_type STREQUAL "DEBUG") + set(path_suffix "/debug") + else() + set(path_suffix "") + endif() + + set(includepath "-I${CURRENT_INSTALLED_DIR}/include") + set(libpath "${libpath_flag}${CURRENT_INSTALLED_DIR}${path_suffix}/lib") + + foreach(lang IN LISTS arg_LANGUAGES) + z_vcpkg_meson_convert_compiler_flags_to_list(${lang}flags "${VCPKG_COMBINED_${lang}_FLAGS_${config_type}}") + if(lang MATCHES "^(C|CXX)$") + vcpkg_list(APPEND ${lang}flags "${includepath}") + endif() + z_vcpkg_meson_convert_list_to_python_array(${lang}flags ${${lang}flags}) + set(lang_mapping "${lang}") + if(lang STREQUAL "Fortran") + set(lang_mapping "FC") + endif() + string(TOLOWER "${lang_mapping}" langlower) + if(lang STREQUAL "CXX") + set(langlower cpp) + endif() + set(MESON_${lang_mapping}FLAGS "${langlower}_args = ${${lang}flags}\n") + set(linker_flags "${VCPKG_COMBINED_SHARED_LINKER_FLAGS_${config_type}}") + z_vcpkg_meson_convert_compiler_flags_to_list(linker_flags "${linker_flags}") + vcpkg_list(APPEND linker_flags "${libpath}") + z_vcpkg_meson_convert_list_to_python_array(linker_flags ${linker_flags}) + string(APPEND MESON_${lang_mapping}FLAGS "${langlower}_link_args = ${linker_flags}\n") + set(MESON_${lang_mapping}FLAGS "${MESON_${lang_mapping}FLAGS}" PARENT_SCOPE) + endforeach() +endfunction() + +function(z_vcpkg_get_build_and_host_system build_system host_system is_cross) #https://mesonbuild.com/Cross-compilation.html + set(build_unknown FALSE) + if(CMAKE_HOST_WIN32) + if(DEFINED ENV{PROCESSOR_ARCHITEW6432}) + set(build_arch $ENV{PROCESSOR_ARCHITEW6432}) + else() + set(build_arch $ENV{PROCESSOR_ARCHITECTURE}) + endif() + if(build_arch MATCHES "(amd|AMD)64") + set(build_cpu_fam x86_64) + set(build_cpu x86_64) + elseif(build_arch MATCHES "(x|X)86") + set(build_cpu_fam x86) + set(build_cpu i686) + elseif(build_arch MATCHES "^(ARM|arm)64$") + set(build_cpu_fam aarch64) + set(build_cpu armv8) + elseif(build_arch MATCHES "^(ARM|arm)$") + set(build_cpu_fam arm) + set(build_cpu armv7hl) + else() + if(NOT DEFINED VCPKG_MESON_CROSS_FILE OR NOT DEFINED VCPKG_MESON_NATIVE_FILE) + message(WARNING "Unsupported build architecture ${build_arch}! Please set VCPKG_MESON_(CROSS|NATIVE)_FILE to a meson file containing the build_machine entry!") + endif() + set(build_unknown TRUE) + endif() + elseif(CMAKE_HOST_UNIX) + # at this stage, CMAKE_HOST_SYSTEM_PROCESSOR is not defined + execute_process( + COMMAND uname -m + OUTPUT_VARIABLE MACHINE + OUTPUT_STRIP_TRAILING_WHITESPACE + COMMAND_ERROR_IS_FATAL ANY) + + # Show real machine architecture to visually understand whether we are in a native Apple Silicon terminal or running under Rosetta emulation + debug_message("Machine: ${MACHINE}") + + if(MACHINE MATCHES "arm64|aarch64") + set(build_cpu_fam aarch64) + set(build_cpu armv8) + elseif(MACHINE MATCHES "armv7h?l") + set(build_cpu_fam arm) + set(build_cpu ${MACHINE}) + elseif(MACHINE MATCHES "x86_64|amd64") + set(build_cpu_fam x86_64) + set(build_cpu x86_64) + elseif(MACHINE MATCHES "x86|i686") + set(build_cpu_fam x86) + set(build_cpu i686) + elseif(MACHINE MATCHES "i386") + set(build_cpu_fam x86) + set(build_cpu i386) + elseif(MACHINE MATCHES "loongarch64") + set(build_cpu_fam loongarch64) + set(build_cpu loongarch64) + else() + # https://github.com/mesonbuild/meson/blob/master/docs/markdown/Reference-tables.md#cpu-families + if(NOT DEFINED VCPKG_MESON_CROSS_FILE OR NOT DEFINED VCPKG_MESON_NATIVE_FILE) + message(WARNING "Unhandled machine: ${MACHINE}! Please set VCPKG_MESON_(CROSS|NATIVE)_FILE to a meson file containing the build_machine entry!") + endif() + set(build_unknown TRUE) + endif() + else() + if(NOT DEFINED VCPKG_MESON_CROSS_FILE OR NOT DEFINED VCPKG_MESON_NATIVE_FILE) + message(WARNING "Failed to detect the build architecture! Please set VCPKG_MESON_(CROSS|NATIVE)_FILE to a meson file containing the build_machine entry!") + endif() + set(build_unknown TRUE) + endif() + + set(build "[build_machine]\n") # Machine the build is performed on + string(APPEND build "endian = 'little'\n") + if(CMAKE_HOST_WIN32) + string(APPEND build "system = 'windows'\n") + elseif(CMAKE_HOST_APPLE) + string(APPEND build "system = 'darwin'\n") + elseif(CYGWIN) + string(APPEND build "system = 'cygwin'\n") + elseif(CMAKE_HOST_UNIX) + string(APPEND build "system = 'linux'\n") + else() + set(build_unknown TRUE) + endif() + + if(DEFINED build_cpu_fam) + string(APPEND build "cpu_family = '${build_cpu_fam}'\n") + endif() + if(DEFINED build_cpu) + string(APPEND build "cpu = '${build_cpu}'") + endif() + if(NOT build_unknown) + set(${build_system} "${build}" PARENT_SCOPE) + endif() + + set(host_unkown FALSE) + if(VCPKG_TARGET_ARCHITECTURE MATCHES "(amd|AMD|x|X)64") + set(host_cpu_fam x86_64) + set(host_cpu x86_64) + elseif(VCPKG_TARGET_ARCHITECTURE MATCHES "(x|X)86") + set(host_cpu_fam x86) + set(host_cpu i686) + elseif(VCPKG_TARGET_ARCHITECTURE MATCHES "^(ARM|arm)64$") + set(host_cpu_fam aarch64) + set(host_cpu armv8) + elseif(VCPKG_TARGET_ARCHITECTURE MATCHES "^(ARM|arm)$") + set(host_cpu_fam arm) + set(host_cpu armv7hl) + elseif(VCPKG_TARGET_ARCHITECTURE MATCHES "loongarch64") + set(host_cpu_fam loongarch64) + set(host_cpu loongarch64) + elseif(VCPKG_TARGET_ARCHITECTURE MATCHES "wasm32") + set(host_cpu_fam wasm32) + set(host_cpu wasm32) + else() + if(NOT DEFINED VCPKG_MESON_CROSS_FILE OR NOT DEFINED VCPKG_MESON_NATIVE_FILE) + message(WARNING "Unsupported target architecture ${VCPKG_TARGET_ARCHITECTURE}! Please set VCPKG_MESON_(CROSS|NATIVE)_FILE to a meson file containing the host_machine entry!" ) + endif() + set(host_unkown TRUE) + endif() + + set(host "[host_machine]\n") # host=target in vcpkg. + string(APPEND host "endian = 'little'\n") + if(NOT VCPKG_CMAKE_SYSTEM_NAME OR VCPKG_TARGET_IS_MINGW OR VCPKG_TARGET_IS_UWP) + set(meson_system_name "windows") + else() + string(TOLOWER "${VCPKG_CMAKE_SYSTEM_NAME}" meson_system_name) + endif() + string(APPEND host "system = '${meson_system_name}'\n") + string(APPEND host "cpu_family = '${host_cpu_fam}'\n") + string(APPEND host "cpu = '${host_cpu}'") + if(NOT host_unkown) + set(${host_system} "${host}" PARENT_SCOPE) + endif() + + if(NOT build_cpu_fam MATCHES "${host_cpu_fam}" + OR VCPKG_TARGET_IS_ANDROID OR VCPKG_TARGET_IS_IOS OR VCPKG_TARGET_IS_UWP + OR (VCPKG_TARGET_IS_MINGW AND NOT CMAKE_HOST_WIN32)) + set(${is_cross} TRUE PARENT_SCOPE) + endif() +endfunction() + +function(z_vcpkg_meson_setup_extra_windows_variables config_type) + ## b_vscrt + if(VCPKG_CRT_LINKAGE STREQUAL "static") + set(crt_type "mt") + else() + set(crt_type "md") + endif() + if(config_type STREQUAL "DEBUG") + set(crt_type "${crt_type}d") + endif() + set(MESON_VSCRT_LINKAGE "b_vscrt = '${crt_type}'" PARENT_SCOPE) + ## winlibs + separate_arguments(c_winlibs NATIVE_COMMAND "${VCPKG_DETECTED_CMAKE_C_STANDARD_LIBRARIES}") + separate_arguments(cpp_winlibs NATIVE_COMMAND "${VCPKG_DETECTED_CMAKE_CXX_STANDARD_LIBRARIES}") + z_vcpkg_meson_convert_list_to_python_array(c_winlibs ${c_winlibs}) + z_vcpkg_meson_convert_list_to_python_array(cpp_winlibs ${cpp_winlibs}) + set(MESON_WINLIBS "c_winlibs = ${c_winlibs}\n") + string(APPEND MESON_WINLIBS "cpp_winlibs = ${cpp_winlibs}") + set(MESON_WINLIBS "${MESON_WINLIBS}" PARENT_SCOPE) +endfunction() + +function(z_vcpkg_meson_setup_variables config_type) + set(meson_var_list VSCRT_LINKAGE WINLIBS MT AR RC C C_LD CXX CXX_LD OBJC OBJC_LD OBJCXX OBJCXX_LD FC FC_LD WINDRES CFLAGS CXXFLAGS OBJCFLAGS OBJCXXFLAGS FCFLAGS SHARED_LINKER_FLAGS) + foreach(var IN LISTS meson_var_list) + set(MESON_${var} "") + endforeach() + + if(VCPKG_TARGET_IS_WINDOWS) + z_vcpkg_meson_setup_extra_windows_variables("${config_type}") + endif() + + z_vcpkg_meson_set_proglist_variables("${config_type}") + z_vcpkg_meson_set_flags_variables("${config_type}") + + foreach(var IN LISTS meson_var_list) + set(MESON_${var} "${MESON_${var}}" PARENT_SCOPE) + endforeach() +endfunction() + +function(vcpkg_generate_meson_cmd_args) + cmake_parse_arguments(PARSE_ARGV 0 arg + "" + "OUTPUT;CONFIG" + "OPTIONS;LANGUAGES;ADDITIONAL_BINARIES;ADDITIONAL_PROPERTIES" + ) + + if(NOT arg_LANGUAGES) + set(arg_LANGUAGES C CXX) + endif() + + vcpkg_list(JOIN arg_ADDITIONAL_BINARIES "\n" MESON_ADDITIONAL_BINARIES) + vcpkg_list(JOIN arg_ADDITIONAL_PROPERTIES "\n" MESON_ADDITIONAL_PROPERTIES) + + set(buildtype "${arg_CONFIG}") + + if(NOT VCPKG_CHAINLOAD_TOOLCHAIN_FILE) + z_vcpkg_select_default_vcpkg_chainload_toolchain() + endif() + vcpkg_list(APPEND VCPKG_CMAKE_CONFIGURE_OPTIONS "-DVCPKG_LANGUAGES=${arg_LANGUAGES}") + vcpkg_cmake_get_vars(cmake_vars_file) + debug_message("Including cmake vars from: ${cmake_vars_file}") + include("${cmake_vars_file}") + + vcpkg_list(APPEND arg_OPTIONS --backend ninja --wrap-mode nodownload -Doptimization=plain) + + z_vcpkg_get_build_and_host_system(MESON_HOST_MACHINE MESON_BUILD_MACHINE IS_CROSS) + + if(arg_CONFIG STREQUAL "DEBUG") + set(suffix "dbg") + else() + string(SUBSTRING "${arg_CONFIG}" 0 3 suffix) + string(TOLOWER "${suffix}" suffix) + endif() + set(meson_input_file_${buildtype} "${CURRENT_BUILDTREES_DIR}/meson-${TARGET_TRIPLET}-${suffix}.log") + + if(IS_CROSS) + # VCPKG_CROSSCOMPILING is not used since it regresses a lot of ports in x64-windows-x triplets + # For consistency this should proably be changed in the future? + vcpkg_list(APPEND arg_OPTIONS --native "${SCRIPTS}/buildsystems/meson/none.txt") + vcpkg_list(APPEND arg_OPTIONS --cross "${meson_input_file_${buildtype}}") + else() + vcpkg_list(APPEND arg_OPTIONS --native "${meson_input_file_${buildtype}}") + endif() + + # User provided cross/native files + if(VCPKG_MESON_NATIVE_FILE) + vcpkg_list(APPEND arg_OPTIONS --native "${VCPKG_MESON_NATIVE_FILE}") + endif() + if(VCPKG_MESON_NATIVE_FILE_${buildtype}) + vcpkg_list(APPEND arg_OPTIONS --native "${VCPKG_MESON_NATIVE_FILE_${buildtype}}") + endif() + if(VCPKG_MESON_CROSS_FILE) + vcpkg_list(APPEND arg_OPTIONS --cross "${VCPKG_MESON_CROSS_FILE}") + endif() + if(VCPKG_MESON_CROSS_FILE_${buildtype}) + vcpkg_list(APPEND arg_OPTIONS --cross "${VCPKG_MESON_CROSS_FILE_${buildtype}}") + endif() + + vcpkg_list(APPEND arg_OPTIONS --libdir lib) # else meson install into an architecture describing folder + vcpkg_list(APPEND arg_OPTIONS --pkgconfig.relocatable) + + if(arg_CONFIG STREQUAL "RELEASE") + vcpkg_list(APPEND arg_OPTIONS -Ddebug=false --prefix "${CURRENT_PACKAGES_DIR}") + vcpkg_list(APPEND arg_OPTIONS "--pkg-config-path;['${CURRENT_INSTALLED_DIR}/lib/pkgconfig','${CURRENT_INSTALLED_DIR}/share/pkgconfig']") + if(VCPKG_TARGET_IS_WINDOWS) + vcpkg_list(APPEND arg_OPTIONS "-Dcmake_prefix_path=['${CURRENT_INSTALLED_DIR}','${CURRENT_INSTALLED_DIR}/debug','${CURRENT_INSTALLED_DIR}/share']") + else() + vcpkg_list(APPEND arg_OPTIONS "-Dcmake_prefix_path=['${CURRENT_INSTALLED_DIR}','${CURRENT_INSTALLED_DIR}/debug']") + endif() + elseif(arg_CONFIG STREQUAL "DEBUG") + vcpkg_list(APPEND arg_OPTIONS -Ddebug=true --prefix "${CURRENT_PACKAGES_DIR}/debug" --includedir ../include) + vcpkg_list(APPEND arg_OPTIONS "--pkg-config-path;['${CURRENT_INSTALLED_DIR}/debug/lib/pkgconfig','${CURRENT_INSTALLED_DIR}/share/pkgconfig']") + if(VCPKG_TARGET_IS_WINDOWS) + vcpkg_list(APPEND arg_OPTIONS "-Dcmake_prefix_path=['${CURRENT_INSTALLED_DIR}/debug','${CURRENT_INSTALLED_DIR}','${CURRENT_INSTALLED_DIR}/share']") + else() + vcpkg_list(APPEND arg_OPTIONS "-Dcmake_prefix_path=['${CURRENT_INSTALLED_DIR}/debug','${CURRENT_INSTALLED_DIR}']") + endif() + else() + message(FATAL_ERROR "Unknown configuration. Only DEBUG and RELEASE are valid values.") + endif() + + # Allow overrides / additional configuration variables from triplets + if(DEFINED VCPKG_MESON_CONFIGURE_OPTIONS) + vcpkg_list(APPEND arg_OPTIONS ${VCPKG_MESON_CONFIGURE_OPTIONS}) + endif() + if(DEFINED VCPKG_MESON_CONFIGURE_OPTIONS_${buildtype}) + vcpkg_list(APPEND arg_OPTIONS ${VCPKG_MESON_CONFIGURE_OPTIONS_${buildtype}}) + endif() + + if(VCPKG_LIBRARY_LINKAGE STREQUAL "dynamic") + set(MESON_DEFAULT_LIBRARY shared) + else() + set(MESON_DEFAULT_LIBRARY static) + endif() + set(MESON_CMAKE_BUILD_TYPE "${cmake_build_type_${buildtype}}") + z_vcpkg_meson_setup_variables(${buildtype}) + configure_file("${CMAKE_CURRENT_FUNCTION_LIST_DIR}/meson.template.in" "${meson_input_file_${buildtype}}" @ONLY) + set("${arg_OUTPUT}" ${arg_OPTIONS} PARENT_SCOPE) +endfunction() + +function(vcpkg_configure_meson) + # parse parameters such that semicolons in options arguments to COMMAND don't get erased + cmake_parse_arguments(PARSE_ARGV 0 arg + "NO_PKG_CONFIG" + "SOURCE_PATH" + "OPTIONS;OPTIONS_DEBUG;OPTIONS_RELEASE;LANGUAGES;ADDITIONAL_BINARIES;ADDITIONAL_NATIVE_BINARIES;ADDITIONAL_CROSS_BINARIES;ADDITIONAL_PROPERTIES" + ) + + if(DEFINED arg_ADDITIONAL_NATIVE_BINARIES OR DEFINED arg_ADDITIONAL_CROSS_BINARIES) + message(WARNING "Options ADDITIONAL_(NATIVE|CROSS)_BINARIES have been deprecated. Only use ADDITIONAL_BINARIES!") + endif() + vcpkg_list(APPEND arg_ADDITIONAL_BINARIES ${arg_ADDITIONAL_NATIVE_BINARIES} ${arg_ADDITIONAL_CROSS_BINARIES}) + vcpkg_list(REMOVE_DUPLICATES arg_ADDITIONAL_BINARIES) + + file(REMOVE_RECURSE "${CURRENT_BUILDTREES_DIR}/${TARGET_TRIPLET}-rel") + file(REMOVE_RECURSE "${CURRENT_BUILDTREES_DIR}/${TARGET_TRIPLET}-dbg") + + vcpkg_find_acquire_program(MESON) + + get_filename_component(CMAKE_PATH "${CMAKE_COMMAND}" DIRECTORY) + vcpkg_add_to_path("${CMAKE_PATH}") # Make CMake invokeable for Meson + + vcpkg_find_acquire_program(NINJA) + + if(NOT arg_NO_PKG_CONFIG) + vcpkg_find_acquire_program(PKGCONFIG) + set(ENV{PKG_CONFIG} "${PKGCONFIG}") + endif() + + vcpkg_find_acquire_program(PYTHON3) + get_filename_component(PYTHON3_DIR "${PYTHON3}" DIRECTORY) + vcpkg_add_to_path(PREPEND "${PYTHON3_DIR}") + + set(buildtypes "") + if(NOT DEFINED VCPKG_BUILD_TYPE OR VCPKG_BUILD_TYPE STREQUAL "debug") + set(buildname "DEBUG") + set(cmake_build_type_${buildname} "Debug") + vcpkg_list(APPEND buildtypes "${buildname}") + set(path_suffix_${buildname} "debug/") + set(suffix_${buildname} "dbg") + endif() + if(NOT DEFINED VCPKG_BUILD_TYPE OR VCPKG_BUILD_TYPE STREQUAL "release") + set(buildname "RELEASE") + set(cmake_build_type_${buildname} "Release") + vcpkg_list(APPEND buildtypes "${buildname}") + set(path_suffix_${buildname} "") + set(suffix_${buildname} "rel") + endif() + + # configure build + foreach(buildtype IN LISTS buildtypes) + message(STATUS "Configuring ${TARGET_TRIPLET}-${suffix_${buildtype}}") + file(MAKE_DIRECTORY "${CURRENT_BUILDTREES_DIR}/${TARGET_TRIPLET}-${suffix_${buildtype}}") + + vcpkg_generate_meson_cmd_args( + OUTPUT cmd_args + CONFIG ${buildtype} + LANGUAGES ${arg_LANGUAGES} + OPTIONS ${arg_OPTIONS} ${arg_OPTIONS_${buildtype}} + ADDITIONAL_BINARIES ${arg_ADDITIONAL_BINARIES} + ADDITIONAL_PROPERTIES ${arg_ADDITIONAL_PROPERTIES} + ) + + vcpkg_execute_required_process( + COMMAND ${MESON} setup ${cmd_args} ${arg_SOURCE_PATH} + WORKING_DIRECTORY "${CURRENT_BUILDTREES_DIR}/${TARGET_TRIPLET}-${suffix_${buildtype}}" + LOGNAME config-${TARGET_TRIPLET}-${suffix_${buildtype}} + SAVE_LOG_FILES + meson-logs/meson-log.txt + meson-info/intro-dependencies.json + meson-logs/install-log.txt + ) + + message(STATUS "Configuring ${TARGET_TRIPLET}-${suffix_${buildtype}} done") + endforeach() +endfunction() diff --git a/cmake/vcpkg-ports/vcpkg-tool-meson/vcpkg_install_meson.cmake b/cmake/vcpkg-ports/vcpkg-tool-meson/vcpkg_install_meson.cmake new file mode 100644 index 0000000..0351f27 --- /dev/null +++ b/cmake/vcpkg-ports/vcpkg-tool-meson/vcpkg_install_meson.cmake @@ -0,0 +1,71 @@ +function(vcpkg_install_meson) + cmake_parse_arguments(PARSE_ARGV 0 arg "ADD_BIN_TO_PATH" "" "") + + vcpkg_find_acquire_program(NINJA) + unset(ENV{DESTDIR}) # installation directory was already specified with '--prefix' option + + if(VCPKG_TARGET_IS_OSX) + vcpkg_backup_env_variables(VARS SDKROOT MACOSX_DEPLOYMENT_TARGET) + set(ENV{SDKROOT} "${VCPKG_DETECTED_CMAKE_OSX_SYSROOT}") + set(ENV{MACOSX_DEPLOYMENT_TARGET} "${VCPKG_DETECTED_CMAKE_OSX_DEPLOYMENT_TARGET}") + endif() + + foreach(buildtype IN ITEMS "debug" "release") + if(DEFINED VCPKG_BUILD_TYPE AND NOT VCPKG_BUILD_TYPE STREQUAL buildtype) + continue() + endif() + + if(buildtype STREQUAL "debug") + set(short_buildtype "dbg") + else() + set(short_buildtype "rel") + endif() + + message(STATUS "Package ${TARGET_TRIPLET}-${short_buildtype}") + if(arg_ADD_BIN_TO_PATH) + vcpkg_backup_env_variables(VARS PATH) + if(buildtype STREQUAL "debug") + vcpkg_add_to_path(PREPEND "${CURRENT_INSTALLED_DIR}/debug/bin") + else() + vcpkg_add_to_path(PREPEND "${CURRENT_INSTALLED_DIR}/bin") + endif() + endif() + vcpkg_execute_required_process( + COMMAND "${NINJA}" install -v + WORKING_DIRECTORY "${CURRENT_BUILDTREES_DIR}/${TARGET_TRIPLET}-${short_buildtype}" + LOGNAME package-${TARGET_TRIPLET}-${short_buildtype} + ) + if(arg_ADD_BIN_TO_PATH) + vcpkg_restore_env_variables(VARS PATH) + endif() + endforeach() + + vcpkg_list(SET renamed_libs) + if(VCPKG_TARGET_IS_WINDOWS AND VCPKG_LIBRARY_LINKAGE STREQUAL static AND NOT VCPKG_TARGET_IS_MINGW) + # Meson names all static libraries lib.a which basically breaks the world + file(GLOB_RECURSE gen_libraries "${CURRENT_PACKAGES_DIR}*/**/lib*.a") + foreach(gen_library IN LISTS gen_libraries) + get_filename_component(libdir "${gen_library}" DIRECTORY) + get_filename_component(libname "${gen_library}" NAME) + string(REGEX REPLACE ".a$" ".lib" fixed_librawname "${libname}") + string(REGEX REPLACE "^lib" "" fixed_librawname "${fixed_librawname}") + file(RENAME "${gen_library}" "${libdir}/${fixed_librawname}") + # For cmake fixes. + string(REGEX REPLACE ".a$" "" origin_librawname "${libname}") + string(REGEX REPLACE ".lib$" "" fixed_librawname "${fixed_librawname}") + vcpkg_list(APPEND renamed_libs ${fixed_librawname}) + set(${librawname}_old ${origin_librawname}) + set(${librawname}_new ${fixed_librawname}) + endforeach() + file(GLOB_RECURSE cmake_files "${CURRENT_PACKAGES_DIR}*/*.cmake") + foreach(cmake_file IN LISTS cmake_files) + foreach(current_lib IN LISTS renamed_libs) + vcpkg_replace_string("${cmake_file}" "${${current_lib}_old}" "${${current_lib}_new}" IGNORE_UNCHANGED) + endforeach() + endforeach() + endif() + + if(VCPKG_TARGET_IS_OSX) + vcpkg_restore_env_variables(VARS SDKROOT MACOSX_DEPLOYMENT_TARGET) + endif() +endfunction() diff --git a/cmake/vcpkg-triplets/universal-osx.cmake b/cmake/vcpkg-triplets/universal-osx.cmake new file mode 100644 index 0000000..1c91a56 --- /dev/null +++ b/cmake/vcpkg-triplets/universal-osx.cmake @@ -0,0 +1,8 @@ +# See https://github.com/microsoft/vcpkg/discussions/19454 +# NOTE: Try to keep in sync with default arm64-osx definition +set(VCPKG_TARGET_ARCHITECTURE x64) +set(VCPKG_CRT_LINKAGE dynamic) +set(VCPKG_LIBRARY_LINKAGE static) + +set(VCPKG_CMAKE_SYSTEM_NAME Darwin) +set(VCPKG_OSX_ARCHITECTURES "arm64;x86_64") diff --git a/default.nix b/default.nix new file mode 100644 index 0000000..5ecef55 --- /dev/null +++ b/default.nix @@ -0,0 +1,4 @@ +(import (fetchTarball { + url = "https://github.com/edolstra/flake-compat/archive/ff81ac966bb2cae68946d5ed5fc4994f96d0ffec.tar.gz"; + sha256 = "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU="; +}) { src = ./.; }).defaultNix diff --git a/docs/ADD_JAVA_GUIDE.md b/docs/ADD_JAVA_GUIDE.md new file mode 100644 index 0000000..9a34b1b --- /dev/null +++ b/docs/ADD_JAVA_GUIDE.md @@ -0,0 +1,150 @@ +# Adding Java 21 to Your Launcher (For Minecraft 1.21.10) + +## The Issue +Minecraft 1.21.10 requires **Java 21**, but your system has Java 25 (too new). + +## Quick Solution - Install Java 21 Locally (No Root Needed) + +### Step 1: Download Java 21 + +```bash +# Go to your launcher folder +cd /home/admin/ai-lab/_projects/_minecraft/launcher/ + +# Create java directory +mkdir -p java + +# Download Adoptium Java 21 (portable tar.gz) +cd java +wget https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.5%2B11/OpenJDK21U-jdk_x64_linux_hotspot_21.0.5_11.tar.gz + +# Extract it +tar xzf OpenJDK21U-jdk_x64_linux_hotspot_21.0.5_11.tar.gz + +# Verify +ls -la +# You should see: jdk-21.0.5+11/ +``` + +### Step 2: Configure Launcher to Use It + +**Option A: Via Launcher GUI (Easiest)** +1. Run your launcher: `bash start.sh` +2. Go to **Settings** (gear icon) +3. Click **Java** tab +4. Under "Java Installation", click **"Auto-detect"** or **"Browse"** +5. Navigate to: `/home/admin/ai-lab/_projects/_minecraft/launcher/java/jdk-21.0.5+11/bin/java` +6. Click **OK** + +**Option B: Edit Config File Directly** +```bash +cd /home/admin/ai-lab/_projects/_minecraft/launcher/ + +# Edit the config file +nano bin/prismlauncher.cfg + +# Find or add these lines: +JavaPath=/home/admin/ai-lab/_projects/_minecraft/launcher/java/jdk-21.0.5+11/bin/java +JavaDetect=true + +# Save (Ctrl+X, Y, Enter) +``` + +### Step 3: Test It + +```bash +# Run launcher +cd /home/admin/ai-lab/_projects/_minecraft/launcher/ +bash start.sh + +# Create a 1.21.10 instance +# It should now work without version errors! +``` + +## Alternative Java 21 Downloads + +If the above link doesn't work: + +### Amazon Corretto 21 +```bash +cd java +wget https://corretto.aws/downloads/latest/amazon-corretto-21-x64-linux-jdk.tar.gz +tar xzf amazon-corretto-21-x64-linux-jdk.tar.gz +``` + +### Azul Zulu 21 +```bash +cd java +wget https://cdn.azul.com/zulu/bin/zulu21.38.21-ca-jdk21.0.5-linux_x64.tar.gz +tar xzf zulu21.38.21-ca-jdk21.0.5-linux_x64.tar.gz +``` + +## Verify It's Working + +```bash +# Test the Java version +java/jdk-21.0.5+11/bin/java -version + +# Should output: +# openjdk version "21.0.5" +# OpenJDK Runtime Environment ... +# OpenJDK 64-Bit Server VM ... +``` + +## Permanent Storage + +With this setup: +- ✅ Java is stored **inside your launcher folder** +- ✅ Portable - travels with the launcher +- ✅ Works from USB drives +- ✅ No root/sudo needed +- ✅ Doesn't affect system Java + +## Folder Structure After Adding Java + +``` +launcher/ +├── start.sh +├── bin/ +│ └── prismlauncher +├── java/ +│ └── jdk-21.0.5+11/ ← Java 21 installed here +│ ├── bin/ +│ │ └── java ← Point to this +│ └── (other files) +└── share/ +``` + +## For Minecraft 1.21.10 + +**Required Java Versions:** +- Minimum: Java 21 +- Recommended: Java 21.0.5 or later + +**NOT Compatible:** +- ❌ Java 8 (too old) +- ❌ Java 17 (too old for 1.21) +- ❌ Java 25 (too new, causes issues) + +## Quick Install Script + +Copy-paste this entire block to auto-install Java 21: + +```bash +cd /home/admin/ai-lab/_projects/_minecraft/launcher/ +mkdir -p java && cd java +wget https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.5%2B11/OpenJDK21U-jdk_x64_linux_hotspot_21.0.5_11.tar.gz +tar xzf OpenJDK21U-jdk_x64_linux_hotspot_21.0.5_11.tar.gz +cd .. +echo "Java installed!" +ls -la java/ +echo "" +echo "Now configure your launcher to use:" +echo "java/jdk-21.0.5+11/bin/java" +``` + +--- + +**Updated:** 2026-04-13 +**For:** Minecraft 1.21.10 +**Java Version:** 21.0.5+11 (LTS) diff --git a/docs/BLOAT_AUDIT.md b/docs/BLOAT_AUDIT.md new file mode 100644 index 0000000..dd17edd --- /dev/null +++ b/docs/BLOAT_AUDIT.md @@ -0,0 +1,164 @@ +# Bloat Audit — racked.ru launcher + +**Date:** 2026-04-30 +**Total runtime size:** ~1.3 GB (`launcher/`) +**Goal:** identify what's strippable without breaking "load Minecraft reliably" + +--- + +## TL;DR + +| Category | Size | Strippable? | +|---|---|---| +| Mojang game assets (`assets/`) | 552M | **No** — needed by MC | +| Bundled Java 21 (`java/jdk-21.0.9+10/`) | 346M | No, but see below | +| Java tar.gz leftover (`java/java21.tar.gz`) | **198M** | **YES — instant win** | +| MC libraries (`libraries/`) | 99M | No | +| User instance (`instances/Fabulously Optimized/`) | 88M | No (user data) | +| Launcher binary (`bin/prismlauncher`) | 15M | Partial (strip + LTO) | +| Cache (`cache/`) | 5.5M | Yes (regenerated) | +| Translations | 692K | Mostly (see below) | +| Misc state (logs, metacache, sync-conflicts) | ~1M | Yes | + +**Quick wins:** **~200 MB instantly** (java tar.gz + sync conflicts + cache). + +--- + +## Runtime layout (`launcher/`) + +``` +552M assets/ Mojang resources — textures, sounds. Lookup by hash. KEEP. +543M java/ + 198M java21.tar.gz ← extracted already, DELETE + 346M jdk-21.0.9+10/ runtime, KEEP (or move outside launcher) + 99M libraries/ MC + Fabric + Forge libs, on-demand cache. KEEP. + 88M instances/ user world + modpack. SACRED. + 15M bin/prismlauncher main binary + 5.5M cache/ HTTP/asset cache, REGENERATES. delete-safe. + 692K translations/ + 319K mmc_en_GB.qm keep + 316K mmc_en_GB.sync-conflict...qm ← Syncthing leftover, DELETE + 26K index_v2.json keep + 26K index_v2.sync-conflict...json ← DELETE + 196K images/ backgrounds (cat). Strip if no cat wanted. + 56K share/ + 40K metacache/ + 12K icons/ + 0 themes/ iconthemes/ catpacks/ empty +``` + +### Immediate deletes (safe, ~200M): +```bash +cd launcher/ +rm java/java21.tar.gz # 198M +rm translations/*.sync-conflict-* # 316K + 26K +rm prismlauncher.sync-conflict-*.cfg metacache.sync-conflict-* 2>/dev/null +rm -rf cache/* # 5.5M (regens) +``` + +### Optional moves: +- **Java outside launcher folder** — if portability not strictly USB-required, point `JavaPath` at a system Java 21 install. Saves 346M from the launcher tree. Already configured in `prismlauncher.cfg:JavaPath`. + +--- + +## Source bloat (`source/`, ~13M) + +Compile-time only. Trimming reduces binary size + build time. + +### Icon themes — biggest source bloat (~5.5M) + +`source/launcher/resources/` contains **17 icon theme dirs**: + +| Theme | Size | Used? | +|---|---|---| +| `multimc/` | 3.0M | No (legacy) | +| `backgrounds/` | 1.4M | Cat backgrounds | +| `sources/` | 400K | SVG masters | +| `flat_white/` | 208K | **YES (default)** | +| `flat/` | 208K | No | +| `breeze_light/` `breeze_dark/` | 408K | No | +| `pe_light/dark/colored/blue/` | 752K | No | +| `iOS/` `OSX/` | 324K | No | +| `racked_ru/` | 12K | YES (custom) | +| `shaders/` `assets/` `documents/` | 36K | Various | + +**Strippable:** all except `flat_white/`, `racked_ru/`, `sources/`, `assets/`, `shaders/`. Saves ~5M from binary qrc. + +### Mod platforms (`source/launcher/modplatform/`, 564K) + +| Platform | Size | Modern? | +|---|---|---| +| `flame/` (CurseForge) | 124K | Yes | +| `modrinth/` | 92K | Yes | +| `atlauncher/` | 80K | Niche | +| `technic/` | 52K | Dying | +| `legacy_ftb/` | 36K | Dead | +| `ftb/` | 32K | Use modrinth instead | +| `import_ftb/` | 24K | Pair w/ ftb | +| `packwiz/` | 20K | Niche | + +**Recommended keep:** `flame/`, `modrinth/`, helpers, generic. **Strippable:** atlauncher + technic + legacy_ftb + ftb + import_ftb + packwiz = ~244K source, ~1-2M off binary after compile. + +### News system + +Already audited (`NETWORK_AUDIT.md`). News fetches RSS from prismlauncher.org on every startup. Strippable: +- `source/launcher/news/` — `NewsChecker.cpp/h`, `NewsEntry.cpp/h` +- `source/launcher/ui/dialogs/NewsDialog.{cpp,h,ui}` +- News toolbar button in `MainWindow.ui` +- `news.svg` icons across themes + +Saves: ~30K source, removes 1 outbound network call on startup, removes "News" toolbar button. + +### Other strip candidates + +| Component | Source size | Worth stripping? | +|---|---|---| +| `tests/` | 928K | **Already excluded from runtime build** — leave for dev, or `rm -rf` if no tests run | +| `nix/` + `flake.*` | 28K | If not building via Nix, delete | +| `.github/` | 136K | Workflows for upstream CI, drop in fork | +| `program_info/` translations of metainfo | 888K | Bulk is icons (.icns/.ico/.png), required for OS integration | +| `libraries/` (vendored quazip, tomlplusplus, etc.) | 648K | Required, system libs an option but fragile | +| Setup wizard (`launcher/ui/setupwizard/`) | 68K | First-run only — keep | +| Screenshots gallery (`launcher/ui/screenshots`?) | 24K | Niche | + +--- + +## Stripped binary + +`bin/prismlauncher` 15M. Likely already release-built. Confirm: +```bash +file bin/prismlauncher # should say "stripped" +strip --strip-all bin/prismlauncher 2>/dev/null # idempotent +``` +If unstripped, can drop several MB. + +LTO + `-Os` at CMake configure: `-DCMAKE_BUILD_TYPE=MinSizeRel -DCMAKE_INTERPROCEDURAL_OPTIMIZATION=ON` — typically 10–20% binary shrink. + +--- + +## Reliability cost of each strip + +| Strip | Reliability impact | +|---|---| +| Delete java tar.gz | Zero — already extracted | +| Delete sync-conflicts | Zero | +| Delete cache/ | Zero — regenerates on first launch | +| Drop unused icon themes | Zero — user can't pick them anyway | +| Drop FTB/Technic/ATLauncher modplatforms | Zero **if** user only uses CurseForge/Modrinth | +| Disable news | Zero — purely informational, also removes online startup hit | +| Move bundled Java to system Java | Low — if system Java 21 missing, breaks. Keep bundled for portability | +| Strip backgrounds | Zero — cat purely cosmetic | +| Drop multimc/ icon theme | Zero — legacy default replaced by flat_white | + +None of the proposed strips touch MC launching, auth, asset download, mod loading, or world saves. + +--- + +## Recommended action plan (ordered by ROI) + +1. **Now (~200M, zero risk):** delete `java/java21.tar.gz`, sync-conflict files, `cache/*` +2. **Source trim, next rebuild:** drop 12 unused icon themes, FTB/Technic/ATLauncher modplatforms, news system +3. **Build flags:** `MinSizeRel` + LTO + `strip --strip-all` +4. **Optional:** move Java out of `launcher/` if portability not required + +Estimated final size after all: **~700 MB** (down from 1.3 GB). Most remaining is non-strippable (Mojang assets, MC libs, user instance, Java). diff --git a/docs/CHANGES_2026-04-30.md b/docs/CHANGES_2026-04-30.md new file mode 100644 index 0000000..267ea7a --- /dev/null +++ b/docs/CHANGES_2026-04-30.md @@ -0,0 +1,93 @@ +# Source changes — 2026-04-30 + +All edits in `source/`. Rebuild required, then move build artifact into `launcher/`. + +## Branding + +| File | Change | +|---|---| +| `program_info/CMakeLists.txt` | `Launcher_DisplayName` → `"racked.ru launcher"` (was `"Prism Launcher"`) | +| `launcher/Application.cpp:300` | `setApplicationDisplayName()` no longer appends version. Window title becomes `racked.ru launcher` (was `Prism Launcher 11.0.0-develop`) | + +`Launcher_CommonName` left as `PrismLauncher` — used for binary name, install paths, env var prefix. Changing it would require renaming `bin/prismlauncher`, `prismlauncher.cfg`, and `PRISMLAUNCHER_DATA_DIR` env handling. Display-only rebrand is cleaner. + +## "Cracked" scrub — DONE + +All 7 files cleaned. `Diegiwg/PrismLauncher-Cracked` URLs → placeholder `s8n-ru/minecraft-launcher`. Doc prose rephrased to "PrismLauncher upstream fork (Diegiwg)". Per-file copyright headers + LICENSE preserved (GPL-3.0 §5c). + +Files touched: `CMakeLists.txt`, `program_info/CMakeLists.txt`, `program_info/org.prismlauncher.PrismLauncher.metainfo.xml.in`, `README_RELEASE.md`, `PROJECT_SUMMARY.md`, `BUILD_GUIDE.md`, `scripts/create-release.sh`. + +**Open:** placeholder repo slug `s8n-ru/minecraft-launcher` — replace with real repo URL once chosen. + +## News feed → racked.ru + +| File | Setting | Old | New | +|---|---|---|---| +| `CMakeLists.txt:174` | `Launcher_NEWS_RSS_URL` | `https://prismlauncher.org/feed/feed.xml` | `https://racked.ru/feed.xml` | +| `CMakeLists.txt:175` | `Launcher_NEWS_OPEN_URL` | `https://prismlauncher.org/news` | `https://racked.ru/news` | + +**Open:** racked.ru must serve a valid RSS feed at `/feed.xml` or news pane stays empty + retries. Until ready, optional disable by setting URLs blank. + +## Default settings (ported runtime → source defaults) + +| File | Setting | Old default | New default | +|---|---|---|---| +| `launcher/Application.cpp` | `MinMemAlloc` | 512 | 384 | +| `launcher/Application.cpp` | `MaxMemAlloc` | `SysInfo::defaultMaxJvmMem()` | 4096 | +| `launcher/Application.cpp` | `MenuBarInsteadOfToolBar` | false | true | + +See `SETTINGS_AUDIT.md` for full diff table. + +## Bloat strips — DONE + +### Source-side + +| Target | Action | Saved | +|---|---|---| +| `.github/` | deleted | 136K | +| `tests/` + `if(BUILD_TESTING)` block in `CMakeLists.txt:503-505` | deleted | ~928K | +| `nix/`, `flake.nix`, `flake.lock` | deleted | ~28K | +| `launcher/resources/multimc/` icon theme | deleted (3M) but instance icons preserved (see below) | ~2.5M | + +### multimc → racked_ru migration + +multimc theme had only `Application.cpp:950` hardcoded reference (default instance icons) plus Qt theme fallback. Plan B chosen — extracted the 71 instance icons into `racked_ru/`, dropped rest of multimc. + +| File | Change | +|---|---| +| `launcher/resources/racked_ru/{32x32,50x50,128x128,scalable}/instances/` | new — copied from multimc | +| `launcher/resources/racked_ru/racked_ru.qrc` | added 71 file entries under `prefix="/icons/racked_ru"` | +| `launcher/CMakeLists.txt:1284` | removed `resources/multimc/multimc.qrc` from `qt_add_resources()` | +| `launcher/Application.cpp:950-951` | `:/icons/multimc/...instances/` → `:/icons/racked_ru/...instances/` | +| `launcher/ui/themes/ThemeManager.h:94` | `builtinIcons{"flat_white", "multimc"}` → `{"flat_white", "racked_ru"}` | + +**Risk note:** non-instance multimc icons (proxy, language, atlauncher logo, etc.) no longer baked. flat_white covers most UI icons. If a niche icon is missing in active theme, blank slot. Surface during test, easy fix (copy missing icon from upstream multimc archive into flat_white or racked_ru). + +### Runtime-side (`launcher/`) + +| Deleted | Saved | +|---|---| +| `java/java21.tar.gz` (extracted archive leftover) | 198M | +| `*sync-conflict*` (Syncthing leftovers, 4 files) | ~340K | +| `cache/*` (regens on first launch) | ~5.5M | + +`launcher/` size: 1.3G → 1.1G. + +## Verification (after rebuild) + +1. `cd source && cmake -B build -S . -DCMAKE_BUILD_TYPE=Release && cmake --build build -j$(nproc)` +2. Move new `prismlauncher` binary + resource artifacts into `launcher/bin/` and `launcher/share/` as appropriate +3. Run `launcher/run.sh` +4. Check: + - Window title = `racked.ru launcher` (no version suffix) + - Settings → Appearance → Icon Theme dropdown lists `flat_white` + `racked_ru` only + - Instance List shows valid kitten/Minecraft instance icons (not blank squares) + - News pane shows racked.ru content (or empty/retrying if feed not yet live) + - "Fabulously Optimized" instance launches Minecraft cleanly + +## Out of scope (per user decision) + +- Modplatform strips (ATLauncher / Technic / FTB / Legacy FTB / Import FTB / Packwiz) — kept (nostalgia) +- Other icon themes (flat, breeze_*, pe_*, iOS, OSX) — kept +- `backgrounds/` cat art — kept +- Java bundle — kept (instant-play guarantee) diff --git a/docs/MINECRAFT_LAUNCHER.md b/docs/MINECRAFT_LAUNCHER.md new file mode 100644 index 0000000..3d7b4fc --- /dev/null +++ b/docs/MINECRAFT_LAUNCHER.md @@ -0,0 +1,572 @@ +# Minecraft Launcher Project - Complete Documentation + +**Location:** `/home/admin/ai-lab/_projects/_minecraft/` + +**Last Updated:** 2026-04-13 + +--- + +## Table of Contents +1. [Project Overview](#project-overview) +2. [Current Setup](#current-setup) +3. [Build System](#build-system) +4. [Custom Theme](#custom-theme) +5. [Development History](#development-history) +6. [File Structure](#file-structure) +7. [Configuration](#configuration) +8. [Deployment](#deployment) +9. [Troubleshooting](#troubleshooting) +10. [Future Work](#future-work) + +--- + +## Project Overview + +This project contains a **custom-branded PrismLauncher** for Minecraft, themed as "racked.ru" with a minimalist black and red design. The launcher is built from PrismLauncher-Cracked and customized for portable operation (USB-friendly, no installation required). + +### Key Features +- **Minimalist Design**: Black background with red accents +- **Portable Mode**: All data stored locally, perfect for USB drives +- **Cross-Platform**: Builds available for Windows, Linux, and macOS +- **Stripped Resources**: Removed unused themes to minimize size +- **Custom Branding**: racked.ru theme and catpack background + +--- + +## Current Setup + +### Launcher Location +``` +/home/admin/ai-lab/_projects/_minecraft/racked.ru - minecraft/ +``` + +### Current Launcher Contents (as of 2026-04-13) +- `prismlauncher.exe` - Main launcher executable (Windows) +- `prismlauncher.cfg` - Configuration file with custom theme settings +- `portable.txt` - Enables portable mode +- `Qt6*.dll` - Qt framework libraries +- `platforms/` - Qt platform plugins +- `iconengines/` - Icon rendering plugins +- `imageformats/` - Image format support +- `themes/racked.ru/` - Custom theme files +- `catpacks/racked_ru.png` - Background cat image +- `instances/racked.ru/` - Minecraft instance with mods + +### Configuration (prismlauncher.cfg) +```ini +[General] +ConfigVersion=1.2 +ApplicationTheme=racked.ru +IconTheme=flat_white +BackgroundCat=racked_ru +Language=en_US +MenuBarInsteadOfToolBar=true +StatusBarVisible=false +TheCat=true +``` + +--- + +## Build System + +### Build Repository +The build system and source code are maintained in: +``` +/home/admin/ai-lab/_projects/_minecraft/source/ +``` + +### Build Scripts Available + +#### Linux Scripts +| Script | Purpose | +|--------|---------| +| `setup-and-build.sh` | One-command setup and build | +| `scripts/build-linux-portable.sh` | Build for Linux | +| `scripts/deploy-to-minecraft-folder.sh` | Build and deploy automatically | +| `scripts/build-windows-from-linux.sh` | Cross-compile for Windows | +| `scripts/build-all-platforms-linux.sh` | Multi-platform build | +| `scripts/create-release.sh` | Create versioned release packages | + +#### Windows Scripts (Legacy) +| Script | Purpose | +|--------|---------| +| `scripts/build-windows-portable.bat` | Build for Windows | +| `scripts/deploy-to-minecraft-folder.bat` | Deploy to Windows folder | + +### Build Process (Linux) + +**Quick Build:** +```bash +cd /home/admin/ai-lab/_projects/_minecraft/source +bash setup-and-build.sh +``` + +This: +1. Installs dependencies (Fedora packages) +2. Builds the launcher +3. Creates portable release in `release/` + +**Deploy Build:** +```bash +bash scripts/deploy-to-minecraft-folder.sh +``` + +This: +1. Builds the launcher +2. Backs up current installation +3. Deploys new version to `racked.ru - minecraft/` + +### Build Dependencies (Fedora) +```bash +sudo dnf install -y \ + cmake gcc-c++ make \ + qt6-qtbase-devel qt6-qttools-devel qt6-qtsvg-devel \ + qt6-qtnetworkauth-devel qt6-qtimageformats \ + zlib-devel mesa-libGL-devel +``` + +--- + +## Custom Theme + +### Theme Location +``` +source/launcher/resources/racked_ru/ +``` + +### Theme Files + +#### theme.json +```json +{ + "colors": { + "AlternateBase": "#000000", + "Base": "#000000", + "BrightText": "#ff0000", + "Button": "#000000", + "ButtonText": "#ffffff", + "Highlight": "#4C4C4C", + "HighlightedText": "#CCCCCC", + "Link": "#CD001F", + "Text": "#ffffff", + "ToolTipBase": "#ffffff", + "ToolTipText": "#ffffff", + "Window": "#000000", + "WindowText": "#ffffff", + "fadeAmount": 0.5, + "fadeColor": "#000000" + }, + "name": "racked.ru", + "widgets": "Fusion" +} +``` + +#### themeStyle.css +```css +QToolTip { color: #ffffff; background-color: #2a82da; border: 1px solid white; } +``` + +### Background +- File: `catpacks/racked_ru.png` +- Custom cat image displayed in launcher background +- Configured via `BackgroundCat=racked_ru` in config + +### Theme Changes Made +1. Removed all default themes (pe_dark, pe_light, pe_blue, etc.) +2. Kept only flat_white icon theme for minimalism +3. Added custom racked.ru theme with black/red colors +4. Set racked.ru as default application theme +5. Set flat_white as default icon theme + +--- + +## Development History + +### Timeline + +#### 2026-04-13 - Project Creation +- Cloned upstream PrismLauncher-Cracked repository +- Created custom racked.ru theme integration +- Stripped unused themes and resources +- Implemented cross-platform build system for Linux +- Created automated deployment scripts +- Configured portable mode support + +#### Changes Made +1. **Theme Integration** + - Added racked.ru theme with custom colors + - Set as default theme in Application.cpp + - Updated main.cpp to load only required resources + - Modified ThemeManager to remove unused themes + +2. **Resource Optimization** + - Removed 10+ unused icon themes + - Removed unused application themes + - Reduced build size significantly + - Kept only flat_white and racked.ru themes + +3. **Portable Mode** + - Added portable.txt to launcher root + - Configured builds to be USB-friendly + - All data stored locally in launcher directory + +4. **Build System** + - Created Linux-first build scripts + - Added cross-compilation support for Windows + - Automated deployment to minecraft folder + - Created release packaging scripts + +### Upstream Base +- **Project**: PrismLauncher-Cracked +- **Repository**: https://github.com/Diegiwg/PrismLauncher-Cracked +- **License**: GPL-3.0-only +- **Qt Version**: 6.5.3+ + +--- + +## File Structure + +### Current Launcher (Production) +``` +racked.ru - minecraft/ +├── prismlauncher.exe # Main executable +├── prismlauncher.cfg # Configuration +├── portable.txt # Enables portable mode +├── Qt6Core.dll # Qt libraries +├── Qt6Gui.dll +├── Qt6Widgets.dll +├── Qt6Network.dll +├── ... (other Qt DLLs) +├── platforms/ # Qt platform plugins +│ └── qwindows.dll +├── iconengines/ # Icon rendering +├── imageformats/ # Image support +├── themes/ +│ └── racked.ru/ +│ ├── theme.json +│ └── themeStyle.css +├── catpacks/ +│ └── racked_ru.png +├── instances/ +│ └── racked.ru/ # Minecraft instance +│ └── minecraft/ +│ └── config/ # Mod configurations +├── cache/ # Download cache +├── meta/ # Metadata +├── translations/ # Language files +└── logs/ # Log files +``` + +### Build System (Development) +``` +source/ +├── launcher/ # Source code +│ ├── resources/ +│ │ └── racked_ru/ # Custom theme +│ ├── Application.cpp # Modified defaults +│ └── main.cpp # Resource loading +├── scripts/ +│ ├── setup-and-build.sh +│ ├── build-linux-portable.sh +│ ├── deploy-to-minecraft-folder.sh +│ ├── build-windows-from-linux.sh +│ ├── build-all-platforms-linux.sh +│ └── create-release.sh +├── README_LINUX.md +├── LINUX_SETUP.md +├── LINUX_QUICKSTART.md +├── BUILD_GUIDE.md +├── PROJECT_SUMMARY.md +└── RELEASE_CHECKLIST.md +``` + +--- + +## Configuration + +### Default Settings +These are the default settings configured in the build: + +```ini +ApplicationTheme=racked.ru # Custom black/red theme +IconTheme=flat_white # Minimal white icons +BackgroundCat=racked_ru # Custom background image +MenuBarInsteadOfToolBar=true # Menu bar layout +StatusBarVisible=false # Minimal UI +TheCat=true # Enable background cat +``` + +### User Configuration +User-specific settings are stored in `prismlauncher.cfg` in the launcher directory (portable mode) or in system config directory (if portable.txt is removed). + +### Java Configuration +The launcher auto-detects Java installations. Current settings: +```ini +AutomaticJavaDownload=true +AutomaticJavaSwitch=true +JavaVersion=21.0.7 +JavaArchitecture=64 +``` + +--- + +## Deployment + +### Deploy to Current Location + +**Linux (Recommended):** +```bash +cd /home/admin/ai-lab/_projects/_minecraft/source +bash scripts/deploy-to-minecraft-folder.sh +``` + +**Manual Deployment:** +```bash +# Build first +bash scripts/build-linux-portable.sh + +# Backup current launcher +mv "racked.ru - minecraft" "racked.ru - minecraft-backup-$(date +%Y%m%d)" + +# Copy new build +cp -r release/Racked.ru-PrismLauncher-Linux-Portable/* "racked.ru - minecraft/" +``` + +### Deployment Locations + +**Current Production:** +``` +/home/admin/ai-lab/_projects/_minecraft/racked.ru - minecraft/ +``` + +**Backup Location (after deploy):** +``` +/home/admin/ai-lab/_projects/_minecraft/racked.ru - minecraft-backup-YYYYMMDD/ +``` + +### Release Packages + +Release packages are created in: +``` +source/release/ +├── racked-prismlauncher--linux-portable.tar.gz +├── racked-prismlauncher--windows-portable.zip +└── racked-prismlauncher--macos-portable.tar.gz +``` + +To create a release: +```bash +bash scripts/create-release.sh 1.0.0 +``` + +--- + +## Troubleshooting + +### Java Requirements for Minecraft 1.21.10 + +**Minecraft 1.21.10 requires Java 21.** Your system has Java 25 which is too new. + +**Solution:** Install Java 21 locally (no root needed) +- See `ADD_JAVA_GUIDE.md` for complete instructions +- Quick fix: Download from https://github.com/adoptium/temurin21-binaries +- Place Java in: `launcher/java/` +- Configure launcher to use: `java/jdk-21.0.5+11/bin/java` + +**Quick Install Script:** +```bash +cd launcher/ +mkdir -p java && cd java +wget https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.5%2B11/OpenJDK21U-jdk_x64_linux_hotspot_21.0.5_11.tar.gz +tar xzf OpenJDK21U-jdk_x64_linux_hotspot_21.0.5_11.tar.gz +``` + +### Common Issues + +#### Launcher Won't Start +**Linux:** +```bash +# Check missing dependencies +ldd release/Racked.ru-PrismLauncher-Linux-Portable/bin/prismlauncher + +# Install Qt6 if missing +sudo dnf install qt6-qtbase +``` + +**Windows:** +```bash +# Ensure all DLLs are present +# Rebuild with: scripts\build-windows-portable.bat +``` + +#### Theme Not Loading +1. Check `prismlauncher.cfg` for correct theme settings +2. Verify theme files exist in `themes/racked.ru/` +3. Rebuild launcher if files are missing + +#### Portable Mode Not Working +1. Ensure `portable.txt` exists in launcher root +2. Check file permissions (must be readable) +3. Delete and recreate if necessary + +#### Build Fails on Linux +```bash +# Install dependencies +sudo dnf install cmake gcc-c++ make qt6-qtbase-devel + +# Clean build +rm -rf build-linux-portable/ install-linux-portable/ +bash scripts/build-linux-portable.sh +``` + +#### Cross-Compilation for Windows Fails +```bash +# Install MinGW +sudo dnf install mingw64-gcc-c++ mingw64-qt6-qtbase + +# Verify +x86_64-w64-mingw32-g++ --version +``` + +### Backup and Recovery + +**Backup Current Launcher:** +```bash +cp -r "racked.ru - minecraft" "racked.ru - minecraft-backup-$(date +%Y%m%d)" +``` + +**Restore from Backup:** +```bash +rm -rf "racked.ru - minecraft" +mv "racked.ru - minecraft-backup-YYYYMMDD" "racked.ru - minecraft" +``` + +**Preserve Data:** +Before any deployment, backup: +- `instances/` folder (your Minecraft instances) +- `prismlauncher.cfg` (your settings) +- Any custom mods or resource packs + +--- + +## Future Work + +### Planned Improvements + +#### 1. Automatic Updates +- [ ] Implement update checker +- [ ] Create update script +- [ ] Add update notifications + +#### 2. Custom Branding +- [ ] Custom launcher icon +- [ ] Custom splash screen +- [ ] Custom about dialog +- [ ] Branded installer (optional) + +#### 3. Performance Optimization +- [ ] Further reduce binary size +- [ ] Optimize startup time +- [ ] Reduce memory footprint + +#### 4. Distribution +- [ ] Upload to racked.ru website +- [ ] Create GitHub releases +- [ ] Set up auto-update server +- [ ] Package for Linux distros (Flatpak, AppImage) + +#### 5. Documentation +- [ ] User guide +- [ ] Mod installation guide +- [ ] Server connection guide +- [ ] Video tutorial + +### Known Limitations + +1. **macOS Builds**: Cannot cross-compile from Linux easily + - Solution: Build natively on macOS + +2. **Windows Builds**: Require MinGW or native Windows + - Currently supports cross-compilation from Linux + +3. **Size**: Still includes full Qt6 framework + - ~100-150MB minimum due to Qt dependencies + +4. **Updates**: Manual update process + - Must rebuild and redeploy for updates + +### Wishlist + +- [ ] Custom modpack manager +- [ ] Integrated server browser +- [ ] Custom news feed from racked.ru +- [ ] Integrated voice chat +- [ ] Performance monitoring +- [ ] Shader pack manager + +--- + +## Additional Resources + +### Documentation Files +- `README_LINUX.md` - Complete Linux build guide +- `LINUX_SETUP.md` - Detailed setup instructions +- `LINUX_QUICKSTART.md` - Quick reference +- `BUILD_GUIDE.md` - Cross-platform build guide +- `PROJECT_SUMMARY.md` - Technical overview +- `RELEASE_CHECKLIST.md` - Release verification + +### External Resources +- **PrismLauncher**: https://prismlauncher.org/ +- **PrismLauncher-Cracked**: https://github.com/Diegiwg/PrismLauncher-Cracked +- **Qt Framework**: https://www.qt.io/ +- **racked.ru Website**: https://racked.ru/ + +### Support +- **Issues**: GitHub Issues in source +- **Discord**: (Add your Discord link) +- **Website**: https://racked.ru/ + +--- + +## Quick Reference + +### Most Common Commands + +**Build from scratch:** +```bash +cd /home/admin/ai-lab/_projects/_minecraft/source +bash setup-and-build.sh +``` + +**Deploy to minecraft folder:** +```bash +bash scripts/deploy-to-minecraft-folder.sh +``` + +**Create release:** +```bash +bash scripts/create-release.sh 1.0.0 +``` + +**Run built launcher:** +```bash +cd release/Racked.ru-PrismLauncher-Linux-Portable/ +./run.sh +``` + +### Important Locations + +``` +Project Root: /home/admin/ai-lab/_projects/_minecraft/ +Current Launcher: /home/admin/ai-lab/_projects/_minecraft/racked.ru - minecraft/ +Build System: /home/admin/ai-lab/_projects/_minecraft/source/ +Upstream Reference: /home/admin/ai-lab/prismlauncher-cracked-upstream/ +``` + +--- + +**Version**: 1.0.0 (Initial Release) +**Build Date**: 2026-04-13 +**Maintained By**: racked.ru team +**License**: GPL-3.0-only (based on PrismLauncher) diff --git a/docs/NETWORK_AUDIT.md b/docs/NETWORK_AUDIT.md new file mode 100644 index 0000000..8e6642e --- /dev/null +++ b/docs/NETWORK_AUDIT.md @@ -0,0 +1,104 @@ +# Network & Telemetry Audit — racked.ru launcher (PrismLauncher fork) + +**Date:** 2026-04-30 +**Scope:** `source/` tree — what the launcher contacts over the network, and whether anything phones home. + +## Verdict: No telemetry, no analytics, no crash reporting. + +Grep for `sentry|analytics|telemetry|tracking|google-analytics|mixpanel|amplitude|posthog|crashreport` across `*.cpp/*.h/CMakeLists` returns **zero hits**. (One false positive: a contributor's email `sentrycraft123@gmail.com` in copyright headers — unrelated to Sentry SDK.) + +PrismLauncher upstream has never shipped telemetry. This fork preserves that. + +--- + +## News feed — does it pull from online? + +**Yes.** It is an RSS feed download. + +- Code: `source/launcher/news/NewsChecker.cpp` constructs a `NetJob` and downloads the feed configured at build time. +- Caller: `source/launcher/ui/MainWindow.cpp:279` + ```cpp + m_newsChecker.reset(new NewsChecker(APPLICATION->network(), BuildConfig.NEWS_RSS_URL)); + ``` +- URL is set in `source/CMakeLists.txt`: + ```cmake + set(Launcher_NEWS_RSS_URL "https://prismlauncher.org/feed/feed.xml" ...) + set(Launcher_NEWS_OPEN_URL "https://prismlauncher.org/news" ...) + ``` +- Trigger: fires on launcher startup (constructor of MainWindow). + +**Implication for racked.ru fork:** +- Currently still hitting `prismlauncher.org` for news. If you want zero outbound to upstream, override at CMake configure time: + ```bash + -DLauncher_NEWS_RSS_URL=https://racked.ru/feed.xml \ + -DLauncher_NEWS_OPEN_URL=https://racked.ru/news + ``` + Or set both to empty string to disable. (Empty URL → `NetJob` fails silently.) + +--- + +## All network endpoints in source + +Grouped by purpose. None are telemetry. + +### Auth / account (Microsoft / Mojang) — required to play +- `https://login.microsoftonline.com/consumers/oauth2/v2.0/{authorize,devicecode,token}` +- `https://login.live.com/login.srf` +- `http://auth.xboxlive.com` +- `https://api.minecraftservices.com/...` (profile, skins, capes, entitlements, MSA migration) +- `https://account.microsoft.com/family/` +- `https://help.minecraft.net/...` + +### Game assets +- `https://libraries.minecraft.net/` + +### Mod platforms (user-driven) +- `https://api.modrinth.com/v2`, `https://modrinth.com/mod/`, `https://docs.modrinth.com/...` +- `https://api.curseforge.com/v1`, `https://docs.curseforge.com/` +- `https://api.atlauncher.com/v1/`, `http://api.technicpack.net/modpack/` +- `https://api.feed-the-beast.com/v1/modpacks/public`, `https://dist.creeper.host/FTB2/` + +### Pastebins (user-driven, log upload) +- `https://api.mclo.gs`, `https://api.paste.gg/v1/pastes`, `https://hst.sh`, `https://0x0.st` +- `https://api.imgur.com/3/`, `https://imgur.com/a/` + +### Updater / news / help (Prism) +- `https://api.github.com/repos/...` — version checks +- `https://prismlauncher.org/feed/feed.xml` — news RSS (see above) +- `https://prismlauncher.org/news` — "More news" button +- Help URL template in CMake: `https://prismlauncher.org/wiki/help-pages/%1` +- Bug tracker: `https://github.com/Diegiwg/PrismLauncher-Cracked/issues` ← **upstream-fork URL, points at the original "Cracked" project** +- Translations: `https://hosted.weblate.org/projects/prismlauncher/launcher/` +- Matrix/Discord/Subreddit: all `prismlauncher.org` redirects + +### Documentation links (opened in browser only when user clicks) +- `minecraft.wiki`, `fabricmc.net/wiki`, `docs.microsoft.com`, etc. + +--- + +## Risk summary + +| Vector | Risk | Mitigation | +|---|---|---| +| Telemetry | None | n/a | +| Crash reporting | None | n/a | +| News RSS leaks IP to prismlauncher.org on startup | Low (single GET, no UA fingerprint beyond `Launcher_UserAgent` = `PrismLauncher/`) | Override `Launcher_NEWS_RSS_URL` to racked.ru or empty | +| Update check via api.github.com | Low (anonymous, rate-limited) | Override `BUG_TRACKER_URL` and consider disabling updater path | +| User-Agent identifies fork by name + version | Low | Override `Launcher_UserAgent` if anonymity desired | + +--- + +## Recommended CMake overrides for racked.ru build + +```cmake +-DLauncher_NEWS_RSS_URL=https://racked.ru/feed.xml +-DLauncher_NEWS_OPEN_URL=https://racked.ru/news +-DLauncher_HELP_URL=https://racked.ru/help/%1 +-DLauncher_BUG_TRACKER_URL=https://racked.ru/bugs +-DLauncher_MATRIX_URL=https://racked.ru/matrix +-DLauncher_DISCORD_URL=https://racked.ru/discord +-DLauncher_SUBREDDIT_URL=https://racked.ru +-DLauncher_TRANSLATIONS_URL=https://racked.ru/translate +``` + +Or set them blank to disable the menu items entirely. diff --git a/docs/SETTINGS_AUDIT.md b/docs/SETTINGS_AUDIT.md new file mode 100644 index 0000000..01bdc51 --- /dev/null +++ b/docs/SETTINGS_AUDIT.md @@ -0,0 +1,30 @@ +# Settings Audit — runtime cfg vs source defaults + +**Date:** 2026-04-30 +**Source:** `source/launcher/Application.cpp` (registerSetting block lines ~644–900) +**Runtime:** `launcher/prismlauncher.cfg` + +## Diffs found + +| Setting | Source default | Runtime value | Action | +|---|---|---|---| +| `MinMemAlloc` | `512` | `384` | **Ported** → source now defaults `384` | +| `MaxMemAlloc` | `SysInfo::defaultMaxJvmMem()` (system-detected, often 8G+) | `4096` | **Ported** → source now defaults `4096` | +| `MenuBarInsteadOfToolBar` | `false` | `true` | **Ported** → source now defaults `true` | +| `FallbackMRBlockedMods` | `true` (bool) | `2` (Qt::CheckState::Checked) | **Not changed** — already truthy. Stored as enum because UI uses tri-state checkbox; behaviorally identical | +| `ApplicationTheme` | `"racked.ru"` | `"system"` | Runtime override only. Default already correct | +| `BackgroundCat` | `"racked_ru"` | (not set in cfg, irrelevant) | Default correct | +| `IconTheme` | `"flat_white"` | `"flat_white"` | Match | +| `AutomaticJavaDownload` | dynamic (`JavaPath` empty) | `false` | User-toggled, leave dynamic | +| `AutomaticJavaSwitch` | dynamic | `true` | Same | +| `NumberOfConcurrentTasks` | `10` | `10` | Match | +| `NumberOfConcurrentDownloads` | `6` | `6` | Match | + +## Files changed + +- `source/launcher/Application.cpp` — three default values updated (Min/MaxMemAlloc, MenuBarInsteadOfToolBar) + +## Notes + +- `JavaPath`, `LastHostname`, geometry blobs, `SelectedInstance`, `Language` are user/host-specific. Not ported. +- `OnlineFixes=false`, `EnableFeralGamemode=false`, `EnableMangoHud=false`, `UseDiscreteGpu=false`, `UseZink=false` — match defaults. diff --git a/docs/screenshots/launcher.png b/docs/screenshots/launcher.png new file mode 100644 index 0000000..9864cb1 Binary files /dev/null and b/docs/screenshots/launcher.png differ diff --git a/launcher/Application.cpp b/launcher/Application.cpp new file mode 100644 index 0000000..9b34678 --- /dev/null +++ b/launcher/Application.cpp @@ -0,0 +1,2066 @@ +// SPDX-FileCopyrightText: 2022 Sefa Eyeoglu +// +// SPDX-License-Identifier: GPL-3.0-only AND Apache-2.0 + +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2022 Lenny McLennington + * Copyright (C) 2022 Tayou + * Copyright (C) 2023 TheKodeToad + * Copyright (C) 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "Application.h" +#include "BuildConfig.h" + +#include "DataMigrationTask.h" +#include "java/JavaInstallList.h" +#include "net/PasteUpload.h" +#include "tasks/Task.h" +#include "tools/GenericProfiler.h" +#include "ui/InstanceWindow.h" +#include "ui/MainWindow.h" +#include "ui/ToolTipFilter.h" +#include "ui/ViewLogWindow.h" + +#include "ui/dialogs/ProgressDialog.h" +#include "ui/instanceview/AccessibleInstanceView.h" + +#include "ui/pages/BasePageProvider.h" +#include "ui/pages/global/APIPage.h" +#include "ui/pages/global/AccountListPage.h" +#include "ui/pages/global/AppearancePage.h" +#include "ui/pages/global/ExternalToolsPage.h" +#include "ui/pages/global/JavaPage.h" +#include "ui/pages/global/LanguagePage.h" +#include "ui/pages/global/LauncherPage.h" +#include "ui/pages/global/MinecraftPage.h" +#include "ui/pages/global/ProxyPage.h" + +#include "ui/setupwizard/AutoJavaWizardPage.h" +#include "ui/setupwizard/JavaWizardPage.h" +#include "ui/setupwizard/LanguageWizardPage.h" +#include "ui/setupwizard/LoginWizardPage.h" +#include "ui/setupwizard/PasteWizardPage.h" +#include "ui/setupwizard/SetupWizard.h" +#include "ui/setupwizard/ThemeWizardPage.h" + +#include "ui/dialogs/CustomMessageBox.h" + +#include "ui/pagedialog/PageDialog.h" + +#include "ui/themes/ThemeManager.h" + +#include "ApplicationMessage.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "InstanceList.h" +#include "MTPixmapCache.h" + +#include +#include "icons/IconList.h" +#include "net/HttpMetaCache.h" + +#include "updater/ExternalUpdater.h" + +#include "tools/JProfiler.h" +#include "tools/JVisualVM.h" +#include "tools/MCEditTool.h" + +#include "settings/INISettingsObject.h" +#include "settings/Setting.h" + +#include "meta/Index.h" +#include "translations/TranslationsModel.h" + +#include +#include +#include + +#include +#include "SysInfo.h" + +#ifdef Q_OS_LINUX +#include +#include "LibraryUtils.h" +#include "gamemode_client.h" +#endif + +#if defined(Q_OS_LINUX) +#include +#endif + +#if defined(Q_OS_FREEBSD) || defined(Q_OS_OPENBSD) +#include +#include +#endif + +#if defined(Q_OS_MAC) +#if defined(SPARKLE_ENABLED) +#include "updater/MacSparkleUpdater.h" +#endif +#else +#include "updater/PrismExternalUpdater.h" +#endif + +#if defined Q_OS_WIN32 +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#include +#endif + +#include "console/Console.h" + +#define STRINGIFY(x) #x +#define TOSTRING(x) STRINGIFY(x) + +static const QLatin1String liveCheckFile("live.check"); + +PixmapCache* PixmapCache::s_instance = nullptr; + +static bool isANSIColorConsole; + +static QString defaultLogFormat = QStringLiteral( + "%{time process}" + " " + "%{if-debug}Debug:%{endif}" + "%{if-info}Info:%{endif}" + "%{if-warning}Warning:%{endif}" + "%{if-critical}Critical:%{endif}" + "%{if-fatal}Fatal:%{endif}" + " " + "%{if-category}[%{category}] %{endif}" + "%{message}" + " " + "(%{function}:%{line})"); + +#define ansi_reset "\x1b[0m" +#define ansi_bold "\x1b[1m" +#define ansi_reset_bold "\x1b[22m" +#define ansi_faint "\x1b[2m" +#define ansi_italic "\x1b[3m" +#define ansi_red_fg "\x1b[31m" +#define ansi_green_fg "\x1b[32m" +#define ansi_yellow_fg "\x1b[33m" +#define ansi_blue_fg "\x1b[34m" +#define ansi_purple_fg "\x1b[35m" +#define ansi_inverse "\x1b[7m" + +// clang-format off +static QString ansiLogFormat = QStringLiteral( + ansi_faint "%{time process}" ansi_reset + " " + "%{if-debug}" ansi_bold ansi_green_fg "D:" ansi_reset "%{endif}" + "%{if-info}" ansi_bold ansi_blue_fg "I:" ansi_reset "%{endif}" + "%{if-warning}" ansi_bold ansi_yellow_fg "W:" ansi_reset_bold "%{endif}" + "%{if-critical}" ansi_bold ansi_red_fg "C:" ansi_reset_bold "%{endif}" + "%{if-fatal}" ansi_bold ansi_inverse ansi_red_fg "F:" ansi_reset_bold "%{endif}" + " " + "%{if-category}" ansi_bold "[%{category}]" ansi_reset_bold " %{endif}" + "%{message}" + " " + ansi_reset ansi_faint "(%{function}:%{line})" ansi_reset +); +// clang-format on + +#undef ansi_inverse +#undef ansi_purple_fg +#undef ansi_blue_fg +#undef ansi_yellow_fg +#undef ansi_green_fg +#undef ansi_red_fg +#undef ansi_italic +#undef ansi_faint +#undef ansi_bold +#undef ansi_reset_bold +#undef ansi_reset + +namespace { + +/** This is used so that we can output to the log file in addition to the CLI. */ +void appDebugOutput(QtMsgType type, const QMessageLogContext& context, const QString& msg) +{ + static std::mutex loggerMutex; + const std::lock_guard lock(loggerMutex); // synchronized, QFile logFile is not thread-safe + + if (isANSIColorConsole) { + // ensure default is set for log file + qSetMessagePattern(defaultLogFormat); + } + + QString out = qFormatLogMessage(type, context, msg); + if (APPLICATION->logModel) { + APPLICATION->logModel->append(MessageLevel::fromQtMsgType(type), out); + } + + out += QChar::LineFeed; + APPLICATION->logFile->write(out.toUtf8()); + APPLICATION->logFile->flush(); + + if (isANSIColorConsole) { + // format ansi for console; + qSetMessagePattern(ansiLogFormat); + out = qFormatLogMessage(type, context, msg); + out += QChar::LineFeed; + } + + QTextStream(stderr) << out.toLocal8Bit(); + fflush(stderr); +} + +} // namespace + +std::tuple read_lock_File(const QString& path) +{ + auto contents = QString(FS::read(path)); + auto lines = contents.split('\n'); + + QDateTime timestamp; + QString from, to, target, data_path; + for (auto line : lines) { + auto index = line.indexOf("="); + if (index < 0) + continue; + auto left = line.left(index); + auto right = line.mid(index + 1); + if (left.toLower() == "timestamp") { + timestamp = QDateTime::fromString(right, Qt::ISODate); + } else if (left.toLower() == "from") { + from = right; + } else if (left.toLower() == "to") { + to = right; + } else if (left.toLower() == "target") { + target = right; + } else if (left.toLower() == "data_path") { + data_path = right; + } + } + return std::make_tuple(timestamp, from, to, target, data_path); +} + +Application::Application(int& argc, char** argv) : QApplication(argc, argv) +{ + if (console::isConsole()) { + isANSIColorConsole = true; + } + + setOrganizationName(BuildConfig.LAUNCHER_NAME); + setOrganizationDomain(BuildConfig.LAUNCHER_DOMAIN); + setApplicationName(BuildConfig.LAUNCHER_NAME); + setApplicationDisplayName(BuildConfig.LAUNCHER_DISPLAYNAME); + setApplicationVersion(BuildConfig.printableVersionString() + "\n" + BuildConfig.GIT_COMMIT); + setDesktopFileName(BuildConfig.LAUNCHER_APPID); + m_startTime = QDateTime::currentDateTime(); + + // Don't quit on hiding the last window + this->setQuitOnLastWindowClosed(false); + this->setQuitLockEnabled(false); + + // Commandline parsing + QCommandLineParser parser; + parser.setApplicationDescription(BuildConfig.LAUNCHER_DISPLAYNAME); + + parser.addOptions( + { { { "d", "dir" }, "Use a custom path as application root (use '.' for current directory)", "directory" }, + { { "l", "launch" }, "Launch the specified instance (by instance ID)", "instance" }, + { { "s", "server" }, "Join the specified server on launch (only valid in combination with --launch)", "address" }, + { { "w", "world" }, "Join the specified world on launch (only valid in combination with --launch)", "world" }, + { { "a", "profile" }, "Use the account specified by its profile name (only valid in combination with --launch)", "profile" }, + { { "o", "offline" }, "Launch offline, with given player name (only valid in combination with --launch)", "offline" }, + { "alive", "Write a small '" + liveCheckFile + "' file after the launcher starts" }, + { "show-window", "Show the main launcher window (useful in combination with --launch)" }, + { { "I", "import" }, "Import instance or resource from specified local path or URL", "url" }, + { "show", "Opens the window for the specified instance (by instance ID)", "show" } }); + // Has to be positional for some OS to handle that properly + parser.addPositionalArgument("URL", "Import the resource(s) at the given URL(s) (same as -I / --import)", "[URL...]"); + + parser.addHelpOption(); + parser.addVersionOption(); + + parser.process(arguments()); + + m_instanceIdToLaunch = parser.value("launch"); + m_serverToJoin = parser.value("server"); + m_worldToJoin = parser.value("world"); + m_profileToUse = parser.value("profile"); + if (parser.isSet("offline")) { + m_launchOffline = true; + m_offlineName = parser.value("offline"); + } + m_liveCheck = parser.isSet("alive"); + + m_instanceIdToShowWindowOf = parser.value("show"); + m_showMainWindow = parser.isSet("show-window"); + + for (auto url : parser.values("import")) { + m_urlsToImport.append(normalizeImportUrl(url)); + } + + // treat unspecified positional arguments as import urls + for (auto url : parser.positionalArguments()) { + m_urlsToImport.append(normalizeImportUrl(url)); + } + + // error if --launch is missing with --server or --profile + if ((!m_serverToJoin.isEmpty() || !m_worldToJoin.isEmpty() || !m_profileToUse.isEmpty() || m_launchOffline) && + m_instanceIdToLaunch.isEmpty()) { + std::cerr << "--server, --profile and --offline can only be used in combination with --launch!" << std::endl; + m_status = Application::Failed; + return; + } + + QString origcwdPath = QDir::currentPath(); + QString binPath = applicationDirPath(); + + { + // Root path is used for updates and portable data +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) || defined(Q_OS_OPENBSD) + QDir foo(FS::PathCombine(binPath, "..")); // typically portable-root or /usr + m_rootPath = foo.absolutePath(); +#elif defined(Q_OS_WIN32) + m_rootPath = binPath; +#elif defined(Q_OS_MAC) + QDir foo(FS::PathCombine(binPath, "../..")); + m_rootPath = foo.absolutePath(); + // on macOS, touch the root to force Finder to reload the .app metadata (and fix any icon change issues) + FS::updateTimestamp(m_rootPath); +#endif + } + + QString adjustedBy; + QString dataPath; + // change folder + QString dataDirEnv; + QString dirParam = parser.value("dir"); + if (!dirParam.isEmpty()) { + // the dir param. it makes multimc data path point to whatever the user specified + // on command line + adjustedBy = "Command line"; + dataPath = dirParam; + } else if (dataDirEnv = QProcessEnvironment::systemEnvironment().value(QString("%1_DATA_DIR").arg(BuildConfig.LAUNCHER_NAME.toUpper())); + !dataDirEnv.isEmpty()) { + adjustedBy = "System environment"; + dataPath = dataDirEnv; + } else { + QDir foo; + if (DesktopServices::isSnap()) { + foo = QDir(qEnvironmentVariable("SNAP_USER_COMMON")); + } else { + foo = QDir(FS::PathCombine(QStandardPaths::writableLocation(QStandardPaths::AppDataLocation), "..")); + } + + dataPath = foo.absolutePath(); + adjustedBy = "Persistent data path"; + +#ifndef Q_OS_MACOS + if (auto portableUserData = FS::PathCombine(m_rootPath, "UserData"); QDir(portableUserData).exists()) { + dataPath = portableUserData; + adjustedBy = "Portable user data path"; + m_portable = true; + } else if (QFile::exists(FS::PathCombine(m_rootPath, "portable.txt"))) { + dataPath = m_rootPath; + adjustedBy = "Portable data path"; + m_portable = true; + } +#endif + } + + if (!FS::ensureFolderPathExists(dataPath)) { + showFatalErrorMessage( + "The launcher data folder could not be created.", + QString("The launcher data folder could not be created.\n" + "\n" + "Make sure you have the right permissions to the launcher data folder and any folder needed to access it.\n" + "(%1)\n" + "\n" + "The launcher cannot continue until you fix this problem.") + .arg(dataPath)); + return; + } + if (!QDir::setCurrent(dataPath)) { + showFatalErrorMessage("The launcher data folder could not be opened.", + QString("The launcher data folder could not be opened.\n" + "\n" + "Make sure you have the right permissions to the launcher data folder.\n" + "(%1)\n" + "\n" + "The launcher cannot continue until you fix this problem.") + .arg(dataPath)); + return; + } + m_dataPath = dataPath; + + /* + * Establish the mechanism for communication with an already running PrismLauncher that uses the same data path. + * If there is one, tell it what the user actually wanted to do and exit. + * We want to initialize this before logging to avoid messing with the log of a potential already running copy. + */ + auto appID = ApplicationId::fromPathAndVersion(QDir::currentPath(), BuildConfig.printableVersionString()); + { + // FIXME: you can run the same binaries with multiple data dirs and they won't clash. This could cause issues for updates. + m_peerInstance = new LocalPeer(this, appID); + connect(m_peerInstance, &LocalPeer::messageReceived, this, &Application::messageReceived); + if (m_peerInstance->isClient()) { + bool sentMessage = false; + int timeout = 2000; + + if (m_instanceIdToLaunch.isEmpty()) { + ApplicationMessage activate; + activate.command = "activate"; + sentMessage = m_peerInstance->sendMessage(activate.serialize(), timeout); + + if (!m_urlsToImport.isEmpty()) { + for (auto url : m_urlsToImport) { + ApplicationMessage import; + import.command = "import"; + import.args.insert("url", url.toString()); + sentMessage = m_peerInstance->sendMessage(import.serialize(), timeout); + } + } + } else { + ApplicationMessage launch; + launch.command = "launch"; + launch.args["id"] = m_instanceIdToLaunch; + + if (!m_serverToJoin.isEmpty()) { + launch.args["server"] = m_serverToJoin; + } else if (!m_worldToJoin.isEmpty()) { + launch.args["world"] = m_worldToJoin; + } + if (!m_profileToUse.isEmpty()) { + launch.args["profile"] = m_profileToUse; + } + if (m_launchOffline) { + launch.args["offline_enabled"] = "true"; + launch.args["offline_name"] = m_offlineName; + } + sentMessage = m_peerInstance->sendMessage(launch.serialize(), timeout); + } + if (sentMessage) { + m_status = Application::Succeeded; + return; + } else { + std::cerr << "Unable to redirect command to already running instance\n"; + // C function not Qt function - event loop not started yet + ::exit(1); + } + } + } + + // init the logger + { + static const QString baseLogFile = BuildConfig.LAUNCHER_NAME + "-%0.log"; + static const QString logBase = FS::PathCombine("logs", baseLogFile); + if (FS::ensureFolderPathExists("logs")) { // if this did not fail + for (auto i = 0; i <= 4; i++) + if (auto oldName = baseLogFile.arg(i); + QFile::exists(oldName)) // do not pointlessly delete new files if the old ones are not there + FS::move(oldName, logBase.arg(i)); + } + + for (auto i = 4; i > 0; i--) + FS::move(logBase.arg(i - 1), logBase.arg(i)); + + logFile = std::unique_ptr(new QFile(logBase.arg(0))); + if (!logFile->open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate)) { + showFatalErrorMessage("The launcher data folder is not writable!", + QString("The launcher couldn't create a log file - %1.\n" + "\n" + "Make sure you have write permissions to the data folder.\n" + "(%2)\n" + "\n" + "The launcher cannot continue until you fix this problem.") + .arg(logFile->errorString()) + .arg(dataPath)); + return; + } + qInstallMessageHandler(appDebugOutput); + qSetMessagePattern(defaultLogFormat); + + logModel.reset(new LogModel(this)); + + bool foundLoggingRules = false; + + auto logRulesFile = QStringLiteral("qtlogging.ini"); + auto logRulesPath = FS::PathCombine(dataPath, logRulesFile); + + qInfo() << "Testing" << logRulesPath << "..."; + foundLoggingRules = QFile::exists(logRulesPath); + + // search the dataPath() + // seach app data standard path + if (!foundLoggingRules && !isPortable() && dirParam.isEmpty() && dataDirEnv.isEmpty()) { + logRulesPath = QStandardPaths::locate(QStandardPaths::AppDataLocation, FS::PathCombine("..", logRulesFile)); + if (!logRulesPath.isEmpty()) { + qInfo() << "Found" << logRulesPath << "..."; + foundLoggingRules = true; + } + } + // seach root path + if (!foundLoggingRules) { +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) || defined(Q_OS_OPENBSD) + logRulesPath = FS::PathCombine(m_rootPath, "share", BuildConfig.LAUNCHER_NAME, logRulesFile); +#else + logRulesPath = FS::PathCombine(m_rootPath, logRulesFile); +#endif + qInfo() << "Testing" << logRulesPath << "..."; + foundLoggingRules = QFile::exists(logRulesPath); + } + + if (foundLoggingRules) { + // load and set logging rules + qInfo() << "Loading logging rules from:" << logRulesPath; + QSettings loggingRules(logRulesPath, QSettings::IniFormat); + loggingRules.beginGroup("Rules"); + QStringList rule_names = loggingRules.childKeys(); + QStringList rules; + qInfo() << "Setting log rules:"; + for (auto rule_name : rule_names) { + auto rule = QString("%1=%2").arg(rule_name).arg(loggingRules.value(rule_name).toString()); + rules.append(rule); + qInfo() << " " << rule; + } + auto rules_str = rules.join("\n"); + QLoggingCategory::setFilterRules(rules_str); + } + + qInfo() << "<> Log initialized."; + } + + { + bool migrated = false; + + if (!migrated) + migrated = handleDataMigration( + dataPath, FS::PathCombine(QStandardPaths::writableLocation(QStandardPaths::AppDataLocation), "../../PolyMC"), "PolyMC", + "polymc.cfg"); + if (!migrated) + migrated = handleDataMigration( + dataPath, FS::PathCombine(QStandardPaths::writableLocation(QStandardPaths::AppDataLocation), "../../multimc"), "MultiMC", + "multimc.cfg"); + } + + { + qInfo() << qPrintable(BuildConfig.LAUNCHER_DISPLAYNAME + ", " + QString(BuildConfig.LAUNCHER_COPYRIGHT).replace("\n", ", ")); + qInfo() << "Version :" << BuildConfig.printableVersionString(); + qInfo() << "Platform :" << BuildConfig.BUILD_PLATFORM; + qInfo() << "Git commit :" << BuildConfig.GIT_COMMIT; + qInfo() << "Git refspec :" << BuildConfig.GIT_REFSPEC; + qInfo() << "Compiled for :" << BuildConfig.systemID(); + qInfo() << "Compiled by :" << BuildConfig.compilerID(); + qInfo() << "Build Artifact :" << BuildConfig.BUILD_ARTIFACT; + qInfo() << "Updates Enabled :" << (updaterEnabled() ? "Yes" : "No"); + if (adjustedBy.size()) { + qInfo() << "Work dir before adjustment :" << origcwdPath; + qInfo() << "Work dir after adjustment :" << QDir::currentPath(); + qInfo() << "Adjusted by :" << adjustedBy; + } else { + qInfo() << "Work dir :" << QDir::currentPath(); + } + qInfo() << "Binary path :" << binPath; + qInfo() << "Application root path :" << m_rootPath; + if (!m_instanceIdToLaunch.isEmpty()) { + qInfo() << "ID of instance to launch :" << m_instanceIdToLaunch; + } + if (!m_serverToJoin.isEmpty()) { + qInfo() << "Address of server to join :" << m_serverToJoin; + } else if (!m_worldToJoin.isEmpty()) { + qInfo() << "Name of the world to join :" << m_worldToJoin; + } + qInfo() << "<> Paths set."; + } + + if (m_liveCheck) { + QFile check(liveCheckFile); + if (check.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + auto payload = appID.toString().toUtf8(); + if (check.write(payload) == payload.size()) { + check.close(); + } else { + qWarning() << "Could not write into" << liveCheckFile << "error:" << check.errorString(); + check.remove(); // also closes file! + } + } else { + qWarning() << "Could not open" << liveCheckFile << "for writing:" << check.errorString(); + } + } + + // Initialize application settings + { + // Provide a fallback for migration from PolyMC + m_settings.reset(new INISettingsObject({ BuildConfig.LAUNCHER_CONFIGFILE, "polymc.cfg", "multimc.cfg" }, this)); + + // Theming + m_settings->registerSetting("IconTheme", QString("flat_white")); + m_settings->registerSetting("ApplicationTheme", QString("racked.ru")); + m_settings->registerSetting("BackgroundCat", QString("racked_ru")); + + // Remembered state + m_settings->registerSetting("LastUsedGroupForNewInstance", QString()); + + m_settings->registerSetting("MenuBarInsteadOfToolBar", true); + + m_settings->registerSetting("NumberOfConcurrentTasks", 10); + m_settings->registerSetting("NumberOfConcurrentDownloads", 6); + m_settings->registerSetting("NumberOfManualRetries", 1); + m_settings->registerSetting("RequestTimeout", 60); + + QString defaultMonospace; + int defaultSize = 11; +#ifdef Q_OS_WIN32 + defaultMonospace = "Courier"; + defaultSize = 10; +#elif defined(Q_OS_MAC) + defaultMonospace = "Menlo"; +#else + defaultMonospace = "Monospace"; +#endif + + // resolve the font so the default actually matches + QFont consoleFont; + consoleFont.setFamily(defaultMonospace); + consoleFont.setStyleHint(QFont::Monospace); + consoleFont.setFixedPitch(true); + QFontInfo consoleFontInfo(consoleFont); + QString resolvedDefaultMonospace = consoleFontInfo.family(); + QFont resolvedFont(resolvedDefaultMonospace); + qDebug().nospace() << "Detected default console font: " << resolvedDefaultMonospace + << ", substitutions: " << resolvedFont.substitutions().join(','); + + m_settings->registerSetting("ConsoleFont", resolvedDefaultMonospace); + m_settings->registerSetting("ConsoleFontSize", defaultSize); + m_settings->registerSetting("ConsoleMaxLines", 100000); + m_settings->registerSetting("ConsoleOverflowStop", true); + + logModel->setMaxLines(getConsoleMaxLines(settings())); + logModel->setStopOnOverflow(shouldStopOnConsoleOverflow(settings())); + logModel->setOverflowMessage(tr("Cannot display this log since the log length surpassed %1 lines.").arg(logModel->getMaxLines())); + + // Folders + m_settings->registerSetting("InstanceDir", "instances"); + m_settings->registerSetting({ "CentralModsDir", "ModsDir" }, "mods"); + m_settings->registerSetting("IconsDir", "icons"); + m_settings->registerSetting("DownloadsDir", QStandardPaths::writableLocation(QStandardPaths::DownloadLocation)); + m_settings->registerSetting("DownloadsDirWatchRecursive", false); + m_settings->registerSetting("MoveModsFromDownloadsDir", false); + m_settings->registerSetting("SkinsDir", "skins"); + m_settings->registerSetting("JavaDir", "java"); + +#ifdef Q_OS_MACOS + // Folder security-scoped bookmarks + m_settings->registerSetting("InstanceDirBookmark", ""); + m_settings->registerSetting("CentralModsDirBookmark", ""); + m_settings->registerSetting("IconsDirBookmark", ""); + m_settings->registerSetting("DownloadsDirBookmark", ""); + m_settings->registerSetting("SkinsDirBookmark", ""); + m_settings->registerSetting("JavaDirBookmark", ""); +#endif + + // Editors + m_settings->registerSetting("JsonEditor", QString()); + + // Language + m_settings->registerSetting("Language", QString()); + m_settings->registerSetting("UseSystemLocale", false); + + // Console + m_settings->registerSetting("ShowConsole", false); + m_settings->registerSetting("AutoCloseConsole", false); + m_settings->registerSetting("ShowConsoleOnError", true); + m_settings->registerSetting("LogPrePostOutput", true); + + // Window Size + m_settings->registerSetting({ "LaunchMaximized", "MCWindowMaximize" }, false); + m_settings->registerSetting({ "MinecraftWinWidth", "MCWindowWidth" }, 854); + m_settings->registerSetting({ "MinecraftWinHeight", "MCWindowHeight" }, 480); + + // Proxy Settings + m_settings->registerSetting("ProxyType", "None"); + m_settings->registerSetting({ "ProxyAddr", "ProxyHostName" }, "127.0.0.1"); + m_settings->registerSetting("ProxyPort", 8080); + m_settings->registerSetting({ "ProxyUser", "ProxyUsername" }, ""); + m_settings->registerSetting({ "ProxyPass", "ProxyPassword" }, ""); + + // Memory + m_settings->registerSetting({ "MinMemAlloc", "MinMemoryAlloc" }, 384); + m_settings->registerSetting({ "MaxMemAlloc", "MaxMemoryAlloc" }, 4096); + m_settings->registerSetting("PermGen", 128); + m_settings->registerSetting("LowMemWarning", true); + + // Java Settings + m_settings->registerSetting("JavaPath", ""); + m_settings->registerSetting("JavaSignature", ""); + m_settings->registerSetting("JavaArchitecture", ""); + m_settings->registerSetting("JavaRealArchitecture", ""); + m_settings->registerSetting("JavaVersion", ""); + m_settings->registerSetting("JavaVendor", ""); + m_settings->registerSetting("LastHostname", ""); + m_settings->registerSetting("JvmArgs", ""); + m_settings->registerSetting("IgnoreJavaCompatibility", false); + m_settings->registerSetting("IgnoreJavaWizard", false); + auto defaultEnableAutoJava = m_settings->get("JavaPath").toString().isEmpty(); + m_settings->registerSetting("AutomaticJavaSwitch", defaultEnableAutoJava); + m_settings->registerSetting("AutomaticJavaDownload", defaultEnableAutoJava); + m_settings->registerSetting("UserAskedAboutAutomaticJavaDownload", false); + + // Legacy settings + m_settings->registerSetting("OnlineFixes", false); + + // Native library workarounds + m_settings->registerSetting("UseNativeOpenAL", false); + m_settings->registerSetting("CustomOpenALPath", ""); + m_settings->registerSetting("UseNativeGLFW", false); + m_settings->registerSetting("CustomGLFWPath", ""); + + // Performance related options + m_settings->registerSetting("EnableFeralGamemode", false); + m_settings->registerSetting("EnableMangoHud", false); + m_settings->registerSetting("UseDiscreteGpu", false); + m_settings->registerSetting("UseZink", false); + + // Game time + m_settings->registerSetting("ShowGameTime", true); + m_settings->registerSetting("ShowGlobalGameTime", true); + m_settings->registerSetting("RecordGameTime", true); + m_settings->registerSetting("ShowGameTimeWithoutDays", false); + + // Minecraft mods + m_settings->registerSetting("ModMetadataDisabled", false); + m_settings->registerSetting("ModDependenciesDisabled", false); + m_settings->registerSetting("SkipModpackUpdatePrompt", false); + m_settings->registerSetting("ShowModIncompat", false); + + // Minecraft offline player name + m_settings->registerSetting("LastOfflinePlayerName", ""); + + // Wrapper command for launch + m_settings->registerSetting("WrapperCommand", ""); + + // Custom Commands + m_settings->registerSetting({ "PreLaunchCommand", "PreLaunchCmd" }, ""); + m_settings->registerSetting({ "PostExitCommand", "PostExitCmd" }, ""); + + // The cat + m_settings->registerSetting("TheCat", false); + m_settings->registerSetting("CatOpacity", 100); + m_settings->registerSetting("CatFit", "fit"); + + m_settings->registerSetting("StatusBarVisible", true); + + m_settings->registerSetting("ToolbarsLocked", false); + + // Instance + m_settings->registerSetting("InstSortMode", "Name"); + m_settings->registerSetting("InstRenamingMode", "AskEverytime"); + m_settings->registerSetting("SelectedInstance", QString()); + + // Window state and geometry + m_settings->registerSetting("MainWindowState", ""); + m_settings->registerSetting("MainWindowGeometry", + "AdnQywADAAAAAAcIAAAAAAAACGIAAAEJAAAHCAAAAAAAAAhiAAABCQAAAAAAAAAAB4AAAAcIAAAAAAAACGIAAAEJ"); + + m_settings->registerSetting("ConsoleWindowState", ""); + m_settings->registerSetting("ConsoleWindowGeometry", + "AdnQywADAAAAAAcIAAAAAAAAChYAAALBAAAHCAAAAAAAAAoWAAACwQAAAAAAAAAAB4AAAAcIAAAAAAAAChYAAALB"); + + m_settings->registerSetting("SettingsGeometry", ""); + + m_settings->registerSetting("PagedGeometry", + "AdnQywADAAAAAAcIAAAAAAAACm8AAAKJAAAHCAAAAAAAAApvAAACiQAAAAAAAAAAB4AAAAcIAAAAAAAACm8AAAKJ"); + + m_settings->registerSetting("NewInstanceGeometry", + "AdnQywADAAAAAAcIAAAAAAAAC2AAAAH7AAAHCAAAAAAAAAtgAAAB+wAAAAAAAAAAB4AAAAcIAAAAAAAAC2AAAAH7"); + + m_settings->registerSetting("UpdateDialogGeometry", ""); + + m_settings->registerSetting("NewsGeometry", + "AdnQywADAAAAAAcIAAAAAAAACicAAAHzAAAHCAAAAAAAAAonAAAB8wAAAAAAAAAAB4AAAAcIAAAAAAAACicAAAHz"); + + m_settings->registerSetting("ModDownloadGeometry", ""); + m_settings->registerSetting("RPDownloadGeometry", ""); + m_settings->registerSetting("TPDownloadGeometry", ""); + m_settings->registerSetting("ShaderDownloadGeometry", ""); + m_settings->registerSetting("DataPackDownloadGeometry", ""); + + // data pack window + // in future, more pages may be added - so this name is chosen to avoid needing migration + m_settings->registerSetting("WorldManagementGeometry", ""); + + // HACK: This code feels so stupid is there a less stupid way of doing this? + { + m_settings->registerSetting("PastebinURL", ""); + m_settings->registerSetting("PastebinType", PasteUpload::PasteType::Mclogs); + m_settings->registerSetting("PastebinCustomAPIBase", ""); + + QString pastebinURL = m_settings->get("PastebinURL").toString(); + + bool userHadDefaultPastebin = pastebinURL == "https://0x0.st"; + if (!pastebinURL.isEmpty() && !userHadDefaultPastebin) { + m_settings->set("PastebinType", PasteUpload::PasteType::NullPointer); + m_settings->set("PastebinCustomAPIBase", pastebinURL); + m_settings->reset("PastebinURL"); + } + + bool ok; + int pasteType = m_settings->get("PastebinType").toInt(&ok); + // If PastebinType is invalid then reset the related settings. + if (!ok || !(PasteUpload::PasteType::First <= pasteType && pasteType <= PasteUpload::PasteType::Last)) { + m_settings->reset("PastebinType"); + m_settings->reset("PastebinCustomAPIBase"); + } + } + { + auto resetIfInvalid = [this](const Setting* setting) { + if (const QUrl url(setting->get().toString()); !url.isValid() || (url.scheme() != "http" && url.scheme() != "https")) { + m_settings->reset(setting->id()); + } + }; + + // Meta URL + resetIfInvalid(m_settings->registerSetting("MetaURLOverride", "").get()); + + // Resource URL + resetIfInvalid(m_settings->registerSetting({ "ResourceURLOverride", "ResourceURL" }, "").get()); + + // Legacy FML libs URL + resetIfInvalid(m_settings->registerSetting("LegacyFMLLibsURLOverride", "").get()); + } + + m_settings->registerSetting("CloseAfterLaunch", false); + m_settings->registerSetting("QuitAfterGameStop", false); + + m_settings->registerSetting("Env", "{}"); + + // Custom Microsoft Authentication Client ID + m_settings->registerSetting("MSAClientIDOverride", ""); + + // Custom Flame API Key + { + m_settings->registerSetting("CFKeyOverride", ""); + m_settings->registerSetting("FlameKeyOverride", ""); + + QString flameKey = m_settings->get("CFKeyOverride").toString(); + + if (!flameKey.isEmpty()) + m_settings->set("FlameKeyOverride", flameKey); + m_settings->reset("CFKeyOverride"); + } + m_settings->registerSetting("FallbackMRBlockedMods", true); + m_settings->registerSetting("ModrinthToken", ""); + m_settings->registerSetting("UserAgentOverride", ""); + + // FTBApp instances + m_settings->registerSetting("FTBAppInstancesPath", ""); + + // Custom Technic Client ID + m_settings->registerSetting("TechnicClientID", ""); + + // Init page provider + { + m_globalSettingsProvider = std::make_unique(tr("Settings")); + m_globalSettingsProvider->addPage(); + m_globalSettingsProvider->addPage(); + m_globalSettingsProvider->addPage(); + m_globalSettingsProvider->addPage(); + m_globalSettingsProvider->addPage(); + m_globalSettingsProvider->addPage(); + m_globalSettingsProvider->addPage(); + m_globalSettingsProvider->addPage(); + m_globalSettingsProvider->addPage(); + } + + PixmapCache::setInstance(new PixmapCache(this)); + + qInfo() << "<> Settings loaded."; + } + +#ifndef QT_NO_ACCESSIBILITY + QAccessible::installFactory(groupViewAccessibleFactory); +#endif /* !QT_NO_ACCESSIBILITY */ + + // initialize network access and proxy setup + { + m_network.reset(new QNetworkAccessManager()); + QString proxyTypeStr = settings()->get("ProxyType").toString(); + QString addr = settings()->get("ProxyAddr").toString(); + int port = settings()->get("ProxyPort").value(); + QString user = settings()->get("ProxyUser").toString(); + QString pass = settings()->get("ProxyPass").toString(); + updateProxySettings(proxyTypeStr, addr, port, user, pass); + qInfo() << "<> Network done."; + } + + // load translations + { + m_translations.reset(new TranslationsModel("translations")); + auto bcp47Name = m_settings->get("Language").toString(); + m_translations->selectLanguage(bcp47Name); + qInfo() << "Your language is" << bcp47Name; + qInfo() << "<> Translations loaded."; + } + + // Instance icons + { + auto setting = APPLICATION->settings()->getSetting("IconsDir"); + QStringList instFolders = { ":/icons/racked_ru/32x32/instances/", ":/icons/racked_ru/50x50/instances/", + ":/icons/racked_ru/128x128/instances/", ":/icons/racked_ru/scalable/instances/" }; + m_icons.reset(new IconList(instFolders, setting->get().toString())); + connect(setting.get(), &Setting::SettingChanged, + [this](const Setting&, QVariant value) { m_icons->directoryChanged(value.toString()); }); + qInfo() << "<> Instance icons initialized."; + } + + // Themes + m_themeManager = std::make_unique(); + +#ifdef Q_OS_MACOS + // for macOS: getting directory settings will generate URL security-scoped bookmarks if needed and not present + // this facilitates a smooth transition from a non-sandboxed version of the launcher, that likely can access the directory, + // and a sandboxed version that can't access the directory without a bookmark + // this section can likely be removed once the sandboxed version has been released for a while and migrations aren't done anymore + { + m_settings->get("InstanceDir"); + m_settings->get("CentralModsDir"); + m_settings->get("IconsDir"); + m_settings->get("DownloadsDir"); + m_settings->get("SkinsDir"); + m_settings->get("JavaDir"); + } +#endif + + // initialize and load all instances + { + auto InstDirSetting = m_settings->getSetting("InstanceDir"); + // instance path: check for problems with '!' in instance path and warn the user in the log + // and remember that we have to show him a dialog when the gui starts (if it does so) + QString instDir = m_settings->get("InstanceDir").toString(); + qInfo() << "Instance path :" << instDir; + if (FS::checkProblemticPathJava(QDir(instDir))) { + qWarning() << "Your instance path contains \'!\' and this is known to cause java problems!"; + } + m_instances.reset(new InstanceList(m_settings.get(), instDir, this)); + connect(InstDirSetting.get(), &Setting::SettingChanged, m_instances.get(), &InstanceList::on_InstFolderChanged); + qInfo() << "Loading Instances..."; + m_instances->loadList(); + qInfo() << "<> Instances loaded."; + } + + // and accounts + { + m_accounts.reset(new AccountList(this)); + qInfo() << "Loading accounts..."; + m_accounts->setListFilePath("accounts.json", true); + m_accounts->loadList(); + m_accounts->fillQueue(); + qInfo() << "<> Accounts loaded."; + } + + // init the http meta cache + { + m_metacache.reset(new HttpMetaCache("metacache")); + m_metacache->addBase("asset_indexes", QDir("assets/indexes").absolutePath()); + m_metacache->addBase("libraries", QDir("libraries").absolutePath()); + m_metacache->addBase("fmllibs", QDir("mods/minecraftforge/libs").absolutePath()); + m_metacache->addBase("general", QDir("cache").absolutePath()); + m_metacache->addBase("ATLauncherPacks", QDir("cache/ATLauncherPacks").absolutePath()); + m_metacache->addBase("FTBPacks", QDir("cache/FTBPacks").absolutePath()); + m_metacache->addBase("TechnicPacks", QDir("cache/TechnicPacks").absolutePath()); + m_metacache->addBase("FlamePacks", QDir("cache/FlamePacks").absolutePath()); + m_metacache->addBase("FlameMods", QDir("cache/FlameMods").absolutePath()); + m_metacache->addBase("ModrinthPacks", QDir("cache/ModrinthPacks").absolutePath()); + m_metacache->addBase("ModrinthModpacks", QDir("cache/ModrinthModpacks").absolutePath()); + m_metacache->addBase("translations", QDir("translations").absolutePath()); + m_metacache->addBase("meta", QDir("meta").absolutePath()); + m_metacache->addBase("java", QDir("cache/java").absolutePath()); + m_metacache->addBase("feed", QDir("cache/feed").absolutePath()); + m_metacache->Load(); + qInfo() << "<> Cache initialized."; + } + + // now we have network, download translation updates + m_translations->downloadIndex(); + + // FIXME: what to do with these? + m_profilers.insert("jprofiler", std::shared_ptr(new JProfilerFactory())); + m_profilers.insert("jvisualvm", std::shared_ptr(new JVisualVMFactory())); + m_profilers.insert("generic", std::shared_ptr(new GenericProfilerFactory())); + for (auto profiler : m_profilers.values()) { + profiler->registerSettings(m_settings.get()); + } + + // Create the MCEdit thing... why is this here? + { + m_mcedit.reset(new MCEditTool(m_settings.get())); + } + +#ifdef Q_OS_MACOS + connect(this, &Application::clickedOnDock, [this]() { this->showMainWindow(); }); +#endif + + connect(this, &Application::aboutToQuit, [this]() { + if (m_instances) { + // save any remaining instance state + m_instances->saveNow(); + } + if (logFile) { + logFile->flush(); + logFile->close(); + } + }); + + updateCapabilities(); + + detectLibraries(); + + // check update locks + { + auto update_log_path = FS::PathCombine(m_dataPath, "logs", "prism_launcher_update.log"); + + auto update_lock = QFileInfo(FS::PathCombine(m_dataPath, ".prism_launcher_update.lock")); + if (update_lock.exists()) { + auto [timestamp, from, to, target, data_path] = read_lock_File(update_lock.absoluteFilePath()); + auto infoMsg = tr("This installation has a update lock file present at: %1\n" + "\n" + "Timestamp: %2\n" + "Updating from version %3 to %4\n" + "Target install path: %5\n" + "Data Path: %6" + "\n" + "This likely means that a update attempt failed. Please ensure your installation is in working order before " + "proceeding.\n" + "Check the Prism Launcher updater log at: \n" + "%7\n" + "for details on the last update attempt.\n" + "\n" + "To delete this lock and proceed select \"Ignore\" below.") + .arg(update_lock.absoluteFilePath()) + .arg(timestamp.toString(Qt::ISODate), from, to, target, data_path) + .arg(update_log_path); + auto msgBox = QMessageBox(QMessageBox::Warning, tr("Update In Progress"), infoMsg, QMessageBox::Ignore | QMessageBox::Abort); + msgBox.setDefaultButton(QMessageBox::Abort); + msgBox.setModal(true); + msgBox.setDetailedText(FS::read(update_log_path)); + msgBox.setMinimumWidth(460); + msgBox.adjustSize(); + auto res = msgBox.exec(); + switch (res) { + case QMessageBox::Ignore: { + FS::deletePath(update_lock.absoluteFilePath()); + break; + } + case QMessageBox::Abort: + [[fallthrough]]; + default: { + qDebug() << "Exiting because update lockfile is present"; + QMetaObject::invokeMethod(this, []() { exit(1); }, Qt::QueuedConnection); + return; + } + } + } + + auto update_fail_marker = QFileInfo(FS::PathCombine(m_dataPath, ".prism_launcher_update.fail")); + if (update_fail_marker.exists()) { + auto infoMsg = tr("An update attempt failed\n" + "\n" + "Please ensure your installation is in working order before " + "proceeding.\n" + "Check the Prism Launcher updater log at: \n" + "%1\n" + "for details on the last update attempt.") + .arg(update_log_path); + auto msgBox = QMessageBox(QMessageBox::Warning, tr("Update Failed"), infoMsg, QMessageBox::Ignore | QMessageBox::Abort); + msgBox.setDefaultButton(QMessageBox::Abort); + msgBox.setModal(true); + msgBox.setDetailedText(FS::read(update_log_path)); + msgBox.setMinimumWidth(460); + msgBox.adjustSize(); + auto res = msgBox.exec(); + switch (res) { + case QMessageBox::Ignore: { + FS::deletePath(update_fail_marker.absoluteFilePath()); + break; + } + case QMessageBox::Abort: + [[fallthrough]]; + default: { + qDebug() << "Exiting because update lockfile is present"; + QMetaObject::invokeMethod(this, []() { exit(1); }, Qt::QueuedConnection); + return; + } + } + } + + auto update_success_marker = QFileInfo(FS::PathCombine(m_dataPath, ".prism_launcher_update.success")); + if (update_success_marker.exists()) { + auto infoMsg = tr("Update succeeded\n" + "\n" + "You are now running %1 .\n" + "Check the Prism Launcher updater log at: \n" + "%2\n" + "for details.") + .arg(BuildConfig.printableVersionString()) + .arg(update_log_path); + auto msgBox = new QMessageBox(QMessageBox::Information, tr("Update Succeeded"), infoMsg, QMessageBox::Ok); + msgBox->setDefaultButton(QMessageBox::Ok); + msgBox->setDetailedText(FS::read(update_log_path)); + msgBox->setAttribute(Qt::WA_DeleteOnClose); + msgBox->setMinimumWidth(460); + msgBox->adjustSize(); + msgBox->open(); + FS::deletePath(update_success_marker.absoluteFilePath()); + } + } + + // notify user if /tmp is mounted with `noexec` (#1693) + QString jvmArgs = m_settings->get("JvmArgs").toString(); + if (jvmArgs.indexOf("java.io.tmpdir") == -1) { /* java.io.tmpdir is a valid workaround, so don't annoy */ + bool is_tmp_noexec = false; + +#if defined(Q_OS_LINUX) + + struct statvfs tmp_stat; + statvfs("/tmp", &tmp_stat); + is_tmp_noexec = tmp_stat.f_flag & ST_NOEXEC; + +#elif defined(Q_OS_FREEBSD) || defined(Q_OS_OPENBSD) + + struct statfs tmp_stat; + statfs("/tmp", &tmp_stat); + is_tmp_noexec = tmp_stat.f_flags & MNT_NOEXEC; + +#endif + + if (is_tmp_noexec) { + auto infoMsg = + tr("Your /tmp directory is currently mounted with the 'noexec' flag enabled.\n" + "Some versions of Minecraft may not launch.\n" + "\n" + "You may solve this issue by remounting /tmp as 'exec' or setting " + "the java.io.tmpdir JVM argument to a writeable directory in a " + "filesystem where the 'exec' flag is set (e.g., /home/user/.local/tmp)\n"); + auto msgBox = new QMessageBox(QMessageBox::Information, tr("Incompatible system configuration"), infoMsg, QMessageBox::Ok); + msgBox->setDefaultButton(QMessageBox::Ok); + msgBox->setAttribute(Qt::WA_DeleteOnClose); + msgBox->setMinimumWidth(460); + msgBox->adjustSize(); + msgBox->open(); + } + } + + if (qgetenv("XDG_CURRENT_DESKTOP") == "gamescope") { + installEventFilter(new ToolTipFilter); + } + + if (createSetupWizard()) { + return; + } + + m_themeManager->applyCurrentlySelectedTheme(true); + performMainStartupAction(); +} + +bool Application::createSetupWizard() +{ + bool javaRequired = [this]() { + if (BuildConfig.JAVA_DOWNLOADER_ENABLED && settings()->get("AutomaticJavaDownload").toBool()) { + return false; + } + bool ignoreJavaWizard = settings()->get("IgnoreJavaWizard").toBool(); + if (ignoreJavaWizard) { + return false; + } + QString currentHostName = QHostInfo::localHostName(); + QString oldHostName = settings()->get("LastHostname").toString(); + if (currentHostName != oldHostName) { + settings()->set("LastHostname", currentHostName); + return true; + } + QString currentJavaPath = settings()->get("JavaPath").toString(); + QString actualPath = FS::ResolveExecutable(currentJavaPath); + return actualPath.isNull(); + }(); + bool askjava = BuildConfig.JAVA_DOWNLOADER_ENABLED && !javaRequired && !settings()->get("AutomaticJavaDownload").toBool() && + !settings()->get("AutomaticJavaSwitch").toBool() && !settings()->get("UserAskedAboutAutomaticJavaDownload").toBool(); + bool languageRequired = settings()->get("Language").toString().isEmpty(); + bool pasteInterventionRequired = settings()->get("PastebinURL") != ""; + bool validWidgets = m_themeManager->isValidApplicationTheme(settings()->get("ApplicationTheme").toString()); + bool validIcons = m_themeManager->isValidIconTheme(settings()->get("IconTheme").toString()); + bool login = !m_accounts->anyAccountIsValid() && capabilities() & Application::SupportsMSA; + bool themeInterventionRequired = !validWidgets || !validIcons; + bool wizardRequired = javaRequired || languageRequired || pasteInterventionRequired || themeInterventionRequired || askjava || login; + if (wizardRequired) { + // set default theme after going into theme wizard + if (!validIcons) + settings()->set("IconTheme", QString("flat_white")); + if (!validWidgets) { +#if defined(Q_OS_WIN32) && QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) + const QString style = + QGuiApplication::styleHints()->colorScheme() == Qt::ColorScheme::Dark ? QStringLiteral("dark") : QStringLiteral("bright"); +#else + const QString style = QStringLiteral("system"); +#endif + + settings()->set("ApplicationTheme", style); + } + + m_themeManager->applyCurrentlySelectedTheme(true); + + m_setupWizard = new SetupWizard(nullptr); + if (languageRequired) { + m_setupWizard->addPage(new LanguageWizardPage(m_setupWizard)); + } + + if (javaRequired) { + m_setupWizard->addPage(new JavaWizardPage(m_setupWizard)); + } else if (askjava) { + m_setupWizard->addPage(new AutoJavaWizardPage(m_setupWizard)); + } + + if (pasteInterventionRequired) { + m_setupWizard->addPage(new PasteWizardPage(m_setupWizard)); + } + + if (themeInterventionRequired) { + m_setupWizard->addPage(new ThemeWizardPage(m_setupWizard)); + } + + if (login) { + m_setupWizard->addPage(new LoginWizardPage(m_setupWizard)); + } + connect(m_setupWizard, &QDialog::finished, this, &Application::setupWizardFinished); + m_setupWizard->show(); + } + + return wizardRequired || login; +} + +bool Application::updaterEnabled() +{ +#if defined(Q_OS_MAC) + return BuildConfig.UPDATER_ENABLED; +#else + return BuildConfig.UPDATER_ENABLED && QFileInfo(FS::PathCombine(m_rootPath, updaterBinaryName())).isFile(); +#endif +} + +QString Application::updaterBinaryName() +{ + auto exe_name = QStringLiteral("%1_updater").arg(BuildConfig.LAUNCHER_APP_BINARY_NAME); +#if defined Q_OS_WIN32 + exe_name.append(".exe"); +#else + exe_name.prepend("bin/"); +#endif + return exe_name; +} + +bool Application::event(QEvent* event) +{ +#ifdef Q_OS_MACOS + if (event->type() == QEvent::ApplicationStateChange) { + auto ev = static_cast(event); + + if (m_prevAppState == Qt::ApplicationActive && ev->applicationState() == Qt::ApplicationActive) { + emit clickedOnDock(); + } + m_prevAppState = ev->applicationState(); + } +#endif + + if (event->type() == QEvent::FileOpen) { + if (!m_mainWindow) { + showMainWindow(false); + } + auto ev = static_cast(event); + m_mainWindow->processURLs({ ev->url() }); + } + + return QApplication::event(event); +} + +void Application::setupWizardFinished(int status) +{ + qDebug() << "Wizard result =" << status; + performMainStartupAction(); +} + +void Application::performMainStartupAction() +{ + m_status = Application::Initialized; + if (!m_instanceIdToLaunch.isEmpty()) { + auto inst = instances()->getInstanceById(m_instanceIdToLaunch); + if (inst) { + MinecraftTarget::Ptr targetToJoin = nullptr; + MinecraftAccountPtr accountToUse = nullptr; + + qDebug() << "<> Instance" << m_instanceIdToLaunch << "launching"; + if (!m_serverToJoin.isEmpty()) { + // FIXME: validate the server string + targetToJoin.reset(new MinecraftTarget(MinecraftTarget::parse(m_serverToJoin, false))); + qDebug() << " Launching with server" << m_serverToJoin; + } else if (!m_worldToJoin.isEmpty()) { + targetToJoin.reset(new MinecraftTarget(MinecraftTarget::parse(m_worldToJoin, true))); + qDebug() << " Launching with world" << m_worldToJoin; + } + + if (!m_profileToUse.isEmpty()) { + accountToUse = accounts()->getAccountByProfileName(m_profileToUse); + if (!accountToUse) { + return; + } + qDebug() << " Launching with account" << m_profileToUse; + } + + launch(inst, m_launchOffline ? LaunchMode::Offline : LaunchMode::Normal, targetToJoin, accountToUse, m_offlineName); + + if (!m_showMainWindow) { + return; + } + } + } + if (!m_instanceIdToShowWindowOf.isEmpty()) { + auto inst = instances()->getInstanceById(m_instanceIdToShowWindowOf); + if (inst) { + qDebug() << "<> Showing window of instance " << m_instanceIdToShowWindowOf; + showInstanceWindow(inst); + return; + } + } + if (!m_mainWindow) { + // normal main window + showMainWindow(false); + qDebug() << "<> Main window shown."; + } + + // initialize the updater + if (updaterEnabled()) { + qDebug() << "Initializing updater"; +#ifdef Q_OS_MAC +#if defined(SPARKLE_ENABLED) + m_updater.reset(new MacSparkleUpdater()); +#endif +#else + m_updater.reset(new PrismExternalUpdater(m_mainWindow, m_rootPath, m_dataPath)); +#endif + qDebug() << "<> Updater started."; + } + + { // delete instances tmp dirctory + auto instDir = m_settings->get("InstanceDir").toString(); + const QString tempRoot = FS::PathCombine(instDir, ".tmp"); + FS::deletePath(tempRoot); + } + + if (!m_urlsToImport.isEmpty()) { + qDebug() << "<> Importing from url:" << m_urlsToImport; + m_mainWindow->processURLs(m_urlsToImport); + } +} + +void Application::showFatalErrorMessage(const QString& title, const QString& content) +{ + m_status = Application::Failed; + auto dialog = CustomMessageBox::selectable(nullptr, title, content, QMessageBox::Critical); + dialog->exec(); +} + +Application::~Application() +{ + // Shut down logger by setting the logger function to nothing + qInstallMessageHandler(nullptr); +} + +void Application::messageReceived(const QByteArray& message) +{ + ApplicationMessage received; + received.parse(message); + + auto& command = received.command; + + if (status() != Initialized) { + bool isLoginAtempt = false; + if (command == "import") { + QString url = received.args["url"]; + isLoginAtempt = !url.isEmpty() && normalizeImportUrl(url).scheme() == BuildConfig.LAUNCHER_APP_BINARY_NAME; + } + if (!isLoginAtempt) { + qDebug() << "Received message" << message << "while still initializing. It will be ignored."; + return; + } + } + + if (command == "activate") { + showMainWindow(); + } else if (command == "import") { + QString url = received.args["url"]; + if (url.isEmpty()) { + qWarning() << "Received" << command << "message without a zip path/URL."; + return; + } + if (!m_mainWindow) { + showMainWindow(false); + } + m_mainWindow->processURLs({ normalizeImportUrl(url) }); + } else if (command == "launch") { + QString id = received.args["id"]; + QString server = received.args["server"]; + QString world = received.args["world"]; + QString profile = received.args["profile"]; + bool offline = received.args["offline_enabled"] == "true"; + QString offlineName = received.args["offline_name"]; + + BaseInstance* instance; + if (!id.isEmpty()) { + instance = instances()->getInstanceById(id); + if (!instance) { + qWarning() << "Launch command requires an valid instance ID. " << id << "resolves to nothing."; + return; + } + } else { + qWarning() << "Launch command called without an instance ID..."; + return; + } + + MinecraftTarget::Ptr serverObject = nullptr; + if (!server.isEmpty()) { + serverObject = std::make_shared(MinecraftTarget::parse(server, false)); + } else if (!world.isEmpty()) { + serverObject = std::make_shared(MinecraftTarget::parse(world, true)); + } + MinecraftAccountPtr accountObject; + if (!profile.isEmpty()) { + accountObject = accounts()->getAccountByProfileName(profile); + if (!accountObject) { + qWarning() << "Launch command requires the specified profile to be valid. " << profile + << "does not resolve to any account."; + return; + } + } + + launch(instance, offline ? LaunchMode::Offline : LaunchMode::Normal, serverObject, accountObject, offlineName); + } else { + qWarning() << "Received invalid message" << message; + } +} + +TranslationsModel* Application::translations() +{ + return m_translations.get(); +} + +JavaInstallList* Application::javalist() +{ + if (!m_javalist) { + m_javalist.reset(new JavaInstallList()); + } + return m_javalist.get(); +} + +QIcon Application::logo() +{ + return QIcon(":/" + BuildConfig.LAUNCHER_SVGFILENAME); +} + +bool Application::openJsonEditor(const QString& filename) +{ + const QString file = QDir::current().absoluteFilePath(filename); + if (m_settings->get("JsonEditor").toString().isEmpty()) { + return DesktopServices::openUrl(QUrl::fromLocalFile(file)); + } else { + // return DesktopServices::openFile(m_settings->get("JsonEditor").toString(), file); + return DesktopServices::run(m_settings->get("JsonEditor").toString(), { file }); + } +} + +bool Application::launch(BaseInstance* instance, + LaunchMode mode, + MinecraftTarget::Ptr targetToJoin, + MinecraftAccountPtr accountToUse, + const QString& offlineName) +{ + if (m_updateRunning) { + qDebug() << "Cannot launch instances while an update is running. Please try again when updates are completed."; + } else if (instance->canLaunch()) { + QMutexLocker locker(&m_instanceExtrasMutex); + auto& extras = m_instanceExtras[instance->id()]; + auto window = extras.window; + if (window) { + if (!window->saveAll()) { + return false; + } + } + auto& controller = extras.controller; + controller.reset(new LaunchController()); + controller->setInstance(instance); + controller->setLaunchMode(mode); + controller->setProfiler(profilers().value(instance->settings()->get("Profiler").toString(), nullptr).get()); + controller->setTargetToJoin(targetToJoin); + controller->setAccountToUse(accountToUse); + controller->setOfflineName(offlineName); + if (window) { + controller->setParentWidget(window); + } else if (m_mainWindow) { + controller->setParentWidget(m_mainWindow); + } + connect(controller.get(), &LaunchController::finished, this, &Application::controllerFinished); + addRunningInstance(); + QMetaObject::invokeMethod(controller.get(), &Task::start, Qt::QueuedConnection); + return true; + } else if (instance->isRunning()) { + showInstanceWindow(instance, "console"); + return true; + } else if (instance->canEdit()) { + showInstanceWindow(instance); + return true; + } + return false; +} + +bool Application::kill(BaseInstance* instance) +{ + if (!instance->isRunning()) { + qWarning() << "Attempted to kill instance" << instance->id() << ", which isn't running."; + return false; + } + QMutexLocker locker(&m_instanceExtrasMutex); + auto& extras = m_instanceExtras[instance->id()]; + // NOTE: copy of the shared pointer keeps it alive + auto& controller = extras.controller; + locker.unlock(); + if (controller) { + return controller->abort(); + } + return true; +} + +void Application::closeCurrentWindow() +{ + if (focusWindow()) + focusWindow()->close(); +} + +void Application::addRunningInstance() +{ + m_runningInstances++; + if (m_runningInstances == 1) { + emit updateAllowedChanged(false); + } +} + +void Application::subRunningInstance() +{ + if (m_runningInstances == 0) { + qCritical() << "Something went really wrong and we now have less than 0 running instances... WTF"; + return; + } + m_runningInstances--; + if (m_runningInstances == 0) { + emit updateAllowedChanged(true); + } +} + +bool Application::shouldExitNow() const +{ + return m_runningInstances == 0 && m_openWindows == 0; +} + +bool Application::updatesAreAllowed() +{ + return m_runningInstances == 0; +} + +void Application::updateIsRunning(bool running) +{ + m_updateRunning = running; +} + +void Application::controllerFinished() +{ + auto controller = qobject_cast(sender()); + if (!controller) + return; + auto id = controller->id(); + + QMutexLocker locker(&m_instanceExtrasMutex); + auto& extras = m_instanceExtras.at(id); + + const bool wasSuccessful = controller->wasSuccessful(); + // on success, do... + if (wasSuccessful && controller->instance()->settings()->get("AutoCloseConsole").toBool()) { + if (extras.window) { + QMetaObject::invokeMethod(extras.window, &QWidget::close, Qt::QueuedConnection); + } + } + extras.controller.reset(); + subRunningInstance(); + + // quit when there are no more windows. + if (shouldExitNow()) { + m_status = wasSuccessful ? Succeeded : Failed; + exit(wasSuccessful ? 0 : 1); + } +} + +void Application::ShowGlobalSettings(class QWidget* parent, QString open_page) +{ + if (!m_globalSettingsProvider) { + return; + } + emit globalSettingsAboutToOpen(); + { + SettingsObject::Lock lock(APPLICATION->settings()); + PageDialog dlg(m_globalSettingsProvider.get(), open_page, parent); + connect(&dlg, &PageDialog::applied, this, &Application::globalSettingsApplied); + dlg.exec(); + } +} + +MainWindow* Application::showMainWindow(bool minimized) +{ + if (m_mainWindow) { + m_mainWindow->setWindowState(m_mainWindow->windowState() & ~Qt::WindowMinimized); + m_mainWindow->raise(); + m_mainWindow->activateWindow(); + } else { + m_mainWindow = new MainWindow(); + m_mainWindow->restoreState(QByteArray::fromBase64(APPLICATION->settings()->get("MainWindowState").toString().toUtf8())); + m_mainWindow->restoreGeometry(QByteArray::fromBase64(APPLICATION->settings()->get("MainWindowGeometry").toString().toUtf8())); + + if (auto* newsBar = m_mainWindow->findChild("newsToolBar")) { + newsBar->hide(); + newsBar->toggleViewAction()->setChecked(false); + } + + if (minimized) { + m_mainWindow->showMinimized(); + } else { + m_mainWindow->show(); + } + + m_mainWindow->checkInstancePathForProblems(); + connect(this, &Application::updateAllowedChanged, m_mainWindow, &MainWindow::updatesAllowedChanged); + connect(m_mainWindow, &MainWindow::isClosing, this, &Application::on_windowClose); + m_openWindows++; + } + return m_mainWindow; +} + +ViewLogWindow* Application::showLogWindow() +{ + if (m_viewLogWindow) { + m_viewLogWindow->setWindowState(m_viewLogWindow->windowState() & ~Qt::WindowMinimized); + m_viewLogWindow->raise(); + m_viewLogWindow->activateWindow(); + } else { + m_viewLogWindow = new ViewLogWindow(); + connect(m_viewLogWindow, &ViewLogWindow::isClosing, this, &Application::on_windowClose); + m_openWindows++; + } + return m_viewLogWindow; +} + +InstanceWindow* Application::showInstanceWindow(BaseInstance* instance, QString page) +{ + if (!instance) + return nullptr; + auto id = instance->id(); + QMutexLocker locker(&m_instanceExtrasMutex); + auto& extras = m_instanceExtras[id]; + auto& window = extras.window; + + if (window) { +// If the window is minimized on macOS or Windows, activate and bring it up +#ifdef Q_OS_MACOS + if (window->isMinimized()) { + window->setWindowState(window->windowState() & ~Qt::WindowMinimized); + } +#elif defined(Q_OS_WIN) + if (window->isMinimized()) { + window->showNormal(); + } +#endif + + window->raise(); + window->activateWindow(); + } else { + window = new InstanceWindow(instance); + m_openWindows++; + connect(window, &InstanceWindow::isClosing, this, &Application::on_windowClose); + } + + if (!page.isEmpty()) { + window->selectPage(page); + } + if (extras.controller) { + extras.controller->setParentWidget(window); + } + return window; +} + +void Application::on_windowClose() +{ + m_openWindows--; + auto instWindow = qobject_cast(sender()); + if (instWindow) { + QMutexLocker locker(&m_instanceExtrasMutex); + auto& extras = m_instanceExtras[instWindow->instanceId()]; + extras.window = nullptr; + if (extras.controller) { + extras.controller->setParentWidget(m_mainWindow); + } + } + auto mainWindow = qobject_cast(sender()); + if (mainWindow) { + m_mainWindow = nullptr; + } + auto logWindow = qobject_cast(sender()); + if (logWindow) { + m_viewLogWindow = nullptr; + } + // quit when there are no more windows. + if (shouldExitNow()) { + exit(0); + } +} + +void Application::updateProxySettings(QString proxyTypeStr, QString addr, int port, QString user, QString password) +{ + // Set the application proxy settings. + if (proxyTypeStr == "SOCKS5") { + QNetworkProxy::setApplicationProxy(QNetworkProxy(QNetworkProxy::Socks5Proxy, addr, port, user, password)); + } else if (proxyTypeStr == "HTTP") { + QNetworkProxy::setApplicationProxy(QNetworkProxy(QNetworkProxy::HttpProxy, addr, port, user, password)); + } else if (proxyTypeStr == "None") { + // If we have no proxy set, set no proxy and return. + QNetworkProxy::setApplicationProxy(QNetworkProxy(QNetworkProxy::NoProxy)); + } else { + // If we have "Default" selected, set Qt to use the system proxy settings. + QNetworkProxyFactory::setUseSystemConfiguration(true); + } + + qDebug() << "Detecting proxy settings..."; + QNetworkProxy proxy = QNetworkProxy::applicationProxy(); + m_network->setProxy(proxy); + + QString proxyDesc; + if (proxy.type() == QNetworkProxy::NoProxy) { + qDebug() << "Using no proxy is an option!"; + return; + } + switch (proxy.type()) { + case QNetworkProxy::DefaultProxy: + proxyDesc = "Default proxy: "; + break; + case QNetworkProxy::Socks5Proxy: + proxyDesc = "Socks5 proxy: "; + break; + case QNetworkProxy::HttpProxy: + proxyDesc = "HTTP proxy: "; + break; + case QNetworkProxy::HttpCachingProxy: + proxyDesc = "HTTP caching: "; + break; + case QNetworkProxy::FtpCachingProxy: + proxyDesc = "FTP caching: "; + break; + default: + proxyDesc = "DERP proxy: "; + break; + } + proxyDesc += QString("%1:%2").arg(proxy.hostName()).arg(proxy.port()); + qDebug() << proxyDesc; +} + +HttpMetaCache* Application::metacache() +{ + return m_metacache.get(); +} + +QNetworkAccessManager* Application::network() +{ + return m_network.get(); +} + +Meta::Index* Application::metadataIndex() +{ + if (!m_metadataIndex) { + m_metadataIndex.reset(new Meta::Index()); + } + return m_metadataIndex.get(); +} + +void Application::updateCapabilities() +{ + m_capabilities = None; + if (!getMSAClientID().isEmpty()) + m_capabilities |= SupportsMSA; + if (!getFlameAPIKey().isEmpty()) + m_capabilities |= SupportsFlame; + +#ifdef Q_OS_LINUX + if (gamemode_query_status() >= 0) + m_capabilities |= SupportsGameMode; + + if (!LibraryUtils::findMangoHud().isEmpty()) + m_capabilities |= SupportsMangoHud; +#endif +} + +void Application::detectLibraries() +{ +#ifdef Q_OS_LINUX + m_detectedGLFWPath = LibraryUtils::find(BuildConfig.GLFW_LIBRARY_NAME); + m_detectedOpenALPath = LibraryUtils::find(BuildConfig.OPENAL_LIBRARY_NAME); + qDebug() << "Detected native libraries:" << m_detectedGLFWPath << m_detectedOpenALPath; +#endif +} + +QString Application::getJarPath(QString jarFile) +{ + QStringList potentialPaths = { +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) || defined(Q_OS_OPENBSD) + FS::PathCombine(m_rootPath, "share", BuildConfig.LAUNCHER_NAME), +#endif + FS::PathCombine(m_rootPath, "jars"), FS::PathCombine(applicationDirPath(), "jars"), + FS::PathCombine(applicationDirPath(), "..", "jars") // from inside build dir, for debuging + }; + for (QString p : potentialPaths) { + QString jarPath = FS::PathCombine(p, jarFile); + if (QFileInfo(jarPath).isFile()) + return jarPath; + } + return {}; +} + +QString Application::getMSAClientID() +{ + QString clientIDOverride = m_settings->get("MSAClientIDOverride").toString(); + if (!clientIDOverride.isEmpty()) { + return clientIDOverride; + } + + return BuildConfig.MSA_CLIENT_ID; +} + +QString Application::getFlameAPIKey() +{ + QString keyOverride = m_settings->get("FlameKeyOverride").toString(); + if (!keyOverride.isEmpty()) { + return keyOverride; + } + + return BuildConfig.FLAME_API_KEY; +} + +QString Application::getModrinthAPIToken() +{ + QString tokenOverride = m_settings->get("ModrinthToken").toString(); + if (!tokenOverride.isEmpty()) + return tokenOverride; + + return QString(); +} + +QString Application::getUserAgent() +{ + QString uaOverride = m_settings->get("UserAgentOverride").toString(); + if (!uaOverride.isEmpty()) { + return uaOverride.replace("$LAUNCHER_VER", BuildConfig.printableVersionString()); + } + + return BuildConfig.USER_AGENT; +} + +bool Application::handleDataMigration(const QString& currentData, + const QString& oldData, + const QString& name, + const QString& configFile) const +{ + QString nomigratePath = FS::PathCombine(currentData, name + "_nomigrate.txt"); + QStringList configPaths = { FS::PathCombine(oldData, configFile), FS::PathCombine(oldData, BuildConfig.LAUNCHER_CONFIGFILE) }; + + QLocale locale; + + // Is there a valid config at the old location? + bool configExists = false; + for (QString configPath : configPaths) { + configExists |= QFileInfo::exists(configPath); + } + + if (!configExists || QFileInfo::exists(nomigratePath)) { + qDebug() << "<> No migration needed from" << name; + return false; + } + + QString message; + bool currentExists = QFileInfo::exists(FS::PathCombine(currentData, BuildConfig.LAUNCHER_CONFIGFILE)); + + if (currentExists) { + message = tr("Old data from %1 was found, but you already have existing data for %2. Sadly you will need to migrate yourself. Do " + "you want to be reminded of the pending data migration next time you start %2?") + .arg(name, BuildConfig.LAUNCHER_DISPLAYNAME); + } else { + message = tr("It looks like you used %1 before. Do you want to migrate your data to the new location of %2?") + .arg(name, BuildConfig.LAUNCHER_DISPLAYNAME); + + QFileInfo logInfo(FS::PathCombine(oldData, name + "-0.log")); + if (logInfo.exists()) { + QString lastModified = logInfo.lastModified().toString(locale.dateFormat()); + message = tr("It looks like you used %1 on %2 before. Do you want to migrate your data to the new location of %3?") + .arg(name, lastModified, BuildConfig.LAUNCHER_DISPLAYNAME); + } + } + + QMessageBox::StandardButton askMoveDialogue = + QMessageBox::question(nullptr, BuildConfig.LAUNCHER_DISPLAYNAME, message, QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); + + auto setDoNotMigrate = [&nomigratePath] { + QFile file(nomigratePath); + if (!file.open(QIODevice::WriteOnly)) { + qWarning() << "setDoNotMigrate failed; Failed to open file" << file.fileName() << "for writing:" << file.errorString(); + } + }; + + // create no-migrate file if user doesn't want to migrate + if (askMoveDialogue != QMessageBox::Yes) { + qDebug() << "<> Migration declined for" << name; + setDoNotMigrate(); + return currentExists; // cancel further migrations, if we already have a data directory + } + + if (!currentExists) { + // Migrate! + using namespace Filters; + + QList filters; + filters.append(equals(configFile)); + filters.append(equals(BuildConfig.LAUNCHER_CONFIGFILE)); // it's possible that we already used that directory before + filters.append(startsWith("logs/")); + filters.append(equals("accounts.json")); + filters.append(startsWith("accounts/")); + filters.append(startsWith("assets/")); + filters.append(startsWith("icons/")); + filters.append(startsWith("instances/")); + filters.append(startsWith("libraries/")); + filters.append(startsWith("mods/")); + filters.append(startsWith("themes/")); + + ProgressDialog diag; + DataMigrationTask task(oldData, currentData, any(std::move(filters))); + if (diag.execWithTask(&task)) { + qDebug() << "<> Migration succeeded"; + setDoNotMigrate(); + } else { + QString reason = task.failReason(); + QMessageBox::critical(nullptr, BuildConfig.LAUNCHER_DISPLAYNAME, tr("Migration failed! Reason: %1").arg(reason)); + } + } else { + qWarning() << "<> Migration was skipped, due to existing data"; + } + return true; +} + +void Application::triggerUpdateCheck() +{ + if (m_updater) { + qDebug() << "Checking for updates."; + m_updater->setBetaAllowed(false); // There are no other channels than stable + m_updater->checkForUpdates(); + } else { + qDebug() << "Updater not available."; + } +} + +QUrl Application::normalizeImportUrl(const QString& url) +{ + auto local_file = QFileInfo(url); + if (local_file.exists()) { + return QUrl::fromLocalFile(local_file.absoluteFilePath()); + } else { + return QUrl::fromUserInput(url); + } +} + +const QString Application::javaPath() +{ + return m_settings->get("JavaDir").toString(); +} + +void Application::addQSavePath(QString path) +{ + QMutexLocker locker(&m_qsaveResourcesMutex); + m_qsaveResources[path] = m_qsaveResources.value(path, 0) + 1; +} + +void Application::removeQSavePath(QString path) +{ + QMutexLocker locker(&m_qsaveResourcesMutex); + auto count = m_qsaveResources.value(path, 0) - 1; + if (count <= 0) { + m_qsaveResources.remove(path); + } else { + m_qsaveResources[path] = count; + } +} + +bool Application::checkQSavePath(QString path) +{ + QMutexLocker locker(&m_qsaveResourcesMutex); + for (auto partialPath : m_qsaveResources.keys()) { + if (path.startsWith(partialPath) && m_qsaveResources.value(partialPath, 0) > 0) { + return true; + } + } + return false; +} diff --git a/launcher/Application.h b/launcher/Application.h new file mode 100644 index 0000000..936e13d --- /dev/null +++ b/launcher/Application.h @@ -0,0 +1,325 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2022 Tayou + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "QObjectPtr.h" + +#include "minecraft/auth/MinecraftAccount.h" + +class LaunchController; +class LocalPeer; +class InstanceWindow; +class MainWindow; +class ViewLogWindow; +class SetupWizard; +class GenericPageProvider; +class QFile; +class HttpMetaCache; +class SettingsObject; +class InstanceList; +class AccountList; +class IconList; +class QNetworkAccessManager; +class JavaInstallList; +class ExternalUpdater; +class BaseProfilerFactory; +class BaseDetachedToolFactory; +class TranslationsModel; +class ITheme; +class MCEditTool; +class ThemeManager; +class IconTheme; +class BaseInstance; + +class LogModel; + +struct MinecraftTarget; +class MinecraftAccount; + +namespace Meta { +class Index; +} + +#if defined(APPLICATION) +#undef APPLICATION +#endif +#define APPLICATION (static_cast(QCoreApplication::instance())) + +// Used for checking if is a test +#if defined(APPLICATION_DYN) +#undef APPLICATION_DYN +#endif +#define APPLICATION_DYN (dynamic_cast(QCoreApplication::instance())) + +class Application : public QApplication { + Q_OBJECT + public: + enum Status { StartingUp, Failed, Succeeded, Initialized }; + + enum Capability { + None = 0, + + SupportsMSA = 1 << 0, + SupportsFlame = 1 << 1, + SupportsGameMode = 1 << 2, + SupportsMangoHud = 1 << 3, + }; + Q_DECLARE_FLAGS(Capabilities, Capability) + + public: + Application(int& argc, char** argv); + virtual ~Application(); + + bool event(QEvent* event) override; + + SettingsObject* settings() const { return m_settings.get(); } + + qint64 timeSinceStart() const { return m_startTime.msecsTo(QDateTime::currentDateTime()); } + + QIcon logo(); + + ThemeManager* themeManager() { return m_themeManager.get(); } + + ExternalUpdater* updater() { return m_updater.get(); } + + void triggerUpdateCheck(); + + TranslationsModel* translations(); + + JavaInstallList* javalist(); + + InstanceList* instances() const { return m_instances.get(); } + + IconList* icons() const { return m_icons.get(); } + + MCEditTool* mcedit() const { return m_mcedit.get(); } + + AccountList* accounts() const { return m_accounts.get(); } + + Status status() const { return m_status; } + + const QMap>& profilers() const { return m_profilers; } + + void updateProxySettings(QString proxyTypeStr, QString addr, int port, QString user, QString password); + + QNetworkAccessManager* network(); + + HttpMetaCache* metacache(); + + Meta::Index* metadataIndex(); + + void updateCapabilities(); + + void detectLibraries(); + + /*! + * Finds and returns the full path to a jar file. + * Returns a null-string if it could not be found. + */ + QString getJarPath(QString jarFile); + + QString getMSAClientID(); + QString getFlameAPIKey(); + QString getModrinthAPIToken(); + QString getUserAgent(); + + /// this is the root of the 'installation'. Used for automatic updates + const QString& root() { return m_rootPath; } + + /// the data path the application is using + const QString& dataRoot() { return m_dataPath; } + + /// the java installed path the application is using + const QString javaPath(); + + bool isPortable() { return m_portable; } + + const Capabilities capabilities() { return m_capabilities; } + + /*! + * Opens a json file using either a system default editor, or, if not empty, the editor + * specified in the settings + */ + bool openJsonEditor(const QString& filename); + + InstanceWindow* showInstanceWindow(BaseInstance* instance, QString page = QString()); + MainWindow* showMainWindow(bool minimized = false); + ViewLogWindow* showLogWindow(); + + void updateIsRunning(bool running); + bool updatesAreAllowed(); + + void ShowGlobalSettings(class QWidget* parent, QString open_page = QString()); + + bool updaterEnabled(); + QString updaterBinaryName(); + + QUrl normalizeImportUrl(const QString& url); + + signals: + void updateAllowedChanged(bool status); + void globalSettingsAboutToOpen(); + void globalSettingsApplied(); + int currentCatChanged(int index); + + void oauthReplyRecieved(QVariantMap); + +#ifdef Q_OS_MACOS + void clickedOnDock(); +#endif + + public slots: + bool launch(BaseInstance* instance, + LaunchMode mode = LaunchMode::Normal, + std::shared_ptr targetToJoin = nullptr, + shared_qobject_ptr accountToUse = nullptr, + const QString& offlineName = QString()); + bool kill(BaseInstance* instance); + void closeCurrentWindow(); + + private slots: + void on_windowClose(); + void messageReceived(const QByteArray& message); + void controllerFinished(); + void setupWizardFinished(int status); + + private: + bool handleDataMigration(const QString& currentData, const QString& oldData, const QString& name, const QString& configFile) const; + bool createSetupWizard(); + void performMainStartupAction(); + + // sets the fatal error message and m_status to Failed. + void showFatalErrorMessage(const QString& title, const QString& content); + + private: + void addRunningInstance(); + void subRunningInstance(); + bool shouldExitNow() const; + + private: + QHash m_qsaveResources; + mutable QMutex m_qsaveResourcesMutex; + + private: + QDateTime m_startTime; + + std::unique_ptr m_network; + + std::unique_ptr m_updater; + std::unique_ptr m_accounts; + + std::unique_ptr m_metacache; + std::unique_ptr m_metadataIndex; + + std::unique_ptr m_settings; + std::unique_ptr m_instances; + std::unique_ptr m_icons; + std::unique_ptr m_javalist; + std::unique_ptr m_translations; + std::unique_ptr m_globalSettingsProvider; + std::unique_ptr m_mcedit; + QSet m_features; + std::unique_ptr m_themeManager; + + QMap> m_profilers; + + QString m_rootPath; + QString m_dataPath; + Status m_status = Application::StartingUp; + Capabilities m_capabilities; + bool m_portable = false; + +#ifdef Q_OS_MACOS + Qt::ApplicationState m_prevAppState = Qt::ApplicationInactive; +#endif + + // FIXME: attach to instances instead. + struct InstanceXtras { + InstanceWindow* window = nullptr; + std::unique_ptr controller; + }; + std::map m_instanceExtras; + mutable QMutex m_instanceExtrasMutex; + + // main state variables + size_t m_openWindows = 0; + size_t m_runningInstances = 0; + bool m_updateRunning = false; + + // main window, if any + MainWindow* m_mainWindow = nullptr; + + // log window, if any + ViewLogWindow* m_viewLogWindow = nullptr; + + // peer launcher instance connector - used to implement single instance launcher and signalling + LocalPeer* m_peerInstance = nullptr; + + SetupWizard* m_setupWizard = nullptr; + + public: + QString m_detectedGLFWPath; + QString m_detectedOpenALPath; + QString m_instanceIdToLaunch; + QString m_serverToJoin; + QString m_worldToJoin; + QString m_profileToUse; + bool m_launchOffline = false; + QString m_offlineName; + bool m_liveCheck = false; + QList m_urlsToImport; + QString m_instanceIdToShowWindowOf; + bool m_showMainWindow = false; + std::unique_ptr logFile; + std::unique_ptr logModel; + + public: + void addQSavePath(QString); + void removeQSavePath(QString); + bool checkQSavePath(QString); +}; diff --git a/launcher/ApplicationMessage.cpp b/launcher/ApplicationMessage.cpp new file mode 100644 index 0000000..50ac10c --- /dev/null +++ b/launcher/ApplicationMessage.cpp @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ApplicationMessage.h" + +#include +#include +#include "Json.h" + +void ApplicationMessage::parse(const QByteArray& input) +{ + auto doc = Json::requireDocument(input, "ApplicationMessage"); + auto root = Json::requireObject(doc, "ApplicationMessage"); + + command = root.value("command").toString(); + args.clear(); + + auto parsedArgs = root.value("args").toObject(); + for (auto iter = parsedArgs.constBegin(); iter != parsedArgs.constEnd(); iter++) { + args.insert(iter.key(), iter.value().toString()); + } +} + +QByteArray ApplicationMessage::serialize() +{ + QJsonObject root; + root.insert("command", command); + QJsonObject outArgs; + for (auto iter = args.constBegin(); iter != args.constEnd(); iter++) { + outArgs.insert(iter.key(), iter.value()); + } + root.insert("args", outArgs); + + return Json::toText(root); +} diff --git a/launcher/ApplicationMessage.h b/launcher/ApplicationMessage.h new file mode 100644 index 0000000..7ad6743 --- /dev/null +++ b/launcher/ApplicationMessage.h @@ -0,0 +1,13 @@ +#pragma once + +#include +#include +#include + +struct ApplicationMessage { + QString command; + QHash args; + + QByteArray serialize(); + void parse(const QByteArray& input); +}; diff --git a/launcher/AssertHelpers.h b/launcher/AssertHelpers.h new file mode 100644 index 0000000..0b1cdb7 --- /dev/null +++ b/launcher/AssertHelpers.h @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2025 Octol1ttle + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#if defined(ASSERT_NEVER) +#error ASSERT_NEVER already defined +#else +#define ASSERT_NEVER(cond) (Q_ASSERT((cond) == false), (cond)) +#endif diff --git a/launcher/BaseInstaller.cpp b/launcher/BaseInstaller.cpp new file mode 100644 index 0000000..96a3b5e --- /dev/null +++ b/launcher/BaseInstaller.cpp @@ -0,0 +1,56 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include + +#include "BaseInstaller.h" +#include "FileSystem.h" +#include "minecraft/MinecraftInstance.h" + +BaseInstaller::BaseInstaller() {} + +bool BaseInstaller::isApplied(MinecraftInstance* on) +{ + return QFile::exists(filename(on->instanceRoot())); +} + +bool BaseInstaller::add(MinecraftInstance* to) +{ + if (!patchesDir(to->instanceRoot()).exists()) { + QDir(to->instanceRoot()).mkdir("patches"); + } + + if (isApplied(to)) { + if (!remove(to)) { + return false; + } + } + + return true; +} + +bool BaseInstaller::remove(MinecraftInstance* from) +{ + return FS::deletePath(filename(from->instanceRoot())); +} + +QString BaseInstaller::filename(const QString& root) const +{ + return patchesDir(root).absoluteFilePath(id() + ".json"); +} +QDir BaseInstaller::patchesDir(const QString& root) const +{ + return QDir(root + "/patches/"); +} diff --git a/launcher/BaseInstaller.h b/launcher/BaseInstaller.h new file mode 100644 index 0000000..1cf7d65 --- /dev/null +++ b/launcher/BaseInstaller.h @@ -0,0 +1,44 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +#include "BaseVersion.h" + +class MinecraftInstance; +class QDir; +class QString; +class QObject; +class Task; +class BaseVersion; + +class BaseInstaller { + public: + BaseInstaller(); + virtual ~BaseInstaller() {}; + bool isApplied(MinecraftInstance* on); + + virtual bool add(MinecraftInstance* to); + virtual bool remove(MinecraftInstance* from); + + virtual Task* createInstallTask(MinecraftInstance* instance, BaseVersion::Ptr version, QObject* parent) = 0; + + protected: + virtual QString id() const = 0; + QString filename(const QString& root) const; + QDir patchesDir(const QString& root) const; +}; diff --git a/launcher/BaseInstance.cpp b/launcher/BaseInstance.cpp new file mode 100644 index 0000000..0080cc5 --- /dev/null +++ b/launcher/BaseInstance.cpp @@ -0,0 +1,487 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (c) 2022 Jamie Mansfield + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "BaseInstance.h" + +#include +#include +#include +#include +#include + +#include "Application.h" +#include "Json.h" +#include "launch/LaunchTask.h" +#include "settings/INISettingsObject.h" +#include "settings/OverrideSetting.h" +#include "settings/Setting.h" + +#include "BuildConfig.h" +#include "Commandline.h" +#include "FileSystem.h" + +int getConsoleMaxLines(SettingsObject* settings) +{ + auto lineSetting = settings->getSetting("ConsoleMaxLines"); + bool conversionOk = false; + int maxLines = lineSetting->get().toInt(&conversionOk); + if (!conversionOk) { + maxLines = lineSetting->defValue().toInt(); + qWarning() << "ConsoleMaxLines has nonsensical value, defaulting to" << maxLines; + } + return maxLines; +} + +bool shouldStopOnConsoleOverflow(SettingsObject* settings) +{ + return settings->get("ConsoleOverflowStop").toBool(); +} + +BaseInstance::BaseInstance(SettingsObject* globalSettings, std::unique_ptr settings, const QString& rootDir) : QObject() +{ + m_settings = std::move(settings); + m_global_settings = globalSettings; + m_rootDir = rootDir; + + m_settings->registerSetting("name", "Unnamed Instance"); + m_settings->registerSetting("iconKey", "default"); + m_settings->registerSetting("notes", ""); + + m_settings->registerSetting("lastLaunchTime", 0); + m_settings->registerSetting("totalTimePlayed", 0); + if (m_settings->get("totalTimePlayed").toLongLong() < 0) + m_settings->reset("totalTimePlayed"); + m_settings->registerSetting("lastTimePlayed", 0); + + m_settings->registerSetting("linkedInstances", "[]"); + m_settings->registerSetting("shortcuts", QString()); + + // Game time override + auto gameTimeOverride = m_settings->registerSetting("OverrideGameTime", false); + m_settings->registerOverride(globalSettings->getSetting("ShowGameTime"), gameTimeOverride); + m_settings->registerOverride(globalSettings->getSetting("RecordGameTime"), gameTimeOverride); + + // NOTE: Sometimees InstanceType is already registered, as it was used to identify the type of + // a locally stored instance + if (!m_settings->getSetting("InstanceType")) + m_settings->registerSetting("InstanceType", ""); + + // Custom Commands + auto commandSetting = m_settings->registerSetting({ "OverrideCommands", "OverrideLaunchCmd" }, false); + m_settings->registerOverride(globalSettings->getSetting("PreLaunchCommand"), commandSetting); + m_settings->registerOverride(globalSettings->getSetting("WrapperCommand"), commandSetting); + m_settings->registerOverride(globalSettings->getSetting("PostExitCommand"), commandSetting); + + // Console + auto consoleSetting = m_settings->registerSetting("OverrideConsole", false); + m_settings->registerOverride(globalSettings->getSetting("ShowConsole"), consoleSetting); + m_settings->registerOverride(globalSettings->getSetting("AutoCloseConsole"), consoleSetting); + m_settings->registerOverride(globalSettings->getSetting("ShowConsoleOnError"), consoleSetting); + m_settings->registerOverride(globalSettings->getSetting("LogPrePostOutput"), consoleSetting); + + m_settings->registerPassthrough(globalSettings->getSetting("ConsoleMaxLines"), nullptr); + m_settings->registerPassthrough(globalSettings->getSetting("ConsoleOverflowStop"), nullptr); + + // Managed Packs + m_settings->registerSetting("ManagedPack", false); + m_settings->registerSetting("ManagedPackType", ""); + m_settings->registerSetting("ManagedPackID", ""); + m_settings->registerSetting("ManagedPackName", ""); + m_settings->registerSetting("ManagedPackVersionID", ""); + m_settings->registerSetting("ManagedPackVersionName", ""); + m_settings->registerSetting("ManagedPackURL", ""); + + m_settings->registerSetting("Profiler", ""); +} + +BaseInstance::~BaseInstance() {} + +QString BaseInstance::getPreLaunchCommand() +{ + return settings()->get("PreLaunchCommand").toString(); +} + +QString BaseInstance::getWrapperCommand() +{ + return settings()->get("WrapperCommand").toString(); +} + +QString BaseInstance::getPostExitCommand() +{ + return settings()->get("PostExitCommand").toString(); +} + +bool BaseInstance::isManagedPack() const +{ + return m_settings->get("ManagedPack").toBool(); +} + +QString BaseInstance::getManagedPackType() const +{ + return m_settings->get("ManagedPackType").toString(); +} + +QString BaseInstance::getManagedPackID() const +{ + return m_settings->get("ManagedPackID").toString(); +} + +QString BaseInstance::getManagedPackName() const +{ + return m_settings->get("ManagedPackName").toString(); +} + +QString BaseInstance::getManagedPackVersionID() const +{ + return m_settings->get("ManagedPackVersionID").toString(); +} + +QString BaseInstance::getManagedPackVersionName() const +{ + return m_settings->get("ManagedPackVersionName").toString(); +} + +void BaseInstance::setManagedPack(const QString& type, + const QString& id, + const QString& name, + const QString& versionId, + const QString& version) +{ + m_settings->set("ManagedPack", true); + m_settings->set("ManagedPackType", type); + m_settings->set("ManagedPackID", id); + m_settings->set("ManagedPackName", name); + m_settings->set("ManagedPackVersionID", versionId); + m_settings->set("ManagedPackVersionName", version); +} + +void BaseInstance::copyManagedPack(BaseInstance& other) +{ + m_settings->set("ManagedPack", other.isManagedPack()); + m_settings->set("ManagedPackType", other.getManagedPackType()); + m_settings->set("ManagedPackID", other.getManagedPackID()); + m_settings->set("ManagedPackName", other.getManagedPackName()); + m_settings->set("ManagedPackVersionID", other.getManagedPackVersionID()); + m_settings->set("ManagedPackVersionName", other.getManagedPackVersionName()); + + if (APPLICATION->settings()->get("AutomaticJavaSwitch").toBool() && m_settings->get("AutomaticJava").toBool() && + m_settings->get("OverrideJavaLocation").toBool()) { + m_settings->set("OverrideJavaLocation", false); + m_settings->set("JavaPath", ""); + } +} + +QStringList BaseInstance::getLinkedInstances() const +{ + auto setting = m_settings->get("linkedInstances").toString(); + return Json::toStringList(setting); +} + +void BaseInstance::setLinkedInstances(const QStringList& list) +{ + m_settings->set("linkedInstances", Json::fromStringList(list)); +} + +void BaseInstance::addLinkedInstanceId(const QString& id) +{ + auto linkedInstances = getLinkedInstances(); + linkedInstances.append(id); + setLinkedInstances(linkedInstances); +} + +bool BaseInstance::removeLinkedInstanceId(const QString& id) +{ + auto linkedInstances = getLinkedInstances(); + int numRemoved = linkedInstances.removeAll(id); + setLinkedInstances(linkedInstances); + return numRemoved > 0; +} + +bool BaseInstance::isLinkedToInstanceId(const QString& id) const +{ + auto linkedInstances = getLinkedInstances(); + return linkedInstances.contains(id); +} + +void BaseInstance::iconUpdated(QString key) +{ + if (iconKey() == key) { + emit propertiesChanged(this); + } +} + +void BaseInstance::invalidate() +{ + changeStatus(Status::Gone); + qDebug() << "Instance" << id() << "has been invalidated."; +} + +void BaseInstance::changeStatus(BaseInstance::Status newStatus) +{ + Status status = currentStatus(); + if (status != newStatus) { + m_status = newStatus; + emit statusChanged(status, newStatus); + } +} + +BaseInstance::Status BaseInstance::currentStatus() const +{ + return m_status; +} + +QString BaseInstance::id() const +{ + return QFileInfo(instanceRoot()).fileName(); +} + +bool BaseInstance::isRunning() const +{ + return m_isRunning; +} + +void BaseInstance::setRunning(bool running) +{ + if (running == m_isRunning) + return; + + m_isRunning = running; + + emit runningStatusChanged(running); +} + +void BaseInstance::setMinecraftRunning(bool running) +{ + if (!settings()->get("RecordGameTime").toBool()) { + return; + } + + if (running) { + m_timeStarted = QDateTime::currentDateTime(); + setLastLaunch(m_timeStarted.toMSecsSinceEpoch()); + } else { + QDateTime timeEnded = QDateTime::currentDateTime(); + + qint64 current = settings()->get("totalTimePlayed").toLongLong(); + settings()->set("totalTimePlayed", current + m_timeStarted.secsTo(timeEnded)); + settings()->set("lastTimePlayed", m_timeStarted.secsTo(timeEnded)); + + emit propertiesChanged(this); + } +} + +int64_t BaseInstance::totalTimePlayed() const +{ + qint64 current = m_settings->get("totalTimePlayed").toLongLong(); + if (m_isRunning) { + QDateTime timeNow = QDateTime::currentDateTime(); + return current + m_timeStarted.secsTo(timeNow); + } + return current; +} + +int64_t BaseInstance::lastTimePlayed() const +{ + if (m_isRunning) { + QDateTime timeNow = QDateTime::currentDateTime(); + return m_timeStarted.secsTo(timeNow); + } + return m_settings->get("lastTimePlayed").toLongLong(); +} + +void BaseInstance::resetTimePlayed() +{ + settings()->reset("totalTimePlayed"); + settings()->reset("lastTimePlayed"); +} + +QString BaseInstance::instanceType() const +{ + return m_settings->get("InstanceType").toString(); +} + +QString BaseInstance::instanceRoot() const +{ + return m_rootDir; +} + +SettingsObject* BaseInstance::settings() +{ + loadSpecificSettings(); + + return m_settings.get(); +} + +bool BaseInstance::canLaunch() const +{ + return (!hasVersionBroken() && !isRunning()); +} + +bool BaseInstance::reloadSettings() +{ + return m_settings->reload(); +} + +qint64 BaseInstance::lastLaunch() const +{ + return m_settings->get("lastLaunchTime").value(); +} + +void BaseInstance::setLastLaunch(qint64 val) +{ + // FIXME: if no change, do not set. setting involves saving a file. + m_settings->set("lastLaunchTime", val); + emit propertiesChanged(this); +} + +void BaseInstance::setNotes(QString val) +{ + // FIXME: if no change, do not set. setting involves saving a file. + m_settings->set("notes", val); +} + +QString BaseInstance::notes() const +{ + return m_settings->get("notes").toString(); +} + +void BaseInstance::setIconKey(QString val) +{ + // FIXME: if no change, do not set. setting involves saving a file. + m_settings->set("iconKey", val); + emit propertiesChanged(this); +} + +QString BaseInstance::iconKey() const +{ + return m_settings->get("iconKey").toString(); +} + +void BaseInstance::setName(QString val) +{ + // FIXME: if no change, do not set. setting involves saving a file. + m_settings->set("name", val); + emit propertiesChanged(this); +} + +bool BaseInstance::syncInstanceDirName(const QString& newRoot) const +{ + auto oldRoot = instanceRoot(); + return oldRoot == newRoot || QFile::rename(oldRoot, newRoot); +} + +void BaseInstance::registerShortcut(const ShortcutData& data) +{ + auto currentShortcuts = shortcuts(); + currentShortcuts.append(data); + qDebug() << "Registering shortcut for instance" << id() << "with name" << data.name << "and path" << data.filePath; + setShortcuts(currentShortcuts); +} + +void BaseInstance::setShortcuts(const QList& shortcuts) +{ + // FIXME: if no change, do not set. setting involves saving a file. + QJsonArray array; + for (const auto& elem : shortcuts) { + array.append(QJsonObject{ { "name", elem.name }, { "filePath", elem.filePath }, { "target", static_cast(elem.target) } }); + } + + QJsonDocument document; + document.setArray(array); + m_settings->set("shortcuts", QString::fromUtf8(document.toJson(QJsonDocument::Compact))); +} + +QList BaseInstance::shortcuts() const +{ + auto data = m_settings->get("shortcuts").toString().toUtf8(); + QJsonParseError parseError; + auto document = QJsonDocument::fromJson(data, &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isArray()) + return {}; + + QList results; + for (const auto& elem : document.array()) { + if (!elem.isObject()) + continue; + auto dict = elem.toObject(); + if (!dict.contains("name") || !dict.contains("filePath") || !dict.contains("target")) + continue; + int value = dict["target"].toInt(-1); + if (!dict["name"].isString() || !dict["filePath"].isString() || value < 0 || value >= 3) + continue; + + QString shortcutName = dict["name"].toString(); + QString filePath = dict["filePath"].toString(); + if (!QDir(filePath).exists()) { + qWarning() << "Shortcut" << shortcutName << "for instance" << name() << "have non-existent path" << filePath; + continue; + } + results.append({ shortcutName, filePath, static_cast(value) }); + } + return results; +} + +QString BaseInstance::name() const +{ + return m_settings->get("name").toString(); +} + +QString BaseInstance::windowTitle() const +{ + return BuildConfig.LAUNCHER_DISPLAYNAME + ": " + name(); +} + +// FIXME: why is this here? move it to MinecraftInstance!!! +QStringList BaseInstance::extraArguments() +{ + return Commandline::splitArgs(settings()->get("JvmArgs").toString()); +} + +LaunchTask* BaseInstance::getLaunchTask() +{ + return m_launchProcess.get(); +} + +void BaseInstance::updateRuntimeContext() +{ + // NOOP +} + +bool BaseInstance::isLegacy() +{ + return traits().contains("legacyLaunch") || traits().contains("alphaLaunch"); +} diff --git a/launcher/BaseInstance.h b/launcher/BaseInstance.h new file mode 100644 index 0000000..9280d2e --- /dev/null +++ b/launcher/BaseInstance.h @@ -0,0 +1,329 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (c) 2022 Jamie Mansfield + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once +#include + +#include +#include +#include +#include +#include +#include +#include +#include "QObjectPtr.h" + +#include "settings/SettingsObject.h" + +#include "BaseVersionList.h" +#include "MessageLevel.h" +#include "minecraft/auth/MinecraftAccount.h" +#include "settings/INIFile.h" + +#include "net/Mode.h" + +#include "RuntimeContext.h" +#include "minecraft/launch/MinecraftTarget.h" + +class QDir; +class Task; +class LaunchTask; +class BaseInstance; + +/// Shortcut saving target representations +enum class ShortcutTarget { Desktop, Applications, Other }; + +/// Shortcut data representation +struct ShortcutData { + QString name; + QString filePath; + ShortcutTarget target = ShortcutTarget::Other; +}; + +/// Console settings +int getConsoleMaxLines(SettingsObject* settings); +bool shouldStopOnConsoleOverflow(SettingsObject* settings); + +/*! + * \brief Base class for instances. + * This class implements many functions that are common between instances and + * provides a standard interface for all instances. + * + * To create a new instance type, create a new class inheriting from this class + * and implement the pure virtual functions. + */ +class BaseInstance : public QObject { + Q_OBJECT + protected: + /// no-touchy! + BaseInstance(SettingsObject* globalSettings, std::unique_ptr settings, const QString& rootDir); + + public: /* types */ + enum class Status { + Present, + Gone // either nuked or invalidated + }; + + public: + /// virtual destructor to make sure the destruction is COMPLETE + virtual ~BaseInstance(); + + virtual void saveNow() = 0; + + /*** + * the instance has been invalidated - it is no longer tracked by the launcher for some reason, + * but it has not necessarily been deleted. + * + * Happens when the instance folder changes to some other location, or the instance is removed by external means. + */ + void invalidate(); + + /// The instance's ID. The ID SHALL be determined by LAUNCHER internally. The ID IS guaranteed to + /// be unique. + virtual QString id() const; + + void setMinecraftRunning(bool running); + void setRunning(bool running); + bool isRunning() const; + int64_t totalTimePlayed() const; + int64_t lastTimePlayed() const; + void resetTimePlayed(); + + /// get the type of this instance + QString instanceType() const; + + /// Path to the instance's root directory. + QString instanceRoot() const; + + /// Path to the instance's game root directory. + virtual QString gameRoot() const { return instanceRoot(); } + + /// Path to the instance's mods directory. + virtual QString modsRoot() const = 0; + + QString name() const; + void setName(QString val); + + /// Sync name and rename instance dir accordingly; returns true if successful + bool syncInstanceDirName(const QString& newRoot) const; + + /// Register a created shortcut + void registerShortcut(const ShortcutData& data); + QList shortcuts() const; + void setShortcuts(const QList& shortcuts); + + /// Value used for instance window titles + QString windowTitle() const; + + QString iconKey() const; + void setIconKey(QString val); + + QString notes() const; + void setNotes(QString val); + + QString getPreLaunchCommand(); + QString getPostExitCommand(); + QString getWrapperCommand(); + + bool isManagedPack() const; + QString getManagedPackType() const; + QString getManagedPackID() const; + QString getManagedPackName() const; + QString getManagedPackVersionID() const; + QString getManagedPackVersionName() const; + void setManagedPack(const QString& type, const QString& id, const QString& name, const QString& versionId, const QString& version); + void copyManagedPack(BaseInstance& other); + + virtual QStringList extraArguments(); + + /// Traits. Normally inside the version, depends on instance implementation. + virtual QSet traits() const = 0; + + /** + * Gets the time that the instance was last launched. + * Stored in milliseconds since epoch. + */ + qint64 lastLaunch() const; + /// Sets the last launched time to 'val' milliseconds since epoch + void setLastLaunch(qint64 val = QDateTime::currentMSecsSinceEpoch()); + + /*! + * \brief Gets this instance's settings object. + * This settings object stores instance-specific settings. + * + * Note that this method is not const. + * It may call loadSpecificSettings() to ensure those are loaded. + * + * \return A pointer to this instance's settings object. + */ + virtual SettingsObject* settings(); + + /*! + * \brief Loads settings specific to an instance type if they're not already loaded. + */ + virtual void loadSpecificSettings() = 0; + + /// returns a valid update task + virtual QList createUpdateTask() = 0; + + /// returns a valid launcher (task container) + virtual LaunchTask* createLaunchTask(AuthSessionPtr account, MinecraftTarget::Ptr targetToJoin) = 0; + + /// returns the current launch task (if any) + LaunchTask* getLaunchTask(); + + /*! + * Create envrironment variables for running the instance + */ + virtual QProcessEnvironment createEnvironment() = 0; + virtual QProcessEnvironment createLaunchEnvironment() = 0; + + /*! + * Returns the root folder to use for looking up log files + */ + virtual QStringList getLogFileSearchPaths() = 0; + + virtual QString getStatusbarDescription() = 0; + + /// FIXME: this really should be elsewhere... + virtual QString instanceConfigFolder() const = 0; + + /// get variables this instance exports + virtual QMap getVariables() = 0; + + virtual QString typeName() const = 0; + + virtual void updateRuntimeContext(); + RuntimeContext runtimeContext() const { return m_runtimeContext; } + + bool hasVersionBroken() const { return m_hasBrokenVersion; } + void setVersionBroken(bool value) + { + if (m_hasBrokenVersion != value) { + m_hasBrokenVersion = value; + emit propertiesChanged(this); + } + } + + bool hasUpdateAvailable() const { return m_hasUpdate; } + void setUpdateAvailable(bool value) + { + if (m_hasUpdate != value) { + m_hasUpdate = value; + emit propertiesChanged(this); + } + } + + bool hasCrashed() const { return m_crashed; } + void setCrashed(bool value) + { + if (m_crashed != value) { + m_crashed = value; + emit propertiesChanged(this); + } + } + + virtual bool canLaunch() const; + virtual bool canEdit() const = 0; + virtual bool canExport() const = 0; + + virtual void populateLaunchMenu(QMenu* menu) = 0; + + bool reloadSettings(); + + /** + * 'print' a verbose description of the instance into a QStringList + */ + virtual QStringList verboseDescription(AuthSessionPtr session, MinecraftTarget::Ptr targetToJoin) = 0; + + Status currentStatus() const; + + QStringList getLinkedInstances() const; + void setLinkedInstances(const QStringList& list); + void addLinkedInstanceId(const QString& id); + bool removeLinkedInstanceId(const QString& id); + bool isLinkedToInstanceId(const QString& id) const; + + bool isLegacy(); + + protected: + void changeStatus(Status newStatus); + + SettingsObject* globalSettings() const { return m_global_settings; } + + bool isSpecificSettingsLoaded() const { return m_specific_settings_loaded; } + void setSpecificSettingsLoaded(bool loaded) { m_specific_settings_loaded = loaded; } + + signals: + /*! + * \brief Signal emitted when properties relevant to the instance view change + */ + void propertiesChanged(BaseInstance* inst); + + void launchTaskChanged(LaunchTask*); + + void runningStatusChanged(bool running); + + void profilerChanged(); + + void statusChanged(Status from, Status to); + + protected slots: + void iconUpdated(QString key); + + protected: /* data */ + QString m_rootDir; + std::unique_ptr m_settings; + // InstanceFlags m_flags; + bool m_isRunning = false; + std::unique_ptr m_launchProcess; + QDateTime m_timeStarted; + RuntimeContext m_runtimeContext; + + private: /* data */ + Status m_status = Status::Present; + bool m_crashed = false; + bool m_hasUpdate = false; + bool m_hasBrokenVersion = false; + + SettingsObject* m_global_settings; + bool m_specific_settings_loaded = false; +}; + +Q_DECLARE_METATYPE(shared_qobject_ptr) +// Q_DECLARE_METATYPE(BaseInstance::InstanceFlag) +// Q_DECLARE_OPERATORS_FOR_FLAGS(BaseInstance::InstanceFlags) diff --git a/launcher/BaseVersion.h b/launcher/BaseVersion.h new file mode 100644 index 0000000..e442c60 --- /dev/null +++ b/launcher/BaseVersion.h @@ -0,0 +1,51 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +/*! + * An abstract base class for versions. + */ +class BaseVersion { + public: + // TODO: delete + using Ptr = std::shared_ptr; + virtual ~BaseVersion() {} + /*! + * A string used to identify this version in config files. + * This should be unique within the version list or shenanigans will occur. + */ + virtual QString descriptor() const = 0; + + /*! + * The name of this version as it is displayed to the user. + * For example: "1.5.1" + */ + virtual QString name() const = 0; + + /*! + * This should return a string that describes + * the kind of version this is (Stable, Beta, Snapshot, whatever) + */ + virtual QString typeString() const = 0; + virtual bool operator<(BaseVersion& a) const { return name() < a.name(); } + virtual bool operator>(BaseVersion& a) const { return name() > a.name(); } +}; + +Q_DECLARE_METATYPE(BaseVersion::Ptr) diff --git a/launcher/BaseVersionList.cpp b/launcher/BaseVersionList.cpp new file mode 100644 index 0000000..22077c9 --- /dev/null +++ b/launcher/BaseVersionList.cpp @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "BaseVersionList.h" +#include "BaseVersion.h" + +BaseVersionList::BaseVersionList(QObject* parent) : QAbstractListModel(parent) {} + +BaseVersion::Ptr BaseVersionList::findVersion(const QString& descriptor) +{ + for (int i = 0; i < count(); i++) { + if (at(i)->descriptor() == descriptor) + return at(i); + } + return nullptr; +} + +BaseVersion::Ptr BaseVersionList::getRecommended() const +{ + if (count() <= 0) + return nullptr; + else + return at(0); +} + +QVariant BaseVersionList::data(const QModelIndex& index, int role) const +{ + if (!index.isValid()) + return QVariant(); + + if (index.row() > count()) + return QVariant(); + + BaseVersion::Ptr version = at(index.row()); + + switch (role) { + case VersionPointerRole: + return QVariant::fromValue(version); + + case VersionRole: + return version->name(); + + case VersionIdRole: + return version->descriptor(); + + case TypeRole: + return version->typeString(); + + case JavaMajorRole: { + auto major = version->name(); + if (major.startsWith("java")) { + major = "Java " + major.mid(4); + } + return major; + } + + default: + return QVariant(); + } +} + +BaseVersionList::RoleList BaseVersionList::providesRoles() const +{ + return { VersionPointerRole, VersionRole, VersionIdRole, TypeRole }; +} + +int BaseVersionList::rowCount(const QModelIndex& parent) const +{ + // Return count + return parent.isValid() ? 0 : count(); +} + +int BaseVersionList::columnCount(const QModelIndex& parent) const +{ + return parent.isValid() ? 0 : 1; +} + +QHash BaseVersionList::roleNames() const +{ + QHash roles = QAbstractListModel::roleNames(); + roles.insert(VersionRole, "version"); + roles.insert(VersionIdRole, "versionId"); + roles.insert(ParentVersionRole, "parentGameVersion"); + roles.insert(RecommendedRole, "recommended"); + roles.insert(LatestRole, "latest"); + roles.insert(TypeRole, "type"); + roles.insert(BranchRole, "branch"); + roles.insert(PathRole, "path"); + roles.insert(JavaNameRole, "javaName"); + roles.insert(CPUArchitectureRole, "architecture"); + roles.insert(JavaMajorRole, "javaMajor"); + return roles; +} diff --git a/launcher/BaseVersionList.h b/launcher/BaseVersionList.h new file mode 100644 index 0000000..673d135 --- /dev/null +++ b/launcher/BaseVersionList.h @@ -0,0 +1,120 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +#include "BaseVersion.h" +#include "QObjectPtr.h" +#include "tasks/Task.h" + +/*! + * \brief Class that each instance type's version list derives from. + * Version lists are the lists that keep track of the available game versions + * for that instance. This list will not be loaded on startup. It will be loaded + * when the list's load function is called. Before using the version list, you + * should check to see if it has been loaded yet and if not, load the list. + * + * Note that this class also inherits from QAbstractListModel. Methods from that + * class determine how this version list shows up in a list view. Said methods + * all have a default implementation, but they can be overridden by plugins to + * change the behavior of the list. + */ +class BaseVersionList : public QAbstractListModel { + Q_OBJECT + public: + enum ModelRoles { + VersionPointerRole = Qt::UserRole, + VersionRole, + VersionIdRole, + ParentVersionRole, + RecommendedRole, + LatestRole, + TypeRole, + BranchRole, + PathRole, + JavaNameRole, + JavaMajorRole, + CPUArchitectureRole, + SortRole + }; + using RoleList = QList; + + explicit BaseVersionList(QObject* parent = 0); + + /*! + * \brief Gets a task that will reload the version list. + * Simply execute the task to load the list. + * The task returned by this function should reset the model when it's done. + * \return A pointer to a task that reloads the version list. + */ + virtual Task::Ptr getLoadTask() = 0; + + //! Checks whether or not the list is loaded. If this returns false, the list should be + // loaded. + virtual bool isLoaded() = 0; + + //! Gets the version at the given index. + virtual const BaseVersion::Ptr at(int i) const = 0; + + //! Returns the number of versions in the list. + virtual int count() const = 0; + + //////// List Model Functions //////// + QVariant data(const QModelIndex& index, int role) const override; + int rowCount(const QModelIndex& parent) const override; + int columnCount(const QModelIndex& parent) const override; + QHash roleNames() const override; + + //! which roles are provided by this version list? + virtual RoleList providesRoles() const; + + /*! + * \brief Finds a version by its descriptor. + * \param descriptor The descriptor of the version to find. + * \return A const pointer to the version with the given descriptor. NULL if + * one doesn't exist. + */ + virtual BaseVersion::Ptr findVersion(const QString& descriptor); + + /*! + * \brief Gets the recommended version from this list + * If the list doesn't support recommended versions, this works exactly as getLatestStable + */ + virtual BaseVersion::Ptr getRecommended() const; + + /*! + * Sorts the version list. + */ + virtual void sortVersions() = 0; + + protected slots: + /*! + * Updates this list with the given list of versions. + * This is done by copying each version in the given list and inserting it + * into this one. + * We need to do this so that we can set the parents of the versions are set to this + * version list. This can't be done in the load task, because the versions the load + * task creates are on the load task's thread and Qt won't allow their parents + * to be set to something created on another thread. + * To get around that problem, we invoke this method on the GUI thread, which + * then copies the versions and sets their parents correctly. + * \param versions List of versions whose parents should be set. + */ + virtual void updateListData(QList versions) = 0; +}; diff --git a/launcher/CMakeLists.txt b/launcher/CMakeLists.txt new file mode 100644 index 0000000..cd129db --- /dev/null +++ b/launcher/CMakeLists.txt @@ -0,0 +1,1683 @@ +project(application) + +################################ FILES ################################ + +######## Sources and headers ######## + +set(CORE_SOURCES + # LOGIC - Base classes and infrastructure + BaseInstaller.h + BaseInstaller.cpp + BaseVersionList.h + BaseVersionList.cpp + InstanceList.h + InstanceList.cpp + InstanceTask.h + InstanceTask.cpp + LoggedProcess.h + LoggedProcess.cpp + MessageLevel.cpp + MessageLevel.h + BaseVersion.h + BaseInstance.h + BaseInstance.cpp + InstanceDirUpdate.h + InstanceDirUpdate.cpp + NullInstance.h + MMCZip.h + MMCZip.cpp + archive/ArchiveReader.cpp + archive/ArchiveReader.h + archive/ArchiveWriter.cpp + archive/ArchiveWriter.h + archive/ExportToZipTask.cpp + archive/ExportToZipTask.h + archive/ExtractZipTask.cpp + archive/ExtractZipTask.h + StringUtils.h + StringUtils.cpp + QVariantUtils.h + RuntimeContext.h + PSaveFile.h + + # Basic instance manipulation tasks (derived from InstanceTask) + InstanceCreationTask.h + InstanceCreationTask.cpp + InstanceCopyPrefs.h + InstanceCopyPrefs.cpp + InstanceCopyTask.h + InstanceCopyTask.cpp + InstanceImportTask.h + InstanceImportTask.cpp + + # Resource downloading task + ResourceDownloadTask.h + ResourceDownloadTask.cpp + + # Use tracking separate from memory management + Usable.h + + # Prefix tree where node names are strings between separators + SeparatorPrefixTree.h + + # String filters + Filter.h + + # JSON parsing helpers + Json.h + Json.cpp + + FileSystem.h + FileSystem.cpp + + Exception.h + + # RW lock protected map + RWStorage.h + + # a smart pointer wrapper intended for safer use with Qt signal/slot mechanisms + QObjectPtr.h + + # Compression support + GZip.h + GZip.cpp + + # Command line parameter parsing + Commandline.h + Commandline.cpp + + # Version number string support + Version.h + Version.cpp + + # A Recursive file system watcher + RecursiveFileSystemWatcher.h + RecursiveFileSystemWatcher.cpp + + # Time + MMCTime.h + MMCTime.cpp + + MTPixmapCache.h + + # Assertion helper + AssertHelpers.h +) +if (UNIX AND NOT CYGWIN AND NOT APPLE) + set(CORE_SOURCES + ${CORE_SOURCES} + + # LibraryUtils + LibraryUtils.h + LibraryUtils.cpp + ) +endif() + +set(NET_SOURCES + # network stuffs + net/ByteArraySink.h + net/ChecksumValidator.h + net/Download.cpp + net/Download.h + net/DummySink.h + net/FileSink.cpp + net/FileSink.h + net/HttpMetaCache.cpp + net/HttpMetaCache.h + net/MetaCacheSink.cpp + net/MetaCacheSink.h + net/Logging.h + net/Logging.cpp + net/NetJob.cpp + net/NetJob.h + net/NetUtils.h + net/PasteUpload.cpp + net/PasteUpload.h + net/Sink.h + net/Validator.h + net/Upload.cpp + net/Upload.h + net/HeaderProxy.h + net/RawHeaderProxy.h + net/ApiHeaderProxy.h + net/ApiDownload.h + net/ApiDownload.cpp + net/ApiUpload.cpp + net/ApiUpload.h + net/NetRequest.cpp + net/NetRequest.h +) + +# Game launch logic +set(LAUNCH_SOURCES + launch/steps/CheckJava.cpp + launch/steps/CheckJava.h + launch/steps/LookupServerAddress.cpp + launch/steps/LookupServerAddress.h + launch/steps/PostLaunchCommand.cpp + launch/steps/PostLaunchCommand.h + launch/steps/PreLaunchCommand.cpp + launch/steps/PreLaunchCommand.h + launch/steps/TextPrint.cpp + launch/steps/TextPrint.h + launch/steps/QuitAfterGameStop.cpp + launch/steps/QuitAfterGameStop.h + launch/steps/PrintServers.cpp + launch/steps/PrintServers.h + launch/LaunchStep.cpp + launch/LaunchStep.h + launch/LaunchTask.cpp + launch/LaunchTask.h + launch/LogModel.cpp + launch/LogModel.h + launch/TaskStepWrapper.cpp + launch/TaskStepWrapper.h + logs/LogParser.cpp + logs/LogParser.h +) + +# Old update system +set(UPDATE_SOURCES + updater/ExternalUpdater.h +) + +set(MAC_UPDATE_SOURCES + updater/MacSparkleUpdater.h + updater/MacSparkleUpdater.mm +) + +set(PRISM_UPDATE_SOURCES + updater/PrismExternalUpdater.h + updater/PrismExternalUpdater.cpp +) + +# Backend for the news bar... there's usually no news. +set(NEWS_SOURCES + # News System + news/NewsChecker.h + news/NewsChecker.cpp + news/NewsEntry.h + news/NewsEntry.cpp +) + +# Icon interface +set(ICONS_SOURCES + # Icons System and related code + icons/IconUtils.h + icons/IconUtils.cpp +) + +# Support for Minecraft instances and launch +set(MINECRAFT_SOURCES + + # Logging + minecraft/Logging.h + minecraft/Logging.cpp + + # Minecraft support + minecraft/auth/AccountData.cpp + minecraft/auth/AccountData.h + minecraft/auth/AccountList.cpp + minecraft/auth/AccountList.h + minecraft/auth/AuthSession.cpp + minecraft/auth/AuthSession.h + minecraft/auth/AuthStep.h + minecraft/auth/MinecraftAccount.cpp + minecraft/auth/MinecraftAccount.h + minecraft/auth/Parsers.cpp + minecraft/auth/Parsers.h + + minecraft/auth/AuthFlow.cpp + minecraft/auth/AuthFlow.h + + minecraft/auth/steps/EntitlementsStep.cpp + minecraft/auth/steps/EntitlementsStep.h + minecraft/auth/steps/GetSkinStep.cpp + minecraft/auth/steps/GetSkinStep.h + minecraft/auth/steps/LauncherLoginStep.cpp + minecraft/auth/steps/LauncherLoginStep.h + minecraft/auth/steps/MinecraftProfileStep.cpp + minecraft/auth/steps/MinecraftProfileStep.h + minecraft/auth/steps/MSADeviceCodeStep.cpp + minecraft/auth/steps/MSADeviceCodeStep.h + minecraft/auth/steps/MSAStep.cpp + minecraft/auth/steps/MSAStep.h + minecraft/auth/steps/XboxAuthorizationStep.cpp + minecraft/auth/steps/XboxAuthorizationStep.h + minecraft/auth/steps/XboxUserStep.cpp + minecraft/auth/steps/XboxUserStep.h + + minecraft/update/AssetUpdateTask.h + minecraft/update/AssetUpdateTask.cpp + minecraft/update/LegacyFMLLibrariesTask.cpp + minecraft/update/LegacyFMLLibrariesTask.h + minecraft/update/FoldersTask.cpp + minecraft/update/FoldersTask.h + minecraft/update/LibrariesTask.cpp + minecraft/update/LibrariesTask.h + + minecraft/launch/ClaimAccount.cpp + minecraft/launch/ClaimAccount.h + minecraft/launch/CreateGameFolders.cpp + minecraft/launch/CreateGameFolders.h + minecraft/launch/EnsureAvailableMemory.cpp + minecraft/launch/EnsureAvailableMemory.h + minecraft/launch/EnsureOfflineLibraries.cpp + minecraft/launch/EnsureOfflineLibraries.h + minecraft/launch/ModMinecraftJar.cpp + minecraft/launch/ModMinecraftJar.h + minecraft/launch/ExtractNatives.cpp + minecraft/launch/ExtractNatives.h + minecraft/launch/LauncherPartLaunch.cpp + minecraft/launch/LauncherPartLaunch.h + minecraft/launch/MinecraftTarget.cpp + minecraft/launch/MinecraftTarget.h + minecraft/launch/PrintInstanceInfo.cpp + minecraft/launch/PrintInstanceInfo.h + minecraft/launch/ReconstructAssets.cpp + minecraft/launch/ReconstructAssets.h + minecraft/launch/ScanModFolders.cpp + minecraft/launch/ScanModFolders.h + minecraft/launch/VerifyJavaInstall.cpp + minecraft/launch/VerifyJavaInstall.h + minecraft/launch/AutoInstallJava.cpp + minecraft/launch/AutoInstallJava.h + + minecraft/GradleSpecifier.h + minecraft/MinecraftInstance.cpp + minecraft/MinecraftInstance.h + minecraft/LaunchProfile.cpp + minecraft/LaunchProfile.h + minecraft/Component.cpp + minecraft/Component.h + minecraft/PackProfile.cpp + minecraft/PackProfile.h + minecraft/ComponentUpdateTask.cpp + minecraft/ComponentUpdateTask.h + minecraft/MinecraftLoadAndCheck.h + minecraft/MinecraftLoadAndCheck.cpp + minecraft/MojangVersionFormat.cpp + minecraft/MojangVersionFormat.h + minecraft/Rule.cpp + minecraft/Rule.h + minecraft/OneSixVersionFormat.cpp + minecraft/OneSixVersionFormat.h + minecraft/ParseUtils.cpp + minecraft/ParseUtils.h + minecraft/ProfileUtils.cpp + minecraft/ProfileUtils.h + minecraft/ShortcutUtils.cpp + minecraft/ShortcutUtils.h + minecraft/Library.cpp + minecraft/Library.h + minecraft/MojangDownloadInfo.h + minecraft/VanillaInstanceCreationTask.cpp + minecraft/VanillaInstanceCreationTask.h + minecraft/VersionFile.cpp + minecraft/VersionFile.h + minecraft/VersionFilterData.h + minecraft/VersionFilterData.cpp + minecraft/World.h + minecraft/World.cpp + minecraft/WorldList.h + minecraft/WorldList.cpp + + minecraft/mod/MetadataHandler.h + minecraft/mod/Mod.h + minecraft/mod/Mod.cpp + minecraft/mod/ModDetails.h + minecraft/mod/ModFolderModel.h + minecraft/mod/ModFolderModel.cpp + minecraft/mod/Resource.h + minecraft/mod/Resource.cpp + minecraft/mod/ResourceFolderModel.h + minecraft/mod/ResourceFolderModel.cpp + minecraft/mod/DataPack.h + minecraft/mod/DataPack.cpp + minecraft/mod/DataPackFolderModel.h + minecraft/mod/DataPackFolderModel.cpp + minecraft/mod/ResourcePack.h + minecraft/mod/ResourcePack.cpp + minecraft/mod/ResourcePackFolderModel.h + minecraft/mod/ResourcePackFolderModel.cpp + minecraft/mod/TexturePack.h + minecraft/mod/TexturePack.cpp + minecraft/mod/ShaderPack.h + minecraft/mod/ShaderPack.cpp + minecraft/mod/WorldSave.h + minecraft/mod/WorldSave.cpp + minecraft/mod/TexturePackFolderModel.h + minecraft/mod/TexturePackFolderModel.cpp + minecraft/mod/ShaderPackFolderModel.h + minecraft/mod/ShaderPackFolderModel.cpp + minecraft/mod/tasks/ResourceFolderLoadTask.h + minecraft/mod/tasks/ResourceFolderLoadTask.cpp + minecraft/mod/tasks/LocalModParseTask.h + minecraft/mod/tasks/LocalModParseTask.cpp + minecraft/mod/tasks/LocalResourceUpdateTask.h + minecraft/mod/tasks/LocalResourceUpdateTask.cpp + minecraft/mod/tasks/LocalDataPackParseTask.h + minecraft/mod/tasks/LocalDataPackParseTask.cpp + minecraft/mod/tasks/LocalTexturePackParseTask.h + minecraft/mod/tasks/LocalTexturePackParseTask.cpp + minecraft/mod/tasks/LocalShaderPackParseTask.h + minecraft/mod/tasks/LocalShaderPackParseTask.cpp + minecraft/mod/tasks/LocalWorldSaveParseTask.h + minecraft/mod/tasks/LocalWorldSaveParseTask.cpp + minecraft/mod/tasks/LocalResourceParse.h + minecraft/mod/tasks/LocalResourceParse.cpp + minecraft/mod/tasks/GetModDependenciesTask.h + minecraft/mod/tasks/GetModDependenciesTask.cpp + + # Assets + minecraft/AssetsUtils.h + minecraft/AssetsUtils.cpp + + # Minecraft skins + minecraft/skins/CapeChange.cpp + minecraft/skins/CapeChange.h + minecraft/skins/SkinUpload.cpp + minecraft/skins/SkinUpload.h + minecraft/skins/SkinDelete.cpp + minecraft/skins/SkinDelete.h + minecraft/skins/SkinModel.cpp + minecraft/skins/SkinModel.h + minecraft/skins/SkinList.cpp + minecraft/skins/SkinList.h + + minecraft/Agent.h) + +# the screenshots feature +set(SCREENSHOTS_SOURCES + screenshots/Screenshot.h + screenshots/ImgurUpload.h + screenshots/ImgurUpload.cpp + screenshots/ImgurAlbumCreation.h + screenshots/ImgurAlbumCreation.cpp +) + +set(TASKS_SOURCES + # Tasks + tasks/Task.h + tasks/Task.cpp + tasks/ConcurrentTask.h + tasks/ConcurrentTask.cpp + tasks/SequentialTask.h + tasks/SequentialTask.cpp + tasks/MultipleOptionsTask.h + tasks/MultipleOptionsTask.cpp +) + +set(SETTINGS_SOURCES + # Settings + settings/INIFile.cpp + settings/INIFile.h + settings/INISettingsObject.cpp + settings/INISettingsObject.h + settings/OverrideSetting.cpp + settings/OverrideSetting.h + settings/PassthroughSetting.cpp + settings/PassthroughSetting.h + settings/Setting.cpp + settings/Setting.h + settings/SettingsObject.cpp + settings/SettingsObject.h +) + +set(JAVA_SOURCES + java/JavaChecker.h + java/JavaChecker.cpp + java/JavaInstall.h + java/JavaInstall.cpp + java/JavaInstallList.h + java/JavaInstallList.cpp + java/JavaUtils.h + java/JavaUtils.cpp + java/JavaVersion.h + java/JavaVersion.cpp + + java/JavaMetadata.h + java/JavaMetadata.cpp + java/download/ArchiveDownloadTask.cpp + java/download/ArchiveDownloadTask.h + java/download/ManifestDownloadTask.cpp + java/download/ManifestDownloadTask.h + java/download/SymlinkTask.cpp + java/download/SymlinkTask.h + + ui/java/InstallJavaDialog.h + ui/java/InstallJavaDialog.cpp + ui/java/VersionList.h + ui/java/VersionList.cpp +) + +set(TRANSLATIONS_SOURCES + translations/TranslationsModel.h + translations/TranslationsModel.cpp + translations/POTranslator.h + translations/POTranslator.cpp +) + +set(TOOLS_SOURCES + # Tools + tools/BaseExternalTool.cpp + tools/BaseExternalTool.h + tools/BaseProfiler.cpp + tools/BaseProfiler.h + tools/JProfiler.cpp + tools/JProfiler.h + tools/JVisualVM.cpp + tools/JVisualVM.h + tools/MCEditTool.cpp + tools/MCEditTool.h + tools/GenericProfiler.cpp + tools/GenericProfiler.h +) + +set(META_SOURCES + # Metadata sources + meta/JsonFormat.cpp + meta/JsonFormat.h + meta/BaseEntity.cpp + meta/BaseEntity.h + meta/VersionList.cpp + meta/VersionList.h + meta/Version.cpp + meta/Version.h + meta/Index.cpp + meta/Index.h +) + +set(API_SOURCES + modplatform/ModIndex.h + modplatform/ModIndex.cpp + modplatform/ResourceType.h + modplatform/ResourceType.cpp + + modplatform/ResourceAPI.h + modplatform/ResourceAPI.cpp + + modplatform/EnsureMetadataTask.h + modplatform/EnsureMetadataTask.cpp + + modplatform/CheckUpdateTask.h + + modplatform/flame/FlameAPI.h + modplatform/flame/FlameAPI.cpp + modplatform/modrinth/ModrinthAPI.h + modplatform/modrinth/ModrinthAPI.cpp + modplatform/helpers/HashUtils.h + modplatform/helpers/HashUtils.cpp + modplatform/helpers/OverrideUtils.h + modplatform/helpers/OverrideUtils.cpp + + modplatform/helpers/ExportToModList.h + modplatform/helpers/ExportToModList.cpp +) + +set(FTB_SOURCES + modplatform/legacy_ftb/PackFetchTask.h + modplatform/legacy_ftb/PackFetchTask.cpp + modplatform/legacy_ftb/PackInstallTask.h + modplatform/legacy_ftb/PackInstallTask.cpp + modplatform/legacy_ftb/PrivatePackManager.h + modplatform/legacy_ftb/PrivatePackManager.cpp + + modplatform/legacy_ftb/PackHelpers.h + + modplatform/import_ftb/PackInstallTask.h + modplatform/import_ftb/PackInstallTask.cpp + modplatform/import_ftb/PackHelpers.h + modplatform/import_ftb/PackHelpers.cpp + + modplatform/ftb/FTBPackInstallTask.h + modplatform/ftb/FTBPackInstallTask.cpp + modplatform/ftb/FTBPackManifest.h + modplatform/ftb/FTBPackManifest.cpp +) + +set(FLAME_SOURCES + # Flame + modplatform/flame/FlameModIndex.cpp + modplatform/flame/FlameModIndex.h + modplatform/flame/PackManifest.h + modplatform/flame/PackManifest.cpp + modplatform/flame/FileResolvingTask.h + modplatform/flame/FileResolvingTask.cpp + modplatform/flame/FlameCheckUpdate.cpp + modplatform/flame/FlameCheckUpdate.h + modplatform/flame/FlameInstanceCreationTask.h + modplatform/flame/FlameInstanceCreationTask.cpp + modplatform/flame/FlamePackExportTask.h + modplatform/flame/FlamePackExportTask.cpp +) + +set(MODRINTH_SOURCES + modplatform/modrinth/ModrinthPackIndex.cpp + modplatform/modrinth/ModrinthPackIndex.h + modplatform/modrinth/ModrinthCheckUpdate.cpp + modplatform/modrinth/ModrinthCheckUpdate.h + modplatform/modrinth/ModrinthInstanceCreationTask.cpp + modplatform/modrinth/ModrinthInstanceCreationTask.h + modplatform/modrinth/ModrinthPackExportTask.cpp + modplatform/modrinth/ModrinthPackExportTask.h +) + +set(PACKWIZ_SOURCES + modplatform/packwiz/Packwiz.h + modplatform/packwiz/Packwiz.cpp +) + + +set(TECHNIC_SOURCES + modplatform/technic/SingleZipPackInstallTask.h + modplatform/technic/SingleZipPackInstallTask.cpp + modplatform/technic/SolderPackInstallTask.h + modplatform/technic/SolderPackInstallTask.cpp + modplatform/technic/SolderPackManifest.h + modplatform/technic/SolderPackManifest.cpp + modplatform/technic/TechnicPackProcessor.h + modplatform/technic/TechnicPackProcessor.cpp +) + +set(ATLAUNCHER_SOURCES + modplatform/atlauncher/ATLPackIndex.cpp + modplatform/atlauncher/ATLPackIndex.h + modplatform/atlauncher/ATLPackInstallTask.cpp + modplatform/atlauncher/ATLPackInstallTask.h + modplatform/atlauncher/ATLPackManifest.cpp + modplatform/atlauncher/ATLPackManifest.h + modplatform/atlauncher/ATLShareCode.cpp + modplatform/atlauncher/ATLShareCode.h +) + +set(LINKEXE_SOURCES + console/WindowsConsole.h + console/WindowsConsole.cpp + + filelink/FileLink.h + filelink/FileLink.cpp + FileSystem.h + FileSystem.cpp + Exception.h + StringUtils.h + StringUtils.cpp + DesktopServices.h + DesktopServices.cpp +) + +set(PRISMUPDATER_SOURCES + updater/prismupdater/PrismUpdater.h + updater/prismupdater/PrismUpdater.cpp + updater/prismupdater/UpdaterDialogs.h + updater/prismupdater/UpdaterDialogs.cpp + updater/prismupdater/GitHubRelease.h + updater/prismupdater/GitHubRelease.cpp + + Json.h + Json.cpp + FileSystem.h + FileSystem.cpp + StringUtils.h + StringUtils.cpp + DesktopServices.h + DesktopServices.cpp + Version.h + Version.cpp + Markdown.h + Markdown.cpp + + # Zip + MMCZip.h + MMCZip.cpp + archive/ArchiveReader.cpp + archive/ArchiveReader.h + archive/ArchiveWriter.cpp + archive/ArchiveWriter.h + + # Time + MMCTime.h + MMCTime.cpp + + net/ByteArraySink.h + net/ChecksumValidator.h + net/Download.cpp + net/Download.h + net/FileSink.cpp + net/FileSink.h + net/HttpMetaCache.cpp + net/HttpMetaCache.h + net/Logging.h + net/Logging.cpp + net/NetRequest.cpp + net/NetRequest.h + net/NetJob.cpp + net/NetJob.h + net/NetUtils.h + net/Sink.h + net/Validator.h + net/HeaderProxy.h + net/RawHeaderProxy.h + + ui/dialogs/ProgressDialog.cpp + ui/dialogs/ProgressDialog.h + ui/widgets/SubTaskProgressBar.h + ui/widgets/SubTaskProgressBar.cpp + +) + +if(WIN32) + set(PRISMUPDATER_SOURCES + console/WindowsConsole.h + console/WindowsConsole.cpp + ${PRISMUPDATER_SOURCES} + ) +endif() + +######## Logging categories ######## + +ecm_qt_declare_logging_category(CORE_SOURCES + HEADER Logging.h + IDENTIFIER authCredentials + CATEGORY_NAME "launcher.auth.credentials" + DEFAULT_SEVERITY Warning + DESCRIPTION "Secrets and credentials for debugging purposes" + EXPORT "${Launcher_Name}" +) + +ecm_qt_export_logging_category( + IDENTIFIER instanceProfileC + CATEGORY_NAME "launcher.instance.profile" + DEFAULT_SEVERITY Debug + DESCRIPTION "Profile actions" + EXPORT "${Launcher_Name}" +) + +ecm_qt_export_logging_category( + IDENTIFIER instanceProfileResolveC + CATEGORY_NAME "launcher.instance.profile.resolve" + DEFAULT_SEVERITY Debug + DESCRIPTION "Profile component resolusion actions" + EXPORT "${Launcher_Name}" +) + +ecm_qt_export_logging_category( + IDENTIFIER taskLogC + CATEGORY_NAME "launcher.task" + DEFAULT_SEVERITY Debug + DESCRIPTION "Task actions" + EXPORT "${Launcher_Name}" +) + +ecm_qt_export_logging_category( + IDENTIFIER taskNetLogC + CATEGORY_NAME "launcher.task.net" + DEFAULT_SEVERITY Debug + DESCRIPTION "Task network action" + EXPORT "${Launcher_Name}" +) + +ecm_qt_export_logging_category( + IDENTIFIER taskDownloadLogC + CATEGORY_NAME "launcher.task.net.download" + DEFAULT_SEVERITY Debug + DESCRIPTION "Task network download actions" + EXPORT "${Launcher_Name}" +) +ecm_qt_export_logging_category( + IDENTIFIER taskUploadLogC + CATEGORY_NAME "launcher.task.net.upload" + DEFAULT_SEVERITY Debug + DESCRIPTION "Task network upload actions" + EXPORT "${Launcher_Name}" +) + +ecm_qt_export_logging_category( + IDENTIFIER taskMetaCacheLogC + CATEGORY_NAME "launcher.task.net.metacache" + DEFAULT_SEVERITY Debug + DESCRIPTION "task network meta-cache actions" + EXPORT "${Launcher_Name}" +) + +ecm_qt_export_logging_category( + IDENTIFIER taskHttpMetaCacheLogC + CATEGORY_NAME "launcher.task.net.metacache.http" + DEFAULT_SEVERITY Debug + DESCRIPTION "task network http meta-cache actions" + EXPORT "${Launcher_Name}" +) + + + +if(KDE_INSTALL_LOGGINGCATEGORIESDIR) # only install if there is a standard path for this + ecm_qt_install_logging_categories( + EXPORT "${Launcher_Name}" + DESTINATION "${KDE_INSTALL_LOGGINGCATEGORIESDIR}" + ) +endif() + +################################ COMPILE ################################ + +set(LOGIC_SOURCES + ${CORE_SOURCES} + ${NET_SOURCES} + ${LAUNCH_SOURCES} + ${UPDATE_SOURCES} + ${NEWS_SOURCES} + ${MINECRAFT_SOURCES} + ${SCREENSHOTS_SOURCES} + ${TASKS_SOURCES} + ${SETTINGS_SOURCES} + ${JAVA_SOURCES} + ${TRANSLATIONS_SOURCES} + ${TOOLS_SOURCES} + ${META_SOURCES} + ${ICONS_SOURCES} + ${API_SOURCES} + ${FTB_SOURCES} + ${FLAME_SOURCES} + ${MODRINTH_SOURCES} + ${PACKWIZ_SOURCES} + ${TECHNIC_SOURCES} + ${ATLAUNCHER_SOURCES} +) + +if(APPLE AND Launcher_ENABLE_UPDATER) + set (LOGIC_SOURCES ${LOGIC_SOURCES} ${MAC_UPDATE_SOURCES}) +else() + set (LOGIC_SOURCES ${LOGIC_SOURCES} ${PRISM_UPDATE_SOURCES}) +endif() + +SET(LAUNCHER_SOURCES + # Application base + Application.h + Application.cpp + DataMigrationTask.h + DataMigrationTask.cpp + ApplicationMessage.h + ApplicationMessage.cpp + SysInfo.h + SysInfo.cpp + HardwareInfo.cpp + HardwareInfo.h + + # console utils + console/Console.h + + # GUI - general utilities + DesktopServices.h + DesktopServices.cpp + VersionProxyModel.h + VersionProxyModel.cpp + Markdown.h + Markdown.cpp + + # Super secret! + KonamiCode.h + KonamiCode.cpp + + # Icons + icons/MMCIcon.h + icons/MMCIcon.cpp + icons/IconList.h + icons/IconList.cpp + + # log utils + logs/AnonymizeLog.cpp + logs/AnonymizeLog.h + + # GUI - windows + ui/GuiUtil.h + ui/GuiUtil.cpp + ui/MainWindow.h + ui/MainWindow.cpp + ui/InstanceWindow.h + ui/InstanceWindow.cpp + ui/ViewLogWindow.h + ui/ViewLogWindow.cpp + ui/ToolTipFilter.h + ui/ToolTipFilter.cpp + + # FIXME: maybe find a better home for this. + FileIgnoreProxy.cpp + FileIgnoreProxy.h + FastFileIconProvider.cpp + FastFileIconProvider.h + + # GUI - setup wizard + ui/setupwizard/SetupWizard.h + ui/setupwizard/SetupWizard.cpp + ui/setupwizard/BaseWizardPage.h + ui/setupwizard/JavaWizardPage.cpp + ui/setupwizard/JavaWizardPage.h + ui/setupwizard/LanguageWizardPage.cpp + ui/setupwizard/LanguageWizardPage.h + ui/setupwizard/PasteWizardPage.cpp + ui/setupwizard/PasteWizardPage.h + ui/setupwizard/ThemeWizardPage.h + ui/setupwizard/AutoJavaWizardPage.cpp + ui/setupwizard/AutoJavaWizardPage.h + ui/setupwizard/LoginWizardPage.cpp + ui/setupwizard/LoginWizardPage.h + + # GUI - themes + ui/themes/FusionTheme.cpp + ui/themes/FusionTheme.h + ui/themes/BrightTheme.cpp + ui/themes/BrightTheme.h + ui/themes/CustomTheme.cpp + ui/themes/CustomTheme.h + ui/themes/DarkTheme.cpp + ui/themes/DarkTheme.h + ui/themes/ITheme.cpp + ui/themes/ITheme.h + ui/themes/HintOverrideProxyStyle.cpp + ui/themes/HintOverrideProxyStyle.h + ui/themes/SystemTheme.cpp + ui/themes/SystemTheme.h + ui/themes/IconTheme.cpp + ui/themes/IconTheme.h + ui/themes/ThemeManager.cpp + ui/themes/ThemeManager.h + ui/themes/CatPack.cpp + ui/themes/CatPack.h + ui/themes/CatPainter.cpp + ui/themes/CatPainter.h + + # Processes + LaunchMode.h + LaunchController.h + LaunchController.cpp + + # page provider for instances + InstancePageProvider.h + + # Common java checking UI + JavaCommon.h + JavaCommon.cpp + + # GUI - paged dialog base + ui/pages/BasePage.h + ui/pages/BasePageContainer.h + ui/pages/BasePageProvider.h + + # GUI - instance pages + ui/pages/instance/ExternalResourcesPage.cpp + ui/pages/instance/ExternalResourcesPage.h + ui/pages/instance/VersionPage.cpp + ui/pages/instance/VersionPage.h + ui/pages/instance/ManagedPackPage.cpp + ui/pages/instance/ManagedPackPage.h + ui/pages/instance/DataPackPage.h + ui/pages/instance/DataPackPage.cpp + ui/pages/instance/TexturePackPage.h + ui/pages/instance/TexturePackPage.cpp + ui/pages/instance/ResourcePackPage.h + ui/pages/instance/ResourcePackPage.cpp + ui/pages/instance/ShaderPackPage.h + ui/pages/instance/ShaderPackPage.cpp + ui/pages/instance/ModFolderPage.cpp + ui/pages/instance/ModFolderPage.h + ui/pages/instance/NotesPage.cpp + ui/pages/instance/NotesPage.h + ui/pages/instance/LogPage.cpp + ui/pages/instance/LogPage.h + ui/pages/instance/InstanceSettingsPage.h + ui/pages/instance/ScreenshotsPage.cpp + ui/pages/instance/ScreenshotsPage.h + ui/pages/instance/OtherLogsPage.cpp + ui/pages/instance/OtherLogsPage.h + ui/pages/instance/ServersPage.cpp + ui/pages/instance/ServersPage.h + ui/pages/instance/WorldListPage.cpp + ui/pages/instance/WorldListPage.h + ui/pages/instance/McClient.cpp + ui/pages/instance/McClient.h + ui/pages/instance/McResolver.cpp + ui/pages/instance/McResolver.h + ui/pages/instance/ServerPingTask.cpp + ui/pages/instance/ServerPingTask.h + + # GUI - global settings pages + ui/pages/global/AccountListPage.cpp + ui/pages/global/AccountListPage.h + ui/pages/global/ExternalToolsPage.cpp + ui/pages/global/ExternalToolsPage.h + ui/pages/global/JavaPage.cpp + ui/pages/global/JavaPage.h + ui/pages/global/LanguagePage.cpp + ui/pages/global/LanguagePage.h + ui/pages/global/MinecraftPage.h + ui/pages/global/LauncherPage.cpp + ui/pages/global/LauncherPage.h + ui/pages/global/AppearancePage.h + ui/pages/global/ProxyPage.cpp + ui/pages/global/ProxyPage.h + ui/pages/global/APIPage.cpp + ui/pages/global/APIPage.h + + # GUI - platform pages + ui/pages/modplatform/CustomPage.cpp + ui/pages/modplatform/CustomPage.h + + ui/pages/modplatform/ResourcePage.cpp + ui/pages/modplatform/ResourcePage.h + ui/pages/modplatform/ResourceModel.cpp + ui/pages/modplatform/ResourceModel.h + + ui/pages/modplatform/ModPage.cpp + ui/pages/modplatform/ModPage.h + ui/pages/modplatform/ModModel.cpp + ui/pages/modplatform/ModModel.h + + ui/pages/modplatform/ResourcePackPage.cpp + ui/pages/modplatform/ResourcePackModel.cpp + + # Needed for MOC to find them without a corresponding .cpp + ui/pages/modplatform/TexturePackPage.h + ui/pages/modplatform/TexturePackModel.cpp + + ui/pages/modplatform/ShaderPackPage.cpp + ui/pages/modplatform/ShaderPackModel.cpp + + ui/pages/modplatform/DataPackPage.cpp + ui/pages/modplatform/DataPackModel.cpp + + ui/pages/modplatform/ModpackProviderBasePage.h + + ui/pages/modplatform/atlauncher/AtlFilterModel.cpp + ui/pages/modplatform/atlauncher/AtlFilterModel.h + ui/pages/modplatform/atlauncher/AtlListModel.cpp + ui/pages/modplatform/atlauncher/AtlListModel.h + ui/pages/modplatform/atlauncher/AtlOptionalModDialog.cpp + ui/pages/modplatform/atlauncher/AtlOptionalModDialog.h + ui/pages/modplatform/atlauncher/AtlPage.cpp + ui/pages/modplatform/atlauncher/AtlPage.h + ui/pages/modplatform/atlauncher/AtlUserInteractionSupportImpl.cpp + ui/pages/modplatform/atlauncher/AtlUserInteractionSupportImpl.h + + ui/pages/modplatform/ftb/FtbFilterModel.cpp + ui/pages/modplatform/ftb/FtbFilterModel.h + ui/pages/modplatform/ftb/FtbListModel.cpp + ui/pages/modplatform/ftb/FtbListModel.h + ui/pages/modplatform/ftb/FtbPage.cpp + ui/pages/modplatform/ftb/FtbPage.h + + ui/pages/modplatform/legacy_ftb/Page.cpp + ui/pages/modplatform/legacy_ftb/Page.h + ui/pages/modplatform/legacy_ftb/ListModel.h + ui/pages/modplatform/legacy_ftb/ListModel.cpp + + ui/pages/modplatform/import_ftb/ImportFTBPage.cpp + ui/pages/modplatform/import_ftb/ImportFTBPage.h + ui/pages/modplatform/import_ftb/ListModel.h + ui/pages/modplatform/import_ftb/ListModel.cpp + + ui/pages/modplatform/flame/FlameModel.cpp + ui/pages/modplatform/flame/FlameModel.h + ui/pages/modplatform/flame/FlamePage.cpp + ui/pages/modplatform/flame/FlamePage.h + ui/pages/modplatform/flame/FlameResourceModels.cpp + ui/pages/modplatform/flame/FlameResourceModels.h + ui/pages/modplatform/flame/FlameResourcePages.cpp + ui/pages/modplatform/flame/FlameResourcePages.h + + ui/pages/modplatform/modrinth/ModrinthPage.cpp + ui/pages/modplatform/modrinth/ModrinthPage.h + ui/pages/modplatform/modrinth/ModrinthModel.cpp + ui/pages/modplatform/modrinth/ModrinthModel.h + + ui/pages/modplatform/technic/TechnicModel.cpp + ui/pages/modplatform/technic/TechnicModel.h + ui/pages/modplatform/technic/TechnicPage.cpp + ui/pages/modplatform/technic/TechnicPage.h + + ui/pages/modplatform/ImportPage.cpp + ui/pages/modplatform/ImportPage.h + + ui/pages/modplatform/OptionalModDialog.cpp + ui/pages/modplatform/OptionalModDialog.h + + ui/pages/modplatform/modrinth/ModrinthResourcePages.cpp + ui/pages/modplatform/modrinth/ModrinthResourcePages.h + + # GUI - dialogs + ui/dialogs/AboutDialog.cpp + ui/dialogs/AboutDialog.h + ui/dialogs/ProfileSelectDialog.cpp + ui/dialogs/ProfileSelectDialog.h + ui/dialogs/ProfileSetupDialog.cpp + ui/dialogs/ProfileSetupDialog.h + ui/dialogs/CopyInstanceDialog.cpp + ui/dialogs/CopyInstanceDialog.h + ui/dialogs/CreateShortcutDialog.cpp + ui/dialogs/CreateShortcutDialog.h + ui/dialogs/CustomMessageBox.cpp + ui/dialogs/CustomMessageBox.h + ui/dialogs/ExportInstanceDialog.cpp + ui/dialogs/ExportInstanceDialog.h + ui/dialogs/ExportPackDialog.cpp + ui/dialogs/ExportPackDialog.h + ui/dialogs/ExportToModListDialog.cpp + ui/dialogs/ExportToModListDialog.h + ui/dialogs/IconPickerDialog.cpp + ui/dialogs/IconPickerDialog.h + ui/dialogs/ImportResourceDialog.cpp + ui/dialogs/ImportResourceDialog.h + ui/dialogs/MSALoginDialog.cpp + ui/dialogs/MSALoginDialog.h + ui/dialogs/NetworkJobFailedDialog.cpp + ui/dialogs/NetworkJobFailedDialog.h + ui/dialogs/NewComponentDialog.cpp + ui/dialogs/NewComponentDialog.h + ui/dialogs/NewInstanceDialog.cpp + ui/dialogs/NewInstanceDialog.h + ui/dialogs/NewsDialog.cpp + ui/dialogs/NewsDialog.h + ui/pagedialog/PageDialog.cpp + ui/pagedialog/PageDialog.h + ui/dialogs/ProgressDialog.cpp + ui/dialogs/ProgressDialog.h + ui/dialogs/ReviewMessageBox.cpp + ui/dialogs/ReviewMessageBox.h + ui/dialogs/VersionSelectDialog.cpp + ui/dialogs/VersionSelectDialog.h + ui/dialogs/ResourceDownloadDialog.cpp + ui/dialogs/ResourceDownloadDialog.h + ui/dialogs/ScrollMessageBox.cpp + ui/dialogs/ScrollMessageBox.h + ui/dialogs/BlockedModsDialog.cpp + ui/dialogs/BlockedModsDialog.h + ui/dialogs/ChooseProviderDialog.h + ui/dialogs/ChooseProviderDialog.cpp + ui/dialogs/ResourceUpdateDialog.cpp + ui/dialogs/ResourceUpdateDialog.h + ui/dialogs/InstallLoaderDialog.cpp + ui/dialogs/InstallLoaderDialog.h + ui/dialogs/ChooseOfflineNameDialog.cpp + ui/dialogs/ChooseOfflineNameDialog.h + + ui/dialogs/skins/SkinManageDialog.cpp + ui/dialogs/skins/SkinManageDialog.h + + ui/dialogs/skins/draw/SkinOpenGLWindow.h + ui/dialogs/skins/draw/SkinOpenGLWindow.cpp + ui/dialogs/skins/draw/Scene.h + ui/dialogs/skins/draw/Scene.cpp + ui/dialogs/skins/draw/BoxGeometry.h + ui/dialogs/skins/draw/BoxGeometry.cpp + + # GUI - widgets + ui/widgets/CheckComboBox.cpp + ui/widgets/CheckComboBox.h + ui/widgets/Common.cpp + ui/widgets/Common.h + ui/widgets/CustomCommands.cpp + ui/widgets/CustomCommands.h + ui/widgets/EnvironmentVariables.cpp + ui/widgets/EnvironmentVariables.h + ui/widgets/IconLabel.cpp + ui/widgets/IconLabel.h + ui/widgets/JavaWizardWidget.cpp + ui/widgets/JavaWizardWidget.h + ui/widgets/LabeledToolButton.cpp + ui/widgets/LabeledToolButton.h + ui/widgets/LanguageSelectionWidget.cpp + ui/widgets/LanguageSelectionWidget.h + ui/widgets/LogView.cpp + ui/widgets/LogView.h + ui/widgets/InfoFrame.cpp + ui/widgets/InfoFrame.h + ui/widgets/ModFilterWidget.cpp + ui/widgets/ModFilterWidget.h + ui/widgets/ModListView.cpp + ui/widgets/ModListView.h + ui/widgets/PageContainer.cpp + ui/widgets/PageContainer.h + ui/widgets/PageContainer_p.h + ui/widgets/ProjectDescriptionPage.h + ui/widgets/ProjectDescriptionPage.cpp + ui/widgets/VariableSizedImageObject.h + ui/widgets/VariableSizedImageObject.cpp + ui/widgets/ProjectItem.h + ui/widgets/ProjectItem.cpp + ui/widgets/SubTaskProgressBar.h + ui/widgets/SubTaskProgressBar.cpp + ui/widgets/VersionListView.cpp + ui/widgets/VersionListView.h + ui/widgets/VersionSelectWidget.cpp + ui/widgets/VersionSelectWidget.h + ui/widgets/ProgressWidget.h + ui/widgets/ProgressWidget.cpp + ui/widgets/WideBar.h + ui/widgets/WideBar.cpp + ui/widgets/AppearanceWidget.h + ui/widgets/AppearanceWidget.cpp + ui/widgets/MinecraftSettingsWidget.h + ui/widgets/MinecraftSettingsWidget.cpp + ui/widgets/JavaSettingsWidget.h + ui/widgets/JavaSettingsWidget.cpp + + # GUI - instance group view + ui/instanceview/InstanceProxyModel.cpp + ui/instanceview/InstanceProxyModel.h + ui/instanceview/AccessibleInstanceView.cpp + ui/instanceview/AccessibleInstanceView.h + ui/instanceview/AccessibleInstanceView_p.h + ui/instanceview/InstanceView.cpp + ui/instanceview/InstanceView.h + ui/instanceview/InstanceDelegate.cpp + ui/instanceview/InstanceDelegate.h + ui/instanceview/VisualGroup.cpp + ui/instanceview/VisualGroup.h +) + +if (APPLE) + set(LAUNCHER_SOURCES + ${LAUNCHER_SOURCES} + ui/themes/ThemeManager.mm + ) +endif() + +if (NOT Apple) + set(LAUNCHER_SOURCES + ${LAUNCHER_SOURCES} + + ui/dialogs/UpdateAvailableDialog.h + ui/dialogs/UpdateAvailableDialog.cpp +) +endif() + +if (APPLE) + set(LAUNCHER_SOURCES + ${LAUNCHER_SOURCES} + + macsandbox/SecurityBookmarkFileAccess.h + macsandbox/SecurityBookmarkFileAccess.mm + ) +endif() + +if(WIN32) + set(LAUNCHER_SOURCES + console/WindowsConsole.h + console/WindowsConsole.cpp + ${LAUNCHER_SOURCES} + ) +endif() + +qt_wrap_ui(LAUNCHER_UI + ui/MainWindow.ui + ui/setupwizard/PasteWizardPage.ui + ui/setupwizard/AutoJavaWizardPage.ui + ui/setupwizard/LoginWizardPage.ui + ui/pages/global/AccountListPage.ui + ui/pages/global/JavaPage.ui + ui/pages/global/LauncherPage.ui + ui/pages/global/APIPage.ui + ui/pages/global/ProxyPage.ui + ui/pages/global/ExternalToolsPage.ui + ui/pages/instance/ExternalResourcesPage.ui + ui/pages/instance/NotesPage.ui + ui/pages/instance/LogPage.ui + ui/pages/instance/ServersPage.ui + ui/pages/instance/OtherLogsPage.ui + ui/pages/instance/VersionPage.ui + ui/pages/instance/ManagedPackPage.ui + ui/pages/instance/WorldListPage.ui + ui/pages/instance/ScreenshotsPage.ui + ui/pages/modplatform/atlauncher/AtlOptionalModDialog.ui + ui/pages/modplatform/atlauncher/AtlPage.ui + ui/pages/modplatform/CustomPage.ui + ui/pages/modplatform/ResourcePage.ui + ui/pages/modplatform/flame/FlamePage.ui + ui/pages/modplatform/legacy_ftb/Page.ui + ui/pages/modplatform/import_ftb/ImportFTBPage.ui + ui/pages/modplatform/ImportPage.ui + ui/pages/modplatform/OptionalModDialog.ui + ui/pages/modplatform/ftb/FtbPage.ui + ui/pages/modplatform/modrinth/ModrinthPage.ui + ui/pages/modplatform/technic/TechnicPage.ui + ui/widgets/CustomCommands.ui + ui/widgets/EnvironmentVariables.ui + ui/widgets/InfoFrame.ui + ui/widgets/ModFilterWidget.ui + ui/widgets/SubTaskProgressBar.ui + ui/widgets/AppearanceWidget.ui + ui/widgets/MinecraftSettingsWidget.ui + ui/widgets/JavaSettingsWidget.ui + ui/dialogs/CopyInstanceDialog.ui + ui/dialogs/CreateShortcutDialog.ui + ui/dialogs/ProfileSetupDialog.ui + ui/dialogs/ProgressDialog.ui + ui/dialogs/NewInstanceDialog.ui + ui/dialogs/NetworkJobFailedDialog.ui + ui/dialogs/NewComponentDialog.ui + ui/dialogs/NewsDialog.ui + ui/dialogs/ProfileSelectDialog.ui + ui/dialogs/ExportInstanceDialog.ui + ui/dialogs/ExportPackDialog.ui + ui/dialogs/ExportToModListDialog.ui + ui/dialogs/IconPickerDialog.ui + ui/dialogs/ImportResourceDialog.ui + ui/dialogs/MSALoginDialog.ui + ui/dialogs/AboutDialog.ui + ui/dialogs/ReviewMessageBox.ui + ui/dialogs/ScrollMessageBox.ui + ui/dialogs/BlockedModsDialog.ui + ui/dialogs/ChooseProviderDialog.ui + ui/dialogs/skins/SkinManageDialog.ui + ui/dialogs/ChooseOfflineNameDialog.ui +) + +qt_wrap_ui(PRISM_UPDATE_UI + ui/dialogs/UpdateAvailableDialog.ui +) + +if (NOT Apple) + set (LAUNCHER_UI ${LAUNCHER_UI} ${PRISM_UPDATE_UI}) +endif() + +qt_add_resources(LAUNCHER_RESOURCES + resources/backgrounds/backgrounds.qrc + resources/racked_ru/racked_ru.qrc + resources/flat_white/flat_white.qrc + resources/documents/documents.qrc + resources/shaders/shaders.qrc + "${CMAKE_CURRENT_BINARY_DIR}/../${Launcher_Branding_LogoQRC}" +) + +qt_wrap_ui(PRISMUPDATER_UI + updater/prismupdater/SelectReleaseDialog.ui + ui/widgets/SubTaskProgressBar.ui + ui/dialogs/ProgressDialog.ui +) + +######## Windows resource files ######## +if(WIN32) + set(LAUNCHER_RCS ${CMAKE_CURRENT_BINARY_DIR}/../${Launcher_Branding_WindowsRC}) +endif() + +######## Precompiled Headers ########### + +if(${Launcher_USE_PCH}) + message(STATUS "Using precompiled headers for applicable launcher targets") + set(PRECOMPILED_HEADERS + include/base.pch.hpp + include/qtcore.pch.hpp + include/qtgui.pch.hpp + ) +endif() + +####### Targets ######## + +# Add executable +add_library(Launcher_logic STATIC ${LOGIC_SOURCES} ${LAUNCHER_SOURCES} ${LAUNCHER_UI} ${LAUNCHER_RESOURCES}) +target_include_directories(Launcher_logic PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) +target_compile_definitions(Launcher_logic PUBLIC LAUNCHER_APPLICATION) + +if(${Launcher_USE_PCH}) + target_precompile_headers(Launcher_logic PRIVATE ${PRECOMPILED_HEADERS}) +endif() + +target_link_libraries(Launcher_logic + Launcher_murmur2 + nbt++ + ${ZLIB_LIBRARIES} + qdcss + BuildConfig + Qt${QT_VERSION_MAJOR}::Widgets +) + +if(TARGET PkgConfig::libqrencode) + target_link_libraries(Launcher_logic PkgConfig::libqrencode) +else() + target_include_directories(Launcher_logic PRIVATE ${LIBQRENCODE_INCLUDE_DIR}) + target_link_libraries(Launcher_logic ${LIBQRENCODE_LIBRARIES}) +endif() + +if(TARGET PkgConfig::tomlplusplus) + target_link_libraries(Launcher_logic PkgConfig::tomlplusplus) +else() + target_link_libraries(Launcher_logic tomlplusplus::tomlplusplus) +endif() +if(TARGET PkgConfig::libarchive) + target_link_libraries(Launcher_logic PkgConfig::libarchive) +else() + target_link_libraries(Launcher_logic LibArchive::LibArchive) +endif() + +if (CMAKE_SYSTEM_NAME STREQUAL "Linux") + target_link_libraries(Launcher_logic + gamemode + ) +endif() + +target_link_libraries(Launcher_logic + Qt${QT_VERSION_MAJOR}::Core + Qt${QT_VERSION_MAJOR}::Xml + Qt${QT_VERSION_MAJOR}::Network + Qt${QT_VERSION_MAJOR}::Concurrent + Qt${QT_VERSION_MAJOR}::Gui + Qt${QT_VERSION_MAJOR}::Widgets + Qt${QT_VERSION_MAJOR}::NetworkAuth + Qt${QT_VERSION_MAJOR}::OpenGL + ${Launcher_QT_DBUS} + ${Launcher_QT_LIBS} +) +target_link_libraries(Launcher_logic + cmark::cmark + LocalPeer + Launcher_rainbow +) +if (TARGET ${Launcher_QT_DBUS}) + add_compile_definitions(WITH_QTDBUS) +endif() + +if(APPLE) + set(CMAKE_MACOSX_RPATH 1) + set(CMAKE_INSTALL_RPATH "@loader_path/../Frameworks/") + + if(Launcher_ENABLE_UPDATER) + file(DOWNLOAD ${MACOSX_SPARKLE_DOWNLOAD_URL} ${CMAKE_BINARY_DIR}/Sparkle.tar.xz EXPECTED_HASH SHA256=${MACOSX_SPARKLE_SHA256}) + file(ARCHIVE_EXTRACT INPUT ${CMAKE_BINARY_DIR}/Sparkle.tar.xz DESTINATION ${CMAKE_BINARY_DIR}/frameworks/Sparkle) + + find_library(SPARKLE_FRAMEWORK Sparkle "${CMAKE_BINARY_DIR}/frameworks/Sparkle") + add_compile_definitions(SPARKLE_ENABLED) + endif() + + target_link_libraries(Launcher_logic + "-framework AppKit" + "-framework Carbon" + "-framework Foundation" + "-framework ApplicationServices" + ) + if(Launcher_ENABLE_UPDATER) + target_link_libraries(Launcher_logic ${SPARKLE_FRAMEWORK}) + endif() +endif() + +add_executable(${Launcher_Name} MACOSX_BUNDLE WIN32 main.cpp ${LAUNCHER_RCS}) + +if(${Launcher_USE_PCH}) + target_precompile_headers(${Launcher_Name} REUSE_FROM Launcher_logic) +endif() + +target_link_libraries(${Launcher_Name} Launcher_logic) + +if(DEFINED Launcher_APP_BINARY_NAME) + set_target_properties(${Launcher_Name} PROPERTIES OUTPUT_NAME "${Launcher_APP_BINARY_NAME}") +endif() +if(DEFINED Launcher_BINARY_RPATH) + SET_TARGET_PROPERTIES(${Launcher_Name} PROPERTIES INSTALL_RPATH "${Launcher_BINARY_RPATH}") +endif() + +if(DEFINED Launcher_APP_BINARY_DEFS) + target_compile_definitions(${Launcher_Name} PRIVATE ${Launcher_APP_BINARY_DEFS}) + target_compile_definitions(Launcher_logic PRIVATE ${Launcher_APP_BINARY_DEFS}) +endif() + +install(TARGETS ${Launcher_Name} + RUNTIME_DEPENDENCY_SET LAUNCHER_DEPENDENCY_SET + BUNDLE DESTINATION "." COMPONENT Runtime + LIBRARY DESTINATION ${LIBRARY_DEST_DIR} COMPONENT Runtime + RUNTIME DESTINATION ${BINARY_DEST_DIR} COMPONENT Runtime + FRAMEWORK DESTINATION ${FRAMEWORK_DEST_DIR} COMPONENT Runtime +) + +# Deploy PDBs +if(CMAKE_CXX_LINKER_SUPPORTS_PDB) + install(FILES $ DESTINATION ${BINARY_DEST_DIR} OPTIONAL) +endif() + +if(Launcher_BUILD_UPDATER) + # Updater + add_library(prism_updater_logic STATIC ${PRISMUPDATER_SOURCES} ${TASKS_SOURCES} ${PRISMUPDATER_UI}) + target_include_directories(prism_updater_logic PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) + + if(${Launcher_USE_PCH}) + target_precompile_headers(prism_updater_logic PRIVATE ${PRECOMPILED_HEADERS}) + endif() + + target_link_libraries(prism_updater_logic + ${ZLIB_LIBRARIES} + BuildConfig + Qt${QT_VERSION_MAJOR}::Widgets + Qt${QT_VERSION_MAJOR}::Core + Qt${QT_VERSION_MAJOR}::Network + ${Launcher_QT_LIBS} + cmark::cmark + ) + if(TARGET PkgConfig::libarchive) + target_link_libraries(prism_updater_logic PkgConfig::libarchive) + else() + target_link_libraries(prism_updater_logic LibArchive::LibArchive) + endif() + + add_executable("${Launcher_Name}_updater" WIN32 updater/prismupdater/updater_main.cpp) + target_sources("${Launcher_Name}_updater" PRIVATE updater/prismupdater/updater.exe.manifest) + target_link_libraries("${Launcher_Name}_updater" prism_updater_logic) + + if(${Launcher_USE_PCH}) + target_precompile_headers("${Launcher_Name}_updater" REUSE_FROM prism_updater_logic) + endif() + + if(DEFINED Launcher_APP_BINARY_NAME) + set_target_properties("${Launcher_Name}_updater" PROPERTIES OUTPUT_NAME "${Launcher_APP_BINARY_NAME}_updater") + endif() + if(DEFINED Launcher_BINARY_RPATH) + SET_TARGET_PROPERTIES("${Launcher_Name}_updater" PROPERTIES INSTALL_RPATH "${Launcher_BINARY_RPATH}") + endif() + + install(TARGETS "${Launcher_Name}_updater" + BUNDLE DESTINATION "." COMPONENT Runtime + LIBRARY DESTINATION ${LIBRARY_DEST_DIR} COMPONENT Runtime + RUNTIME DESTINATION ${BINARY_DEST_DIR} COMPONENT Runtime + FRAMEWORK DESTINATION ${FRAMEWORK_DEST_DIR} COMPONENT Runtime + ) + + # Deploy PDBs + if(CMAKE_CXX_LINKER_SUPPORTS_PDB) + install(FILES $ DESTINATION ${BINARY_DEST_DIR} OPTIONAL) + endif() +endif() + +if(WIN32 OR (DEFINED Launcher_BUILD_FILELINKER AND Launcher_BUILD_FILELINKER)) + # File link + add_library(filelink_logic STATIC ${LINKEXE_SOURCES}) + + target_include_directories(filelink_logic PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) + + if(${Launcher_USE_PCH}) + target_precompile_headers(filelink_logic PRIVATE ${PRECOMPILED_HEADERS}) + endif() + + target_link_libraries(filelink_logic + BuildConfig + Qt${QT_VERSION_MAJOR}::Widgets + Qt${QT_VERSION_MAJOR}::Core + Qt${QT_VERSION_MAJOR}::Network + # Qt${QT_VERSION_MAJOR}::Concurrent + ${Launcher_QT_LIBS} + ) + + add_executable("${Launcher_Name}_filelink" WIN32 filelink/filelink_main.cpp) + target_sources("${Launcher_Name}_filelink" PRIVATE filelink/filelink.exe.manifest) + + if(${Launcher_USE_PCH}) + target_precompile_headers("${Launcher_Name}_filelink" REUSE_FROM filelink_logic) + endif() + + # HACK: Fix manifest issues with Ninja in release mode (and only release mode) and MSVC + # I have no idea why this works or why it's needed. UPDATE THIS IF YOU EDIT THE MANIFEST!!! -@getchoo + # Thank you 2018 CMake mailing list thread https://cmake.cmake.narkive.com/LnotZXus/conflicting-msvc-manifests + if(MSVC) + set_property(TARGET "${Launcher_Name}_filelink" PROPERTY LINK_FLAGS "/MANIFESTUAC:level='requireAdministrator'") + endif() + + target_link_libraries("${Launcher_Name}_filelink" filelink_logic) + + if(DEFINED Launcher_APP_BINARY_NAME) + set_target_properties("${Launcher_Name}_filelink" PROPERTIES OUTPUT_NAME "${Launcher_APP_BINARY_NAME}_filelink") + endif() + if(DEFINED Launcher_BINARY_RPATH) + SET_TARGET_PROPERTIES("${Launcher_Name}_filelink" PROPERTIES INSTALL_RPATH "${Launcher_BINARY_RPATH}") + endif() + + install(TARGETS "${Launcher_Name}_filelink" + BUNDLE DESTINATION "." COMPONENT Runtime + LIBRARY DESTINATION ${LIBRARY_DEST_DIR} COMPONENT Runtime + RUNTIME DESTINATION ${BINARY_DEST_DIR} COMPONENT Runtime + FRAMEWORK DESTINATION ${FRAMEWORK_DEST_DIR} COMPONENT Runtime + ) + + # Deploy PDBs + if(CMAKE_CXX_LINKER_SUPPORTS_PDB) + install(FILES $ DESTINATION ${BINARY_DEST_DIR} OPTIONAL) + endif() +endif() + +if (UNIX AND APPLE AND Launcher_ENABLE_UPDATER) + # Add Sparkle updater + # It has to be copied here instead of just allowing fixup_bundle to install it, otherwise essential parts of + # the framework aren't installed + install(DIRECTORY ${MACOSX_SPARKLE_DIR}/Sparkle.framework DESTINATION ${FRAMEWORK_DEST_DIR} USE_SOURCE_PERMISSIONS) +endif() + +# Set basic compiler warning/error flags for all targets +get_property(Launcher_TARGETS DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} PROPERTY BUILDSYSTEM_TARGETS) +foreach(target ${Launcher_TARGETS}) + message(STATUS "Enabling all warnings as errors for target '${target}'") + if (CMAKE_CXX_COMPILER_FRONTEND_VARIANT STREQUAL "MSVC") + target_compile_options(${target} PRIVATE /W4 /WX /permissive-) + else() + target_compile_options(${target} PRIVATE -Wall -Wextra -Wpedantic -Werror) + endif() +endforeach() + +# Disable some warnings in main launcher target due to being present in a lot of places. TODO: Fix them. +if (CMAKE_CXX_COMPILER_FRONTEND_VARIANT STREQUAL "MSVC") + target_compile_options(Launcher_logic PRIVATE /wd4100) # C4100 - unused parameter + target_compile_options(${Launcher_Name} PRIVATE /wd4100) # C4100 - unused parameter +else() + target_compile_options(Launcher_logic PRIVATE -Wno-unused-parameter -Wno-missing-field-initializers) + target_compile_options(${Launcher_Name} PRIVATE -Wno-unused-parameter -Wno-missing-field-initializers) +endif() + +#### The bundle mess! #### +# Bundle utilities are used to complete packages for different platforms - they add all the libraries that would otherwise be missing on the target system. +# NOTE: it seems that this absolutely has to be here, and nowhere else. +if(WIN32 OR (UNIX AND APPLE)) + if(WIN32) + set(QT_DEPLOY_TOOL_OPTIONS "--no-opengl-sw --no-quick-import --no-system-d3d-compiler --no-system-dxc-compiler --skip-plugin-types generic,networkinformation") + endif() + + qt_generate_deploy_script( + TARGET ${Launcher_Name} + OUTPUT_SCRIPT QT_DEPLOY_SCRIPT + CONTENT " + qt_deploy_runtime_dependencies( + EXECUTABLE ${BINARY_DEST_DIR}/$ + BIN_DIR ${BINARY_DEST_DIR} + LIBEXEC_DIR ${LIBRARY_DEST_DIR} + LIB_DIR ${LIBRARY_DEST_DIR} + PLUGINS_DIR ${PLUGIN_DEST_DIR} + NO_OVERWRITE + NO_TRANSLATIONS + NO_COMPILER_RUNTIME + DEPLOY_TOOL_OPTIONS ${QT_DEPLOY_TOOL_OPTIONS} + )" + ) + + # Bundle our linked dependencies + install( + RUNTIME_DEPENDENCY_SET LAUNCHER_DEPENDENCY_SET + COMPONENT bundle + DIRECTORIES + ${CMAKE_SYSTEM_LIBRARY_PATH} + ${QT_LIBS_DIR} + ${QT_LIBEXECS_DIR} + PRE_EXCLUDE_REGEXES + "^(api-ms-win|ext-ms)-.*\\.dll$" + # FIXME: Why aren't these caught by the below regex??? + "^azure.*\\.dll$" + "^vcruntime.*\\.dll$" + POST_EXCLUDE_REGEXES + "system32" + LIBRARY DESTINATION ${LIBRARY_DEST_DIR} + RUNTIME DESTINATION ${BINARY_DEST_DIR} + FRAMEWORK DESTINATION ${FRAMEWORK_DEST_DIR} + ) + # Deploy Qt plugins + install( + SCRIPT ${QT_DEPLOY_SCRIPT} + COMPONENT bundle + ) + # FIXME: remove this crap once we stop using msys2 + if(MINGW) + # i've not found a solution better than injecting the config vars like this... + # with install(CODE" for everything everything just breaks + install(CODE " + set(QT_PLUGINS_DIR \"${QT_PLUGINS_DIR}\") + set(QT_LIBS_DIR \"${QT_LIBS_DIR}\") + set(QT_LIBEXECS_DIR \"${QT_LIBEXECS_DIR}\") + set(CMAKE_SYSTEM_LIBRARY_PATH \"${CMAKE_SYSTEM_LIBRARY_PATH}\") + set(CMAKE_INSTALL_PREFIX \"${CMAKE_INSTALL_PREFIX}\") + " + COMPONENT bundle) + + install(CODE [[ + file(GLOB QT_IMAGEFORMAT_DLLS "${QT_PLUGINS_DIR}/imageformats/*.dll") + set(CMAKE_GET_RUNTIME_DEPENDENCIES_TOOL objdump) + file(GET_RUNTIME_DEPENDENCIES + RESOLVED_DEPENDENCIES_VAR imageformatdeps + LIBRARIES ${QT_IMAGEFORMAT_DLLS} + DIRECTORIES + ${CMAKE_SYSTEM_LIBRARY_PATH} + ${QT_PLUGINS_DIR} + ${QT_LIBS_DIR} + ${QT_LIBEXECS_DIR} + PRE_EXCLUDE_REGEXES + "^(api-ms-win|ext-ms)-.*\\.dll$" + # FIXME: Why aren't these caught by the below regex??? + "^azure.*\\.dll$" + "^vcruntime.*\\.dll$" + POST_EXCLUDE_REGEXES + "system32" + ) + foreach(_lib ${imageformatdeps}) + file(INSTALL + DESTINATION ${CMAKE_INSTALL_PREFIX} + TYPE SHARED_LIBRARY + FOLLOW_SYMLINK_CHAIN + FILES ${_lib} + ) + endforeach() + ]] + COMPONENT bundle) + endif() + + # Add qt.conf - this makes Qt stop looking for things outside the bundle + install( + CODE "file(WRITE \"\${CMAKE_INSTALL_PREFIX}/${RESOURCES_DEST_DIR}/qt.conf\" \" \")" + COMPONENT bundle + ) + # Add qtlogging.ini as a config file + install( + FILES "qtlogging.ini" + DESTINATION ${CMAKE_INSTALL_PREFIX}/${RESOURCES_DEST_DIR} + COMPONENT bundle + ) +endif() + +find_program(CLANG_FORMAT clang-format OPTIONAL) +if(CLANG_FORMAT) + message(STATUS "Creating clang-format target") + add_custom_target( + clang-format + COMMAND ${CLANG_FORMAT} -i --style=file:${CMAKE_SOURCE_DIR}/.clang-format ${LOGIC_SOURCES} ${LAUNCHER_SOURCES} ${PRISMUPDATER_SOURCES} ${LINKEXE_SOURCES} ${PRECOMPILED_HEADERS} + WORKING_DIRECTORY ${PROJECT_SOURCE_DIR} + ) +else() + message(WARNING "Unable to find `clang-format`. Not creating custom target") +endif() diff --git a/launcher/Commandline.cpp b/launcher/Commandline.cpp new file mode 100644 index 0000000..8489fb7 --- /dev/null +++ b/launcher/Commandline.cpp @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Authors: Orochimarufan + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "Commandline.h" + +/** + * @file libutil/src/cmdutils.cpp + */ + +namespace Commandline { + +// commandline splitter +QStringList splitArgs(QString args) +{ + QStringList argv; + QString current; + bool escape = false; + QChar inquotes; + for (int i = 0; i < args.length(); i++) { + QChar cchar = args.at(i); + + // \ escaped + if (escape) { + current += cchar; + escape = false; + // in "quotes" + } else if (!inquotes.isNull()) { + if (cchar == '\\') + escape = true; + else if (cchar == inquotes) + inquotes = QChar::Null; + else + current += cchar; + // otherwise + } else { + if (cchar == ' ') { + if (!current.isEmpty()) { + argv << current; + current.clear(); + } + } else if (cchar == '"' || cchar == '\'') + inquotes = cchar; + else + current += cchar; + } + } + if (!current.isEmpty()) + argv << current; + return argv; +} +} // namespace Commandline diff --git a/launcher/Commandline.h b/launcher/Commandline.h new file mode 100644 index 0000000..77c557d --- /dev/null +++ b/launcher/Commandline.h @@ -0,0 +1,36 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Authors: Orochimarufan + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +/** + * @file libutil/include/cmdutils.h + * @brief commandline parsing and processing utilities + */ + +namespace Commandline { + +/** + * @brief split a string into argv items like a shell would do + * @param args the argument string + * @return a QStringList containing all arguments + */ +QStringList splitArgs(QString args); +} // namespace Commandline diff --git a/launcher/DataMigrationTask.cpp b/launcher/DataMigrationTask.cpp new file mode 100644 index 0000000..cab2208 --- /dev/null +++ b/launcher/DataMigrationTask.cpp @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: 2022 Sefa Eyeoglu +// +// SPDX-License-Identifier: GPL-3.0-only + +#include "DataMigrationTask.h" + +#include "FileSystem.h" + +#include +#include +#include + +#include + +DataMigrationTask::DataMigrationTask(const QString& sourcePath, const QString& targetPath, Filter pathMatcher) + : Task(), m_sourcePath(sourcePath), m_targetPath(targetPath), m_pathMatcher(pathMatcher), m_copy(sourcePath, targetPath) +{ + m_copy.matcher(m_pathMatcher).whitelist(true); +} + +void DataMigrationTask::executeTask() +{ + setStatus(tr("Scanning files...")); + + // 1. Scan + // Check how many files we gotta copy + m_copyFuture = QtConcurrent::run(QThreadPool::globalInstance(), [this] { + return m_copy(true); // dry run to collect amount of files + }); + connect(&m_copyFutureWatcher, &QFutureWatcher::finished, this, &DataMigrationTask::dryRunFinished); + connect(&m_copyFutureWatcher, &QFutureWatcher::canceled, this, &DataMigrationTask::dryRunAborted); + m_copyFutureWatcher.setFuture(m_copyFuture); +} + +void DataMigrationTask::dryRunFinished() +{ + disconnect(&m_copyFutureWatcher, &QFutureWatcher::finished, this, &DataMigrationTask::dryRunFinished); + disconnect(&m_copyFutureWatcher, &QFutureWatcher::canceled, this, &DataMigrationTask::dryRunAborted); + + if (!m_copyFuture.isValid() || !m_copyFuture.result()) { + emitFailed(tr("Failed to scan source path.")); + return; + } + + // 2. Copy + // Actually copy all files now. + m_toCopy = m_copy.totalCopied(); + connect(&m_copy, &FS::copy::fileCopied, [&, this](const QString& relativeName) { + QString shortenedName = relativeName; + // shorten the filename to hopefully fit into one line + if (shortenedName.length() > 50) + shortenedName = relativeName.left(20) + "…" + relativeName.right(29); + setProgress(m_copy.totalCopied(), m_toCopy); + setStatus(tr("Copying %1…").arg(shortenedName)); + }); + m_copyFuture = QtConcurrent::run(QThreadPool::globalInstance(), [this] { + return m_copy(false); // actually copy now + }); + connect(&m_copyFutureWatcher, &QFutureWatcher::finished, this, &DataMigrationTask::copyFinished); + connect(&m_copyFutureWatcher, &QFutureWatcher::canceled, this, &DataMigrationTask::copyAborted); + m_copyFutureWatcher.setFuture(m_copyFuture); +} + +void DataMigrationTask::dryRunAborted() +{ + emitAborted(); +} + +void DataMigrationTask::copyFinished() +{ + disconnect(&m_copyFutureWatcher, &QFutureWatcher::finished, this, &DataMigrationTask::copyFinished); + disconnect(&m_copyFutureWatcher, &QFutureWatcher::canceled, this, &DataMigrationTask::copyAborted); + + if (!m_copyFuture.isValid() || !m_copyFuture.result()) { + emitFailed(tr("Some paths could not be copied!")); + return; + } + + emitSucceeded(); +} + +void DataMigrationTask::copyAborted() +{ + emitAborted(); +} diff --git a/launcher/DataMigrationTask.h b/launcher/DataMigrationTask.h new file mode 100644 index 0000000..9a2b0ad --- /dev/null +++ b/launcher/DataMigrationTask.h @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2022 Sefa Eyeoglu +// +// SPDX-License-Identifier: GPL-3.0-only + +#pragma once + +#include "FileSystem.h" +#include "Filter.h" +#include "tasks/Task.h" + +#include +#include + +/* + * Migrate existing data from other MMC-like launchers. + */ + +class DataMigrationTask : public Task { + Q_OBJECT + public: + explicit DataMigrationTask(const QString& sourcePath, const QString& targetPath, Filter pathmatcher); + ~DataMigrationTask() override = default; + + protected: + virtual void executeTask() override; + + protected slots: + void dryRunFinished(); + void dryRunAborted(); + void copyFinished(); + void copyAborted(); + + private: + const QString& m_sourcePath; + const QString& m_targetPath; + const Filter m_pathMatcher; + + FS::copy m_copy; + int m_toCopy = 0; + QFuture m_copyFuture; + QFutureWatcher m_copyFutureWatcher; +}; diff --git a/launcher/DesktopServices.cpp b/launcher/DesktopServices.cpp new file mode 100644 index 0000000..841c139 --- /dev/null +++ b/launcher/DesktopServices.cpp @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 dada513 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2022 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +#include "DesktopServices.h" +#include +#include +#include +#include +#include "FileSystem.h" + +namespace DesktopServices { +bool openPath(const QFileInfo& path, bool ensureFolderPathExists) +{ + qDebug() << "Opening path" << path; + if (ensureFolderPathExists) { + FS::ensureFolderPathExists(path); + } + return openUrl(QUrl::fromLocalFile(QFileInfo(path).absoluteFilePath())); +} + +bool openPath(const QString& path, bool ensureFolderPathExists) +{ + return openPath(QFileInfo(path), ensureFolderPathExists); +} + +bool run(const QString& application, const QStringList& args, const QString& workingDirectory, qint64* pid) +{ + qDebug() << "Running" << application << "with args" << args.join(' '); + return QProcess::startDetached(application, args, workingDirectory, pid); +} + +bool openUrl(const QUrl& url) +{ + qDebug() << "Opening URL" << url.toString(); + return QDesktopServices::openUrl(url); +} + +bool isFlatpak() +{ +#ifdef Q_OS_LINUX + return QFile::exists("/.flatpak-info"); +#else + return false; +#endif +} + +bool isSnap() +{ +#ifdef Q_OS_LINUX + return getenv("SNAP"); +#else + return false; +#endif +} + +} // namespace DesktopServices diff --git a/launcher/DesktopServices.h b/launcher/DesktopServices.h new file mode 100644 index 0000000..6c6208e --- /dev/null +++ b/launcher/DesktopServices.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include + +class QFileInfo; + +/** + * This wraps around QDesktopServices and adds workarounds where needed + * Use this instead of QDesktopServices! + */ +namespace DesktopServices { +/** + * Open a path in whatever application is applicable. + * @param ensureFolderPathExists Make sure the path exists + */ +bool openPath(const QFileInfo& path, bool ensureFolderPathExists = false); + +/** + * Open a path in whatever application is applicable. + * @param ensureFolderPathExists Make sure the path exists + */ +bool openPath(const QString& path, bool ensureFolderPathExists = false); + +/** + * Run an application + */ +bool run(const QString& application, const QStringList& args, const QString& workingDirectory = QString(), qint64* pid = 0); + +/** + * Open the URL, most likely in a browser. Maybe. + */ +bool openUrl(const QUrl& url); + +/** + * Determine whether the launcher is running in a Flatpak environment + */ +bool isFlatpak(); + +/** + * Determine whether the launcher is running in a Snap environment + */ +bool isSnap(); +} // namespace DesktopServices diff --git a/launcher/Exception.h b/launcher/Exception.h new file mode 100644 index 0000000..55b40fd --- /dev/null +++ b/launcher/Exception.h @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: GPL-3.0-only AND Apache-2.0 +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2024 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +class Exception : public std::exception { + public: + Exception(const QString& message) : std::exception(), m_message(message.toUtf8()) { qCritical() << "Exception:" << message; } + Exception(const Exception& other) : std::exception(), m_message(other.m_message) {} + virtual ~Exception() noexcept {} + const char* what() const noexcept { return m_message.constData(); } + QString cause() const { return QString::fromUtf8(m_message); } + + private: + QByteArray m_message; +}; diff --git a/launcher/ExponentialSeries.h b/launcher/ExponentialSeries.h new file mode 100644 index 0000000..b1fca43 --- /dev/null +++ b/launcher/ExponentialSeries.h @@ -0,0 +1,36 @@ + +#pragma once + +template +inline void clamp(T& current, T min, T max) +{ + if (current < min) { + current = min; + } else if (current > max) { + current = max; + } +} + +// List of numbers from min to max. Next is exponent times bigger than previous. + +class ExponentialSeries { + public: + ExponentialSeries(unsigned min, unsigned max, unsigned exponent = 2) + { + m_current = m_min = min; + m_max = max; + m_exponent = exponent; + } + void reset() { m_current = m_min; } + unsigned operator()() + { + unsigned retval = m_current; + m_current *= m_exponent; + clamp(m_current, m_min, m_max); + return retval; + } + unsigned m_current; + unsigned m_min; + unsigned m_max; + unsigned m_exponent; +}; diff --git a/launcher/FastFileIconProvider.cpp b/launcher/FastFileIconProvider.cpp new file mode 100644 index 0000000..1dbab27 --- /dev/null +++ b/launcher/FastFileIconProvider.cpp @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "FastFileIconProvider.h" + +#include +#include + +QIcon FastFileIconProvider::icon(const QFileInfo& info) const +{ +#if QT_VERSION >= QT_VERSION_CHECK(6, 4, 0) + bool link = info.isSymbolicLink() || info.isAlias() || info.isShortcut(); +#else + // in versions prior to 6.4 we don't have access to isAlias + bool link = info.isSymLink(); +#endif + QStyle::StandardPixmap icon; + + if (info.isDir()) { + if (link) + icon = QStyle::SP_DirLinkIcon; + else + icon = QStyle::SP_DirIcon; + } else { + if (link) + icon = QStyle::SP_FileLinkIcon; + else + icon = QStyle::SP_FileIcon; + } + + return QApplication::style()->standardIcon(icon); +} diff --git a/launcher/FastFileIconProvider.h b/launcher/FastFileIconProvider.h new file mode 100644 index 0000000..7799b78 --- /dev/null +++ b/launcher/FastFileIconProvider.h @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include + +class FastFileIconProvider : public QFileIconProvider { + public: + QIcon icon(const QFileInfo& info) const override; +}; diff --git a/launcher/FileIgnoreProxy.cpp b/launcher/FileIgnoreProxy.cpp new file mode 100644 index 0000000..445c2a8 --- /dev/null +++ b/launcher/FileIgnoreProxy.cpp @@ -0,0 +1,310 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "FileIgnoreProxy.h" + +#include +#include +#include +#include +#include "FileSystem.h" +#include "SeparatorPrefixTree.h" +#include "StringUtils.h" + +FileIgnoreProxy::FileIgnoreProxy(QString root, QObject* parent) : QSortFilterProxyModel(parent), m_root(root) {} +// NOTE: Sadly, we have to do sorting ourselves. +bool FileIgnoreProxy::lessThan(const QModelIndex& left, const QModelIndex& right) const +{ + QFileSystemModel* fsm = qobject_cast(sourceModel()); + if (!fsm) { + return QSortFilterProxyModel::lessThan(left, right); + } + bool asc = sortOrder() == Qt::AscendingOrder ? true : false; + + QFileInfo leftFileInfo = fsm->fileInfo(left); + QFileInfo rightFileInfo = fsm->fileInfo(right); + + if (!leftFileInfo.isDir() && rightFileInfo.isDir()) { + return !asc; + } + if (leftFileInfo.isDir() && !rightFileInfo.isDir()) { + return asc; + } + + // sort and proxy model breaks the original model... + if (sortColumn() == 0) { + return StringUtils::naturalCompare(leftFileInfo.fileName(), rightFileInfo.fileName(), Qt::CaseInsensitive) < 0; + } + if (sortColumn() == 1) { + auto leftSize = leftFileInfo.size(); + auto rightSize = rightFileInfo.size(); + if ((leftSize == rightSize) || (leftFileInfo.isDir() && rightFileInfo.isDir())) { + return StringUtils::naturalCompare(leftFileInfo.fileName(), rightFileInfo.fileName(), Qt::CaseInsensitive) < 0 ? asc : !asc; + } + return leftSize < rightSize; + } + return QSortFilterProxyModel::lessThan(left, right); +} + +Qt::ItemFlags FileIgnoreProxy::flags(const QModelIndex& index) const +{ + if (!index.isValid()) + return Qt::NoItemFlags; + + auto sourceIndex = mapToSource(index); + Qt::ItemFlags flags = sourceIndex.flags(); + if (index.column() == 0) { + flags |= Qt::ItemIsUserCheckable; + if (sourceIndex.model()->hasChildren(sourceIndex)) { + flags |= Qt::ItemIsAutoTristate; + } + } + + return flags; +} + +QVariant FileIgnoreProxy::data(const QModelIndex& index, int role) const +{ + QModelIndex sourceIndex = mapToSource(index); + + if (index.column() == 0 && role == Qt::CheckStateRole) { + QFileSystemModel* fsm = qobject_cast(sourceModel()); + auto blockedPath = relPath(fsm->filePath(sourceIndex)); + auto cover = m_blocked.cover(blockedPath); + if (!cover.isNull()) { + return QVariant(Qt::Unchecked); + } else if (m_blocked.exists(blockedPath)) { + return QVariant(Qt::PartiallyChecked); + } else { + return QVariant(Qt::Checked); + } + } + + return sourceIndex.data(role); +} + +bool FileIgnoreProxy::setData(const QModelIndex& index, const QVariant& value, int role) +{ + if (index.column() == 0 && role == Qt::CheckStateRole) { + Qt::CheckState state = static_cast(value.toInt()); + return setFilterState(index, state); + } + + QModelIndex sourceIndex = mapToSource(index); + return QSortFilterProxyModel::sourceModel()->setData(sourceIndex, value, role); +} + +QString FileIgnoreProxy::relPath(const QString& path) const +{ + return QDir(m_root).relativeFilePath(path); +} + +bool FileIgnoreProxy::setFilterState(QModelIndex index, Qt::CheckState state) +{ + QFileSystemModel* fsm = qobject_cast(sourceModel()); + + if (!fsm) { + return false; + } + + QModelIndex sourceIndex = mapToSource(index); + auto blockedPath = relPath(fsm->filePath(sourceIndex)); + bool changed = false; + if (state == Qt::Unchecked) { + // blocking a path + auto& node = m_blocked.insert(blockedPath); + // get rid of all blocked nodes below + node.clear(); + changed = true; + } else if (state == Qt::Checked || state == Qt::PartiallyChecked) { + if (!m_blocked.remove(blockedPath)) { + auto cover = m_blocked.cover(blockedPath); + qDebug() << "Blocked by cover" << cover; + // uncover + m_blocked.remove(cover); + // block all contents, except for any cover + QModelIndex rootIndex = fsm->index(FS::PathCombine(m_root, cover)); + QModelIndex doing = rootIndex; + int row = 0; + QStack todo; + while (1) { + auto node = fsm->index(row, 0, doing); + if (!node.isValid()) { + if (!todo.size()) { + break; + } else { + doing = todo.pop(); + row = 0; + continue; + } + } + auto relpath = relPath(fsm->filePath(node)); + if (blockedPath.startsWith(relpath)) // cover found? + { + // continue processing cover later + todo.push(node); + } else { + // or just block this one. + m_blocked.insert(relpath); + } + row++; + } + } + changed = true; + } + if (changed) { + // update the thing + emit dataChanged(index, index, { Qt::CheckStateRole }); + // update everything above index + QModelIndex up = index.parent(); + while (1) { + if (!up.isValid()) + break; + emit dataChanged(up, up, { Qt::CheckStateRole }); + up = up.parent(); + } + // and everything below the index + QModelIndex doing = index; + int row = 0; + QStack todo; + while (1) { + auto node = this->index(row, 0, doing); + if (!node.isValid()) { + if (!todo.size()) { + break; + } else { + doing = todo.pop(); + row = 0; + continue; + } + } + emit dataChanged(node, node, { Qt::CheckStateRole }); + todo.push(node); + row++; + } + // siblings and unrelated nodes are ignored + } + return true; +} + +bool FileIgnoreProxy::shouldExpand(QModelIndex index) +{ + QModelIndex sourceIndex = mapToSource(index); + QFileSystemModel* fsm = qobject_cast(sourceModel()); + if (!fsm) { + return false; + } + auto blockedPath = relPath(fsm->filePath(sourceIndex)); + auto found = m_blocked.find(blockedPath); + if (found) { + return !found->leaf(); + } + return false; +} + +void FileIgnoreProxy::setBlockedPaths(QStringList paths) +{ + beginResetModel(); + m_blocked.clear(); + m_blocked.insert(paths); + endResetModel(); +} + +bool FileIgnoreProxy::filterAcceptsColumn(int source_column, const QModelIndex& source_parent) const +{ + Q_UNUSED(source_parent) + + // adjust the columns you want to filter out here + // return false for those that will be hidden + if (source_column == 2 || source_column == 3) + return false; + + return true; +} + +bool FileIgnoreProxy::filterAcceptsRow(int sourceRow, const QModelIndex& sourceParent) const +{ + QModelIndex index = sourceModel()->index(sourceRow, 0, sourceParent); + QFileSystemModel* fsm = qobject_cast(sourceModel()); + + auto fileInfo = fsm->fileInfo(index); + return !ignoreFile(fileInfo); +} + +bool FileIgnoreProxy::ignoreFile(QFileInfo fileInfo) const +{ + if (m_ignoreFiles.contains(fileInfo.fileName())) { + return true; + } + + for (const auto& suffix : m_ignoreFilesSuffixes) { + if (fileInfo.fileName().endsWith(suffix)) { + return true; + } + } + + if (m_ignoreFilePaths.covers(relPath(fileInfo.absoluteFilePath()))) { + return true; + } + + return false; +} + +bool FileIgnoreProxy::filterFile(const QFileInfo& file) const +{ + return m_blocked.covers(relPath(file.absoluteFilePath())) || ignoreFile(file); +} + +void FileIgnoreProxy::loadBlockedPathsFromFile(const QString& fileName) +{ + QFile ignoreFile(fileName); + if (!ignoreFile.open(QIODevice::ReadOnly)) { + return; + } + auto ignoreData = ignoreFile.readAll(); + auto string = QString::fromUtf8(ignoreData); + setBlockedPaths(string.split('\n', Qt::SkipEmptyParts)); +} + +void FileIgnoreProxy::saveBlockedPathsToFile(const QString& fileName) +{ + auto ignoreData = blockedPaths().toStringList().join('\n').toUtf8(); + try { + FS::write(fileName, ignoreData); + } catch (const Exception& e) { + qWarning() << e.cause(); + } +} diff --git a/launcher/FileIgnoreProxy.h b/launcher/FileIgnoreProxy.h new file mode 100644 index 0000000..0f149ec --- /dev/null +++ b/launcher/FileIgnoreProxy.h @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include "SeparatorPrefixTree.h" + +class FileIgnoreProxy : public QSortFilterProxyModel { + Q_OBJECT + + public: + FileIgnoreProxy(QString root, QObject* parent); + // NOTE: Sadly, we have to do sorting ourselves. + bool lessThan(const QModelIndex& left, const QModelIndex& right) const; + + virtual Qt::ItemFlags flags(const QModelIndex& index) const; + + virtual QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const; + virtual bool setData(const QModelIndex& index, const QVariant& value, int role = Qt::EditRole); + + QString relPath(const QString& path) const; + + bool setFilterState(QModelIndex index, Qt::CheckState state); + + bool shouldExpand(QModelIndex index); + + void setBlockedPaths(QStringList paths); + + inline const SeparatorPrefixTree<'/'>& blockedPaths() const { return m_blocked; } + inline SeparatorPrefixTree<'/'>& blockedPaths() { return m_blocked; } + + // list of file names that need to be removed completely from model + inline QStringList& ignoreFilesWithName() { return m_ignoreFiles; } + inline QStringList& ignoreFilesWithSuffix() { return m_ignoreFilesSuffixes; } + // list of relative paths that need to be removed completely from model + inline SeparatorPrefixTree<'/'>& ignoreFilesWithPath() { return m_ignoreFilePaths; } + + bool filterFile(const QFileInfo& fileName) const; + + void loadBlockedPathsFromFile(const QString& fileName); + + void saveBlockedPathsToFile(const QString& fileName); + + protected: + bool filterAcceptsColumn(int source_column, const QModelIndex& source_parent) const; + bool filterAcceptsRow(int source_row, const QModelIndex& source_parent) const; + + bool ignoreFile(QFileInfo file) const; + + private: + const QString m_root; + SeparatorPrefixTree<'/'> m_blocked; + QStringList m_ignoreFiles; + QStringList m_ignoreFilesSuffixes; + SeparatorPrefixTree<'/'> m_ignoreFilePaths; +}; diff --git a/launcher/FileSystem.cpp b/launcher/FileSystem.cpp new file mode 100644 index 0000000..f53c934 --- /dev/null +++ b/launcher/FileSystem.cpp @@ -0,0 +1,1724 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2022 TheKodeToad + * Copyright (C) 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "FileSystem.h" +#include + +#include "BuildConfig.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "DesktopServices.h" +#include "PSaveFile.h" +#include "StringUtils.h" + +#if defined Q_OS_WIN32 +#define NOMINMAX +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#include +#include +#include +#include +#include +// for ShellExecute +#include +#include +#include +#else +#include +#endif + +#include +namespace fs = std::filesystem; + +// clone +#if defined(Q_OS_LINUX) +#include +#include /* Definition of FICLONE* constants */ +#include +#include +#include +#elif defined(Q_OS_MACOS) +#include +#include +#elif defined(Q_OS_WIN) +// winbtrfs clone vs rundll32 shellbtrfs.dll,ReflinkCopy +#include +#include +#include +#include +// refs +#include +#if defined(__MINGW32__) +#include +#endif +#endif + +#if defined(Q_OS_WIN) + +#if defined(__MINGW32__) + +// Avoid re-defining structs retroactively added to MinGW +// https://github.com/mingw-w64/mingw-w64/issues/90#issuecomment-2829284729 +#if __MINGW64_VERSION_MAJOR < 13 + +struct _DUPLICATE_EXTENTS_DATA { + HANDLE FileHandle; + LARGE_INTEGER SourceFileOffset; + LARGE_INTEGER TargetFileOffset; + LARGE_INTEGER ByteCount; +}; + +using DUPLICATE_EXTENTS_DATA = _DUPLICATE_EXTENTS_DATA; +using PDUPLICATE_EXTENTS_DATA = _DUPLICATE_EXTENTS_DATA*; +#endif + +struct _FSCTL_GET_INTEGRITY_INFORMATION_BUFFER { + WORD ChecksumAlgorithm; // Checksum algorithm. e.g. CHECKSUM_TYPE_UNCHANGED, CHECKSUM_TYPE_NONE, CHECKSUM_TYPE_CRC32 + WORD Reserved; // Must be 0 + DWORD Flags; // FSCTL_INTEGRITY_FLAG_xxx + DWORD ChecksumChunkSizeInBytes; + DWORD ClusterSizeInBytes; +}; + +using FSCTL_GET_INTEGRITY_INFORMATION_BUFFER = _FSCTL_GET_INTEGRITY_INFORMATION_BUFFER; +using PFSCTL_GET_INTEGRITY_INFORMATION_BUFFER = _FSCTL_GET_INTEGRITY_INFORMATION_BUFFER*; + +struct _FSCTL_SET_INTEGRITY_INFORMATION_BUFFER { + WORD ChecksumAlgorithm; // Checksum algorithm. e.g. CHECKSUM_TYPE_UNCHANGED, CHECKSUM_TYPE_NONE, CHECKSUM_TYPE_CRC32 + WORD Reserved; // Must be 0 + DWORD Flags; // FSCTL_INTEGRITY_FLAG_xxx +}; + +using FSCTL_SET_INTEGRITY_INFORMATION_BUFFER = _FSCTL_SET_INTEGRITY_INFORMATION_BUFFER; +using PFSCTL_SET_INTEGRITY_INFORMATION_BUFFER = _FSCTL_SET_INTEGRITY_INFORMATION_BUFFER*; + +#endif + +#ifndef FSCTL_DUPLICATE_EXTENTS_TO_FILE +#define FSCTL_DUPLICATE_EXTENTS_TO_FILE CTL_CODE(FILE_DEVICE_FILE_SYSTEM, 209, METHOD_BUFFERED, FILE_WRITE_DATA) +#endif + +#ifndef FSCTL_GET_INTEGRITY_INFORMATION +#define FSCTL_GET_INTEGRITY_INFORMATION \ + CTL_CODE(FILE_DEVICE_FILE_SYSTEM, 159, METHOD_BUFFERED, FILE_ANY_ACCESS) // FSCTL_GET_INTEGRITY_INFORMATION_BUFFER +#endif + +#ifndef FSCTL_SET_INTEGRITY_INFORMATION +#define FSCTL_SET_INTEGRITY_INFORMATION \ + CTL_CODE(FILE_DEVICE_FILE_SYSTEM, 160, METHOD_BUFFERED, FILE_READ_DATA | FILE_WRITE_DATA) // FSCTL_SET_INTEGRITY_INFORMATION_BUFFER +#endif + +#ifndef ERROR_NOT_CAPABLE +#define ERROR_NOT_CAPABLE 775L +#endif + +#ifndef ERROR_BLOCK_TOO_MANY_REFERENCES +#define ERROR_BLOCK_TOO_MANY_REFERENCES 347L +#endif + +#endif + +namespace FS { + +void ensureExists(const QDir& dir) +{ + if (!QDir().mkpath(dir.absolutePath())) { + throw FileSystemException("Unable to create folder " + dir.dirName() + " (" + dir.absolutePath() + ")"); + } +} + +void write(const QString& filename, const QByteArray& data) +{ + ensureExists(QFileInfo(filename).dir()); + PSaveFile file(filename); + if (!file.open(PSaveFile::WriteOnly)) { + throw FileSystemException("Couldn't open " + filename + " for writing: " + file.errorString()); + } + if (data.size() != file.write(data)) { + throw FileSystemException("Error writing data to " + filename + ": " + file.errorString()); + } + if (!file.commit()) { + throw FileSystemException("Error while committing data to " + filename + ": " + file.errorString()); + } +} + +void appendSafe(const QString& filename, const QByteArray& data) +{ + ensureExists(QFileInfo(filename).dir()); + QByteArray buffer; + try { + buffer = read(filename); + } catch (FileSystemException&) { + buffer = QByteArray(); + } + buffer.append(data); + PSaveFile file(filename); + if (!file.open(PSaveFile::WriteOnly)) { + throw FileSystemException("Couldn't open " + filename + " for writing: " + file.errorString()); + } + if (buffer.size() != file.write(buffer)) { + throw FileSystemException("Error writing data to " + filename + ": " + file.errorString()); + } + if (!file.commit()) { + throw FileSystemException("Error while committing data to " + filename + ": " + file.errorString()); + } +} + +void append(const QString& filename, const QByteArray& data) +{ + ensureExists(QFileInfo(filename).dir()); + QFile file(filename); + if (!file.open(QFile::Append)) { + throw FileSystemException("Couldn't open " + filename + " for writing: " + file.errorString()); + } + if (data.size() != file.write(data)) { + throw FileSystemException("Error writing data to " + filename + ": " + file.errorString()); + } +} + +QByteArray read(const QString& filename) +{ + QFile file(filename); + if (!file.open(QFile::ReadOnly)) { + throw FileSystemException("Unable to open " + filename + " for reading: " + file.errorString()); + } + const qint64 size = file.size(); + QByteArray data(int(size), 0); + const qint64 ret = file.read(data.data(), size); + if (ret == -1 || ret != size) { + throw FileSystemException("Error reading data from " + filename + ": " + file.errorString()); + } + return data; +} + +bool updateTimestamp(const QString& filename) +{ +#ifdef Q_OS_WIN32 + std::wstring filename_utf_16 = filename.toStdWString(); + return (_wutime64(filename_utf_16.c_str(), nullptr) == 0); +#else + QByteArray filenameBA = QFile::encodeName(filename); + return (utime(filenameBA.data(), nullptr) == 0); +#endif +} + +bool ensureFilePathExists(QString filenamepath) +{ + QFileInfo a(filenamepath); + QDir dir; + QString ensuredPath = a.path(); + bool success = dir.mkpath(ensuredPath); + return success; +} + +bool ensureFolderPathExists(const QFileInfo folderPath) +{ + QDir dir; + QString ensuredPath = folderPath.filePath(); + if (folderPath.exists()) + return true; + + bool success = dir.mkpath(ensuredPath); + return success; +} + +bool ensureFolderPathExists(const QString folderPathName) +{ + return ensureFolderPathExists(QFileInfo(folderPathName)); +} + +bool copyFileAttributes(QString src, QString dst) +{ +#ifdef Q_OS_WIN32 + auto attrs = GetFileAttributesW(src.toStdWString().c_str()); + if (attrs == INVALID_FILE_ATTRIBUTES) + return false; + return SetFileAttributesW(dst.toStdWString().c_str(), attrs); +#else + Q_UNUSED(src); + Q_UNUSED(dst); +#endif + return true; +} + +// needs folders to exists +void copyFolderAttributes(QString src, QString dst, QString relative) +{ + auto path = PathCombine(src, relative); + QDir dsrc(src); + while ((path = QFileInfo(path).path()).length() >= src.length()) { + auto dst_path = PathCombine(dst, dsrc.relativeFilePath(path)); + copyFileAttributes(path, dst_path); + } +} + +/** + * @brief Copies a directory and it's contents from src to dest + * @param offset subdirectory form src to copy to dest + * @return if there was an error during the filecopy + */ +bool copy::operator()(const QString& offset, bool dryRun) +{ + using copy_opts = fs::copy_options; + m_copied = 0; // reset counter + m_failedPaths.clear(); + +// NOTE always deep copy on windows. the alternatives are too messy. +#if defined Q_OS_WIN32 + m_followSymlinks = true; +#endif + + auto src = PathCombine(m_src.absolutePath(), offset); + auto dst = PathCombine(m_dst.absolutePath(), offset); + + std::error_code err; + + fs::copy_options opt = copy_opts::none; + + // The default behavior is to follow symlinks + if (!m_followSymlinks) + opt |= copy_opts::copy_symlinks; + + if (m_overwrite) + opt |= copy_opts::overwrite_existing; + + // Function that'll do the actual copying + auto copy_file = [this, dryRun, src, dst, opt, &err](QString src_path, QString relative_dst_path) { + if (m_matcher && (m_matcher(relative_dst_path) != m_whitelist)) + return; + + auto dst_path = PathCombine(dst, relative_dst_path); + if (!dryRun) { + ensureFilePathExists(dst_path); +#ifdef Q_OS_WIN32 + copyFolderAttributes(src, dst, relative_dst_path); +#endif + fs::copy(StringUtils::toStdString(src_path), StringUtils::toStdString(dst_path), opt, err); + } + if (err) { + qWarning() << "Failed to copy files:" << QString::fromStdString(err.message()); + qDebug() << "Source file:" << src_path; + qDebug() << "Destination file:" << dst_path; + m_failedPaths.append(dst_path); + emit copyFailed(relative_dst_path); + return; + } + m_copied++; + emit fileCopied(relative_dst_path); + }; + + // We can't use copy_opts::recursive because we need to take into account the + // blacklisted paths, so we iterate over the source directory, and if there's no blacklist + // match, we copy the file. + QDir src_dir(src); + QDirIterator source_it(src, QDir::Filter::Files | QDir::Filter::Hidden, QDirIterator::Subdirectories); + + while (source_it.hasNext()) { + auto src_path = source_it.next(); + auto relative_path = src_dir.relativeFilePath(src_path); + + copy_file(src_path, relative_path); + } + + // If the root src is not a directory, the previous iterator won't run. + if (!fs::is_directory(StringUtils::toStdString(src))) + copy_file(src, ""); + + return err.value() == 0; +} + +/// qDebug print support for the LinkPair struct +QDebug operator<<(QDebug debug, const LinkPair& lp) +{ + QDebugStateSaver saver(debug); + + debug.nospace() << "LinkPair{ src: " << lp.src << " , dst: " << lp.dst << " }"; + return debug; +} + +bool create_link::operator()(const QString& offset, bool dryRun) +{ + m_linked = 0; // reset counter + m_path_results.clear(); + m_links_to_make.clear(); + + m_path_results.clear(); + + make_link_list(offset); + + if (!dryRun) + return make_links(); + + return true; +} + +/** + * @brief Make a list of all the links to make + * @param offset subdirectory of src to link to dest + */ +void create_link::make_link_list(const QString& offset) +{ + for (auto pair : m_path_pairs) { + const QString& srcPath = pair.src; + const QString& dstPath = pair.dst; + + auto src = PathCombine(QDir(srcPath).absolutePath(), offset); + auto dst = PathCombine(QDir(dstPath).absolutePath(), offset); + + // you can't hard link a directory so make sure if we deal with a directory we do so recursively + if (m_useHardLinks) + m_recursive = true; + + // Function that'll do the actual linking + auto link_file = [this, dst](QString src_path, QString relative_dst_path) { + if (m_matcher && (m_matcher(relative_dst_path) != m_whitelist)) { + qDebug() << "path" << relative_dst_path << "in black list or not in whitelist"; + return; + } + + auto dst_path = PathCombine(dst, relative_dst_path); + LinkPair link = { src_path, dst_path }; + m_links_to_make.append(link); + }; + + if ((!m_recursive) || !fs::is_directory(StringUtils::toStdString(src))) { + if (m_debug) + qDebug() << "linking single file or dir:" << src << "to" << dst; + link_file(src, ""); + } else { + if (m_debug) + qDebug().nospace() << "linking recursively: " << src << " to " << dst << ", max_depth: " << m_max_depth; + QDir src_dir(src); + QDirIterator source_it(src, QDir::Filter::Files | QDir::Filter::Hidden, QDirIterator::Subdirectories); + + QStringList linkedPaths; + + while (source_it.hasNext()) { + auto src_path = source_it.next(); + auto relative_path = src_dir.relativeFilePath(src_path); + + if (m_max_depth >= 0 && pathDepth(relative_path) > m_max_depth) { + relative_path = pathTruncate(relative_path, m_max_depth); + src_path = src_dir.filePath(relative_path); + if (linkedPaths.contains(src_path)) { + continue; + } + } + + linkedPaths.append(src_path); + + link_file(src_path, relative_path); + } + } + } +} + +bool create_link::make_links() +{ + for (auto link : m_links_to_make) { + QString src_path = link.src; + QString dst_path = link.dst; + auto src_path_std = StringUtils::toStdString(link.src); + auto dst_path_std = StringUtils::toStdString(link.dst); + + ensureFilePathExists(dst_path); + if (m_useHardLinks) { + if (m_debug) + qDebug() << "making hard link:" << src_path << "to" << dst_path; + fs::create_hard_link(src_path_std, dst_path_std, m_os_err); + } else if (fs::is_directory(src_path_std)) { + if (m_debug) + qDebug() << "making directory_symlink:" << src_path << "to" << dst_path; + fs::create_directory_symlink(src_path_std, dst_path_std, m_os_err); + } else { + if (m_debug) + qDebug() << "making symlink:" << src_path << "to" << dst_path; + fs::create_symlink(src_path_std, dst_path_std, m_os_err); + } + + if (m_os_err) { + qWarning() << "Failed to link files:" << QString::fromStdString(m_os_err.message()); + qDebug() << "Source file:" << src_path; + qDebug() << "Destination file:" << dst_path; + qDebug() << "Error category:" << m_os_err.category().name(); + qDebug() << "Error code:" << m_os_err.value(); + emit linkFailed(src_path, dst_path, QString::fromStdString(m_os_err.message()), m_os_err.value()); + } else { + m_linked++; + emit fileLinked(src_path, dst_path); + } + if (m_os_err) + return false; + } + return true; +} + +void create_link::runPrivileged(const QString& offset) +{ + m_linked = 0; // reset counter + m_path_results.clear(); + m_links_to_make.clear(); + + bool gotResults = false; + + make_link_list(offset); + + QString serverName = BuildConfig.LAUNCHER_APP_BINARY_NAME + "_filelink_server" + StringUtils::getRandomAlphaNumeric(); + + connect(&m_linkServer, &QLocalServer::newConnection, this, [this, &gotResults]() { + qDebug() << "Client connected, sending out pairs"; + // construct block of data to send + QByteArray block; + QDataStream out(&block, QIODevice::WriteOnly); + + qint32 blocksize = quint32(sizeof(quint32)); + for (auto link : m_links_to_make) { + blocksize += quint32(link.src.size()); + blocksize += quint32(link.dst.size()); + } + qDebug() << "About to write block of size:" << blocksize; + out << blocksize; + + out << quint32(m_links_to_make.length()); + for (auto link : m_links_to_make) { + out << link.src; + out << link.dst; + } + + QLocalSocket* clientConnection = m_linkServer.nextPendingConnection(); + connect(clientConnection, &QLocalSocket::disconnected, clientConnection, &QLocalSocket::deleteLater); + + connect(clientConnection, &QLocalSocket::readyRead, this, [&, clientConnection]() { + QDataStream in; + quint32 blockSize = 0; + in.setDevice(clientConnection); + + qDebug() << "Reading path results from client"; + qDebug() << "bytes available" << clientConnection->bytesAvailable(); + + // Relies on the fact that QDataStream serializes a quint32 into + // sizeof(quint32) bytes + if (clientConnection->bytesAvailable() < (int)sizeof(quint32)) + return; + qDebug() << "reading block size"; + in >> blockSize; + + qDebug() << "blocksize is" << blockSize; + qDebug() << "bytes available" << clientConnection->bytesAvailable(); + if (clientConnection->bytesAvailable() < blockSize || in.atEnd()) + return; + + quint32 numResults; + in >> numResults; + qDebug() << "numResults" << numResults; + + for (quint32 i = 0; i < numResults; i++) { + FS::LinkResult result; + in >> result.src; + in >> result.dst; + in >> result.err_msg; + qint32 err_value; + in >> err_value; + result.err_value = err_value; + if (result.err_value) { + qDebug() << "privileged link fail" << result.src << "to" << result.dst << "code" << result.err_value << result.err_msg; + emit linkFailed(result.src, result.dst, result.err_msg, result.err_value); + } else { + qDebug() << "privileged link success" << result.src << "to" << result.dst; + m_linked++; + emit fileLinked(result.src, result.dst); + } + m_path_results.append(result); + } + gotResults = true; + qDebug() << "results received, closing connection"; + clientConnection->close(); + }); + + qint64 byteswritten = clientConnection->write(block); + bool bytesflushed = clientConnection->flush(); + qDebug() << "block flushed" << byteswritten << bytesflushed; + }); + + qDebug() << "Listening on pipe" << serverName; + if (!m_linkServer.listen(serverName)) { + qDebug() << "Unable to start local pipe server on" << serverName << ":" << m_linkServer.errorString(); + return; + } + + ExternalLinkFileProcess* linkFileProcess = new ExternalLinkFileProcess(serverName, m_useHardLinks, this); + connect(linkFileProcess, &ExternalLinkFileProcess::processExited, this, [this, &gotResults]() { emit finishedPrivileged(gotResults); }); + connect(linkFileProcess, &ExternalLinkFileProcess::finished, linkFileProcess, &QObject::deleteLater); + + linkFileProcess->start(); +} + +void ExternalLinkFileProcess::runLinkFile() +{ + QString fileLinkExe = + PathCombine(QCoreApplication::instance()->applicationDirPath(), BuildConfig.LAUNCHER_APP_BINARY_NAME + "_filelink"); + QString params = "-s " + m_server; + + params += " -H " + QVariant(m_useHardLinks).toString(); + +#if defined Q_OS_WIN32 + SHELLEXECUTEINFO ShExecInfo; + + fileLinkExe = fileLinkExe + ".exe"; + + qDebug() << "Running: runas" << fileLinkExe << params; + + LPCWSTR programNameWin = (const wchar_t*)fileLinkExe.utf16(); + LPCWSTR paramsWin = (const wchar_t*)params.utf16(); + + // https://learn.microsoft.com/en-us/windows/win32/api/shellapi/ns-shellapi-shellexecuteinfoa + ShExecInfo.cbSize = sizeof(SHELLEXECUTEINFO); + ShExecInfo.fMask = SEE_MASK_NOCLOSEPROCESS; + ShExecInfo.hwnd = NULL; // Optional. A handle to the owner window, used to display and position any UI that the system might produce + // while executing this function. + ShExecInfo.lpVerb = L"runas"; // elevate to admin, show UAC + ShExecInfo.lpFile = programNameWin; + ShExecInfo.lpParameters = paramsWin; + ShExecInfo.lpDirectory = NULL; + ShExecInfo.nShow = SW_HIDE; + ShExecInfo.hInstApp = NULL; + + ShellExecuteEx(&ShExecInfo); + + WaitForSingleObject(ShExecInfo.hProcess, INFINITE); + CloseHandle(ShExecInfo.hProcess); +#endif + + qDebug() << "Process exited"; +} + +bool moveByCopy(const QString& source, const QString& dest) +{ + if (!copy(source, dest)()) { // copy + qDebug() << "Copy of" << source << "to" << dest << "failed!"; + return false; + } + if (!deletePath(source)) { // remove original + qDebug() << "Deletion of" << source << "failed!"; + return false; + }; + return true; +} + +bool move(const QString& source, const QString& dest) +{ + std::error_code err; + + ensureFilePathExists(dest); + fs::rename(StringUtils::toStdString(source), StringUtils::toStdString(dest), err); + + if (err.value() != 0) { + if (moveByCopy(source, dest)) + return true; + qDebug() << "Move of" << source << "to" << dest << "failed!"; + qWarning() << "Failed to move file:" << QString::fromStdString(err.message()) << QString::number(err.value()); + return false; + } + return true; +} + +bool deletePath(QString path) +{ + std::error_code err; + + fs::remove_all(StringUtils::toStdString(path), err); + + if (err) { + qWarning() << "Failed to remove files:" << QString::fromStdString(err.message()); + } + + return err.value() == 0; +} + +bool trash(QString path, QString* pathInTrash) +{ + // FIXME: Figure out trash in Flatpak. Qt seemingly doesn't use the Trash portal + if (DesktopServices::isFlatpak()) + return false; +#if defined Q_OS_WIN32 + if (IsWindowsServer()) + return false; +#endif + return QFile::moveToTrash(path, pathInTrash); +} + +QString PathCombine(const QString& path1, const QString& path2) +{ + if (!path1.size()) + return path2; + if (!path2.size()) + return path1; + return QDir::cleanPath(path1 + QDir::separator() + path2); +} + +QString PathCombine(const QString& path1, const QString& path2, const QString& path3) +{ + return PathCombine(PathCombine(path1, path2), path3); +} + +QString PathCombine(const QString& path1, const QString& path2, const QString& path3, const QString& path4) +{ + return PathCombine(PathCombine(path1, path2, path3), path4); +} + +QString AbsolutePath(const QString& path) +{ + return QFileInfo(path).absolutePath(); +} + +int pathDepth(const QString& path) +{ + if (path.isEmpty()) + return 0; + + QFileInfo info(path); + + auto parts = QDir::toNativeSeparators(info.path()).split(QDir::separator(), Qt::SkipEmptyParts); + + int numParts = parts.length(); + numParts -= parts.count("."); + numParts -= parts.count("..") * 2; + + return numParts; +} + +QString pathTruncate(const QString& path, int depth) +{ + if (path.isEmpty() || (depth < 0)) + return ""; + + QString trunc = QFileInfo(path).path(); + + if (pathDepth(trunc) > depth) { + return pathTruncate(trunc, depth); + } + + auto parts = QDir::toNativeSeparators(trunc).split(QDir::separator(), Qt::SkipEmptyParts); + + if (parts.startsWith(".") && !path.startsWith(".")) { + parts.removeFirst(); + } + if (QDir::toNativeSeparators(path).startsWith(QDir::separator())) { + parts.prepend(""); + } + + trunc = parts.join(QDir::separator()); + + return trunc; +} + +QString ResolveExecutable(QString path) +{ + if (path.isEmpty()) { + return QString(); + } + if (!path.contains('/')) { + path = QStandardPaths::findExecutable(path); + } + QFileInfo pathInfo(path); + if (!pathInfo.exists() || !pathInfo.isExecutable()) { + return QString(); + } + return pathInfo.absoluteFilePath(); +} + +/** + * Normalize path + * + * Any paths inside the current folder will be normalized to relative paths (to current) + * Other paths will be made absolute + */ +QString NormalizePath(QString path) +{ + QDir a = QDir::currentPath(); + QString currentAbsolute = a.absolutePath(); + + QDir b(path); + QString newAbsolute = b.absolutePath(); + + if (newAbsolute.startsWith(currentAbsolute)) { + return a.relativeFilePath(newAbsolute); + } else { + return newAbsolute; + } +} + +static const QString BAD_WIN_CHARS = "<>:\"|?*\r\n"; +static const QString BAD_NTFS_CHARS = "<>:\"|?*"; +static const QString BAD_HFS_CHARS = ":"; + +static const QString BAD_FILENAME_CHARS = BAD_WIN_CHARS + "\\/"; + +QString RemoveInvalidFilenameChars(QString string, QChar replaceWith) +{ + for (int i = 0; i < string.length(); i++) + if (string.at(i) < ' ' || BAD_FILENAME_CHARS.contains(string.at(i))) + string[i] = replaceWith; + return string; +} + +QString RemoveInvalidPathChars(QString path, QChar replaceWith) +{ + QString invalidChars; +#ifdef Q_OS_WIN + invalidChars = BAD_WIN_CHARS; +#endif + + // the null character is ignored in this check as it was not a problem until now + switch (statFS(path).fsType) { + case FilesystemType::FAT: // similar to NTFS + /* fallthrough */ + case FilesystemType::NTFS: + /* fallthrough */ + case FilesystemType::REFS: // similar to NTFS(should be available only on windows) + invalidChars += BAD_NTFS_CHARS; + break; + // case FilesystemType::EXT: + // case FilesystemType::EXT_2_OLD: + // case FilesystemType::EXT_2_3_4: + // case FilesystemType::XFS: + // case FilesystemType::BTRFS: + // case FilesystemType::NFS: + // case FilesystemType::ZFS: + case FilesystemType::APFS: + /* fallthrough */ + case FilesystemType::HFS: + /* fallthrough */ + case FilesystemType::HFSPLUS: + /* fallthrough */ + case FilesystemType::HFSX: + invalidChars += BAD_HFS_CHARS; + break; + // case FilesystemType::FUSEBLK: + // case FilesystemType::F2FS: + // case FilesystemType::UNKNOWN: + default: + break; + } + + if (invalidChars.size() != 0) { + for (int i = 0; i < path.length(); i++) { + if (path.at(i) < ' ' || invalidChars.contains(path.at(i))) { + path[i] = replaceWith; + } + } + } + + return path; +} + +QString DirNameFromString(QString string, QString inDir) +{ + int num = 0; + QString baseName = RemoveInvalidFilenameChars(string, '-'); + QString dirName; + do { + if (num == 0) { + dirName = baseName; + } else { + dirName = baseName + "(" + QString::number(num) + ")"; + } + + // If it's over 9000 + if (num > 9000) + return ""; + num++; + } while (QFileInfo(PathCombine(inDir, dirName)).exists()); + return dirName; +} + +// Does the folder path contain any '!'? If yes, return true, otherwise false. +// (This is a problem for Java) +bool checkProblemticPathJava(QDir folder) +{ + QString pathfoldername = folder.absolutePath(); + return pathfoldername.contains("!", Qt::CaseInsensitive); +} + +QString getDesktopDir() +{ + return QStandardPaths::writableLocation(QStandardPaths::DesktopLocation); +} + +QString getApplicationsDir() +{ + return QStandardPaths::writableLocation(QStandardPaths::ApplicationsLocation); +} + +QString quoteArgs(const QStringList& args, const QString& wrap, const QString& escapeChar, bool wrapOnlyIfNeeded = false) +{ + QString result; + + auto size = args.size(); + for (int i = 0; i < size; ++i) { + QString arg = args[i]; + arg.replace(wrap, escapeChar); + + bool needsWrapping = !wrapOnlyIfNeeded || arg.contains(' ') || arg.contains('\t') || arg.contains(wrap); + + if (needsWrapping) + result += wrap + arg + wrap; + else + result += arg; + + if (i < size - 1) + result += ' '; + } + + return result; +} + +// Cross-platform Shortcut creation +QString createShortcut(QString destination, QString target, QStringList args, QString name, QString icon) +{ + if (destination.isEmpty()) { + destination = PathCombine(getDesktopDir(), RemoveInvalidFilenameChars(name)); + } + if (!ensureFilePathExists(destination)) { + qWarning() << "Destination path can't be created!"; + return QString(); + } +#if defined(Q_OS_MACOS) + QDir application = destination + ".app/"; + + if (application.exists()) { + qWarning() << "Application already exists!"; + return QString(); + } + + if (!application.mkpath(".")) { + qWarning() << "Couldn't create application"; + return QString(); + } + + QDir content = application.path() + "/Contents/"; + QDir resources = content.path() + "/Resources/"; + QDir binaryDir = content.path() + "/MacOS/"; + QFile info(content.path() + "/Info.plist"); + + if (!(content.mkpath(".") && resources.mkpath(".") && binaryDir.mkpath("."))) { + qWarning() << "Couldn't create directories within application"; + return QString(); + } + if (!info.open(QIODevice::WriteOnly | QIODevice::Text)) { + qWarning() << "Failed to open file" << info.fileName() << "for writing:" << info.errorString(); + return QString(); + } + + QFile(icon).rename(resources.path() + "/Icon.icns"); + + // Create the Command file + QString exec = binaryDir.path() + "/Run.command"; + + QFile f(exec); + if (!f.open(QIODevice::WriteOnly | QIODevice::Text)) { + qWarning() << "Failed to open file" << f.fileName() << "for writing:" << f.errorString(); + return QString(); + } + QTextStream stream(&f); + + auto argstring = quoteArgs(args, "\"", "\\\""); + + stream << "#!/bin/bash" << "\n"; + stream << "\"" << target << "\" " << argstring << "\n"; + + stream.flush(); + f.close(); + + f.setPermissions(f.permissions() | QFileDevice::ExeOwner | QFileDevice::ExeGroup | QFileDevice::ExeOther); + + // Generate the Info.plist + QTextStream infoStream(&info); + infoStream << " \n" + "" + "\n" + "\n" + " CFBundleExecutable\n" + " Run.command\n" // The path to the executable + " CFBundleIconFile\n" + " Icon.icns\n" + " CFBundleName\n" + " " + << name + << "\n" // Name of the application + " CFBundlePackageType\n" + " APPL\n" + " CFBundleShortVersionString\n" + " 1.0\n" + " CFBundleVersion\n" + " 1.0\n" + "\n" + ""; + + return application.path(); +#elif defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) || defined(Q_OS_OPENBSD) + if (!destination.endsWith(".desktop")) // in case of isFlatpak destination is already populated + destination += ".desktop"; + QFile f(destination); + if (!f.open(QIODevice::WriteOnly | QIODevice::Text)) { + qWarning() << "Failed to open file" << f.fileName() << "for writing:" << f.errorString(); + return QString(); + } + QTextStream stream(&f); + + auto argstring = quoteArgs(args, "'", "'\\''"); + + stream << "[Desktop Entry]" << "\n"; + stream << "Type=Application" << "\n"; + stream << "Categories=Game;ActionGame;AdventureGame;Simulation" << "\n"; + stream << "Exec=\"" << target.toLocal8Bit() << "\" " << argstring.toLocal8Bit() << "\n"; + stream << "Name=" << name.toLocal8Bit() << "\n"; + if (!icon.isEmpty()) { + stream << "Icon=" << icon.toLocal8Bit() << "\n"; + } + + stream.flush(); + f.close(); + + f.setPermissions(f.permissions() | QFileDevice::ExeOwner | QFileDevice::ExeGroup | QFileDevice::ExeOther); + + return destination; +#elif defined(Q_OS_WIN) + QFileInfo targetInfo(target); + + if (!targetInfo.exists()) { + qWarning() << "Target file does not exist!"; + return QString(); + } + + target = targetInfo.absoluteFilePath(); + + if (target.length() >= MAX_PATH) { + qWarning() << "Target file path is too long!"; + return QString(); + } + + if (!icon.isEmpty() && icon.length() >= MAX_PATH) { + qWarning() << "Icon path is too long!"; + return QString(); + } + + destination += ".lnk"; + + if (destination.length() >= MAX_PATH) { + qWarning() << "Destination path is too long!"; + return QString(); + } + + auto argStr = quoteArgs(args, "\"", "\\\"", true); + if (argStr.length() >= MAX_PATH) { + qWarning() << "Arguments string is too long!"; + return QString(); + } + + HRESULT hres; + + // ...yes, you need to initialize the entire COM stack just to make a shortcut + hres = CoInitialize(nullptr); + if (FAILED(hres)) { + qWarning() << "Failed to initialize COM!"; + return QString(); + } + + WCHAR wsz[MAX_PATH]; + + IShellLink* psl; + + // create an IShellLink instance - this stores the shortcut's attributes + hres = CoCreateInstance(CLSID_ShellLink, NULL, CLSCTX_INPROC_SERVER, IID_IShellLink, (LPVOID*)&psl); + if (SUCCEEDED(hres)) { + wmemset(wsz, 0, MAX_PATH); + target.toWCharArray(wsz); + psl->SetPath(wsz); + + wmemset(wsz, 0, MAX_PATH); + argStr.toWCharArray(wsz); + psl->SetArguments(wsz); + + wmemset(wsz, 0, MAX_PATH); + targetInfo.absolutePath().toWCharArray(wsz); + psl->SetWorkingDirectory(wsz); // "Starts in" attribute + + if (!icon.isEmpty()) { + wmemset(wsz, 0, MAX_PATH); + icon.toWCharArray(wsz); + psl->SetIconLocation(wsz, 0); + } + + // query an IPersistFile interface from our IShellLink instance + // this is the interface that will actually let us save the shortcut to disk! + IPersistFile* ppf; + hres = psl->QueryInterface(IID_IPersistFile, (LPVOID*)&ppf); + if (SUCCEEDED(hres)) { + wmemset(wsz, 0, MAX_PATH); + destination.toWCharArray(wsz); + hres = ppf->Save(wsz, TRUE); + if (FAILED(hres)) { + qWarning() << "IPresistFile->Save() failed"; + qWarning() << "hres =" << hres; + } + ppf->Release(); + } else { + qWarning() << "Failed to query IPersistFile interface from IShellLink instance"; + qWarning() << "hres =" << hres; + } + psl->Release(); + } else { + qWarning() << "Failed to create IShellLink instance"; + qWarning() << "hres =" << hres; + } + + // go away COM, nobody likes you + CoUninitialize(); + + if (SUCCEEDED(hres)) + return destination; + return QString(); +#else + qWarning("Desktop Shortcuts not supported on your platform!"); + return QString(); +#endif +} + +bool overrideFolder(QString overwritten_path, QString override_path) +{ + using copy_opts = fs::copy_options; + + if (!FS::ensureFolderPathExists(overwritten_path)) + return false; + + std::error_code err; + fs::copy_options opt = copy_opts::recursive | copy_opts::overwrite_existing; + + // FIXME: hello traveller! Apparently std::copy does NOT overwrite existing files on GNU libstdc++ on Windows? + fs::copy(StringUtils::toStdString(override_path), StringUtils::toStdString(overwritten_path), opt, err); + + if (err) { + qCritical() << QString("Failed to apply override from %1 to %2").arg(override_path, overwritten_path); + qCritical() << "Reason:" << QString::fromStdString(err.message()); + } + + return err.value() == 0; +} + +QString getFilesystemTypeName(FilesystemType type) +{ + auto iter = s_filesystem_type_names.constFind(type); + if (iter != s_filesystem_type_names.constEnd()) { + return iter.value().constFirst(); + } + return getFilesystemTypeName(FilesystemType::UNKNOWN); +} + +FilesystemType getFilesystemTypeFuzzy(const QString& name) +{ + for (auto iter = s_filesystem_type_names.constBegin(); iter != s_filesystem_type_names.constEnd(); ++iter) { + auto fs_names = iter.value(); + for (auto fs_name : fs_names) { + if (name.toUpper().contains(fs_name.toUpper())) + return iter.key(); + } + } + return FilesystemType::UNKNOWN; +} + +FilesystemType getFilesystemType(const QString& name) +{ + for (auto iter = s_filesystem_type_names.constBegin(); iter != s_filesystem_type_names.constEnd(); ++iter) { + auto fs_names = iter.value(); + if (fs_names.contains(name.toUpper())) + return iter.key(); + } + return FilesystemType::UNKNOWN; +} + +/** + * @brief path to the near ancestor that exists + * + */ +QString nearestExistentAncestor(const QString& path) +{ + if (QFileInfo::exists(path)) + return path; + + QDir dir(path); + if (!dir.makeAbsolute()) + return {}; + do { + dir.setPath(QDir::cleanPath(dir.filePath(QStringLiteral("..")))); + } while (!dir.exists() && !dir.isRoot()); + + return dir.exists() ? dir.path() : QString(); +} + +/** + * @brief colect information about the filesystem under a file + * + */ +FilesystemInfo statFS(const QString& path) +{ + FilesystemInfo info; + + QStorageInfo storage_info(nearestExistentAncestor(path)); + + info.fsTypeName = storage_info.fileSystemType(); + + info.fsType = getFilesystemTypeFuzzy(info.fsTypeName); + + info.blockSize = storage_info.blockSize(); + info.bytesAvailable = storage_info.bytesAvailable(); + info.bytesFree = storage_info.bytesFree(); + info.bytesTotal = storage_info.bytesTotal(); + + info.name = storage_info.name(); + info.rootPath = storage_info.rootPath(); + + return info; +} + +/** + * @brief if the Filesystem is reflink/clone capable + * + */ +bool canCloneOnFS(const QString& path) +{ + FilesystemInfo info = statFS(path); + return canCloneOnFS(info); +} +bool canCloneOnFS(const FilesystemInfo& info) +{ + return canCloneOnFS(info.fsType); +} +bool canCloneOnFS(FilesystemType type) +{ + return s_clone_filesystems.contains(type); +} + +/** + * @brief if the Filesystem is reflink/clone capable and both paths are on the same device + * + */ +bool canClone(const QString& src, const QString& dst) +{ + auto srcVInfo = statFS(src); + auto dstVInfo = statFS(dst); + + bool sameDevice = srcVInfo.rootPath == dstVInfo.rootPath; + + return sameDevice && canCloneOnFS(srcVInfo) && canCloneOnFS(dstVInfo); +} + +/** + * @brief reflink/clones a directory and it's contents from src to dest + * @param offset subdirectory form src to copy to dest + * @return if there was an error during the filecopy + */ +bool clone::operator()(const QString& offset, bool dryRun) +{ + if (!canClone(m_src.absolutePath(), m_dst.absolutePath())) { + qWarning() << "Can not clone: not same device or not clone/reflink filesystem"; + qDebug() << "Source path:" << m_src.absolutePath(); + qDebug() << "Destination path:" << m_dst.absolutePath(); + emit cloneFailed(m_src.absolutePath(), m_dst.absolutePath()); + return false; + } + + m_cloned = 0; // reset counter + m_failedClones.clear(); + + auto src = PathCombine(m_src.absolutePath(), offset); + auto dst = PathCombine(m_dst.absolutePath(), offset); + + std::error_code err; + + // Function that'll do the actual cloneing + auto cloneFile = [this, dryRun, dst, &err](QString src_path, QString relative_dst_path) { + if (m_matcher && (m_matcher(relative_dst_path) != m_whitelist)) + return; + + auto dst_path = PathCombine(dst, relative_dst_path); + if (!dryRun) { + ensureFilePathExists(dst_path); + clone_file(src_path, dst_path, err); + } + if (err) { + qDebug() << "Failed to clone files: error" << err.value() << "message" << QString::fromStdString(err.message()); + qDebug() << "Source file:" << src_path; + qDebug() << "Destination file:" << dst_path; + m_failedClones.append(qMakePair(src_path, dst_path)); + emit cloneFailed(src_path, dst_path); + return; + } + m_cloned++; + emit fileCloned(src_path, dst_path); + }; + + // We can't use copy_opts::recursive because we need to take into account the + // blacklisted paths, so we iterate over the source directory, and if there's no blacklist + // match, we copy the file. + QDir src_dir(src); + QDirIterator source_it(src, QDir::Filter::Files | QDir::Filter::Hidden, QDirIterator::Subdirectories); + + while (source_it.hasNext()) { + auto src_path = source_it.next(); + auto relative_path = src_dir.relativeFilePath(src_path); + + cloneFile(src_path, relative_path); + } + + // If the root src is not a directory, the previous iterator won't run. + if (!fs::is_directory(StringUtils::toStdString(src))) + cloneFile(src, ""); + + return err.value() == 0; +} + +/** + * @brief clone/reflink file from src to dst + * + */ +bool clone_file(const QString& src, const QString& dst, std::error_code& ec) +{ + auto src_path = StringUtils::toStdString(QDir::toNativeSeparators(QFileInfo(src).absoluteFilePath())); + auto dst_path = StringUtils::toStdString(QDir::toNativeSeparators(QFileInfo(dst).absoluteFilePath())); + + FilesystemInfo srcinfo = statFS(src); + FilesystemInfo dstinfo = statFS(dst); + + if ((srcinfo.rootPath != dstinfo.rootPath) || (srcinfo.fsType != dstinfo.fsType)) { + ec = std::make_error_code(std::errc::not_supported); + qWarning() << "reflink/clone must be to the same device and filesystem! src and dst root filesystems do not match."; + return false; + } + +#if defined(Q_OS_WIN) + + if (!win_ioctl_clone(src_path, dst_path, ec)) { + qDebug() << "failed win_ioctl_clone"; + qWarning() << "clone/reflink not supported on windows outside of btrfs or ReFS!"; + qWarning() << "check out https://github.com/maharmstone/btrfs for btrfs support!"; + return false; + } + +#elif defined(Q_OS_LINUX) + + if (!linux_ficlone(src_path, dst_path, ec)) { + qDebug() << "failed linux_ficlone:"; + return false; + } + +#elif defined(Q_OS_MACOS) + + if (!macos_bsd_clonefile(src_path, dst_path, ec)) { + qDebug() << "failed macos_bsd_clonefile:"; + return false; + } + +#else + + qWarning() << "clone/reflink not supported! unknown OS"; + ec = std::make_error_code(std::errc::not_supported); + return false; + +#endif + + return true; +} + +#if defined(Q_OS_WIN) + +static long RoundUpToPowerOf2(long originalValue, long roundingMultiplePowerOf2) +{ + long mask = roundingMultiplePowerOf2 - 1; + return (originalValue + mask) & ~mask; +} + +bool win_ioctl_clone(const std::wstring& src_path, const std::wstring& dst_path, std::error_code& ec) +{ + /** + * This algorithm inspired from https://github.com/0xbadfca11/reflink + * LICENSE MIT + * + * Additional references + * https://learn.microsoft.com/en-us/windows/win32/api/winioctl/ni-winioctl-fsctl_duplicate_extents_to_file + * https://github.com/microsoft/CopyOnWrite/blob/main/lib/Windows/WindowsCopyOnWriteFilesystem.cs#L94 + */ + + HANDLE hSourceFile = CreateFileW(src_path.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, 0, nullptr); + if (hSourceFile == INVALID_HANDLE_VALUE) { + ec = std::error_code(GetLastError(), std::system_category()); + qDebug() << "Failed to open source file" << src_path.c_str(); + return false; + } + + ULONG fs_flags; + if (!GetVolumeInformationByHandleW(hSourceFile, nullptr, 0, nullptr, nullptr, &fs_flags, nullptr, 0)) { + ec = std::error_code(GetLastError(), std::system_category()); + qDebug() << "Failed to get Filesystem information for" << src_path.c_str(); + CloseHandle(hSourceFile); + return false; + } + if (!(fs_flags & FILE_SUPPORTS_BLOCK_REFCOUNTING)) { + SetLastError(ERROR_NOT_CAPABLE); + ec = std::error_code(GetLastError(), std::system_category()); + qWarning() << "Filesystem at" << src_path.c_str() << "does not support reflink"; + CloseHandle(hSourceFile); + return false; + } + + FILE_END_OF_FILE_INFO sourceFileLength; + if (!GetFileSizeEx(hSourceFile, &sourceFileLength.EndOfFile)) { + ec = std::error_code(GetLastError(), std::system_category()); + qDebug() << "Failed to size of source file" << src_path.c_str(); + CloseHandle(hSourceFile); + return false; + } + FILE_BASIC_INFO sourceFileBasicInfo; + if (!GetFileInformationByHandleEx(hSourceFile, FileBasicInfo, &sourceFileBasicInfo, sizeof(sourceFileBasicInfo))) { + ec = std::error_code(GetLastError(), std::system_category()); + qDebug() << "Failed to source file info" << src_path.c_str(); + CloseHandle(hSourceFile); + return false; + } + ULONG junk; + FSCTL_GET_INTEGRITY_INFORMATION_BUFFER sourceFileIntegrity; + if (!DeviceIoControl(hSourceFile, FSCTL_GET_INTEGRITY_INFORMATION, nullptr, 0, &sourceFileIntegrity, sizeof(sourceFileIntegrity), &junk, + nullptr)) { + ec = std::error_code(GetLastError(), std::system_category()); + qDebug() << "Failed to source file integrity info" << src_path.c_str(); + CloseHandle(hSourceFile); + return false; + } + + HANDLE hDestFile = CreateFileW(dst_path.c_str(), GENERIC_READ | GENERIC_WRITE | DELETE, 0, nullptr, CREATE_NEW, 0, hSourceFile); + + if (hDestFile == INVALID_HANDLE_VALUE) { + ec = std::error_code(GetLastError(), std::system_category()); + qDebug() << "Failed to open dest file" << dst_path.c_str(); + CloseHandle(hSourceFile); + return false; + } + FILE_DISPOSITION_INFO destFileDispose = { TRUE }; + if (!SetFileInformationByHandle(hDestFile, FileDispositionInfo, &destFileDispose, sizeof(destFileDispose))) { + ec = std::error_code(GetLastError(), std::system_category()); + qDebug() << "Failed to set dest file info" << dst_path.c_str(); + CloseHandle(hSourceFile); + CloseHandle(hDestFile); + return false; + } + + if (!DeviceIoControl(hDestFile, FSCTL_SET_SPARSE, nullptr, 0, nullptr, 0, &junk, nullptr)) { + ec = std::error_code(GetLastError(), std::system_category()); + qDebug() << "Failed to set dest sparseness" << dst_path.c_str(); + CloseHandle(hSourceFile); + CloseHandle(hDestFile); + return false; + } + FSCTL_SET_INTEGRITY_INFORMATION_BUFFER setDestFileintegrity = { sourceFileIntegrity.ChecksumAlgorithm, sourceFileIntegrity.Reserved, + sourceFileIntegrity.Flags }; + if (!DeviceIoControl(hDestFile, FSCTL_SET_INTEGRITY_INFORMATION, &setDestFileintegrity, sizeof(setDestFileintegrity), nullptr, 0, + nullptr, nullptr)) { + ec = std::error_code(GetLastError(), std::system_category()); + qDebug() << "Failed to set dest file integrity info" << dst_path.c_str(); + CloseHandle(hSourceFile); + CloseHandle(hDestFile); + return false; + } + if (!SetFileInformationByHandle(hDestFile, FileEndOfFileInfo, &sourceFileLength, sizeof(sourceFileLength))) { + ec = std::error_code(GetLastError(), std::system_category()); + qDebug() << "Failed to set dest file size" << dst_path.c_str(); + CloseHandle(hSourceFile); + CloseHandle(hDestFile); + return false; + } + + const LONG64 splitThreshold = (1LL << 32) - sourceFileIntegrity.ClusterSizeInBytes; + + DUPLICATE_EXTENTS_DATA dupExtent; + dupExtent.FileHandle = hSourceFile; + for (LONG64 offset = 0, remain = RoundUpToPowerOf2(sourceFileLength.EndOfFile.QuadPart, sourceFileIntegrity.ClusterSizeInBytes); + remain > 0; offset += splitThreshold, remain -= splitThreshold) { + dupExtent.SourceFileOffset.QuadPart = dupExtent.TargetFileOffset.QuadPart = offset; + dupExtent.ByteCount.QuadPart = std::min(splitThreshold, remain); + + if (!DeviceIoControl(hDestFile, FSCTL_DUPLICATE_EXTENTS_TO_FILE, &dupExtent, sizeof(dupExtent), nullptr, 0, &junk, nullptr)) { + DWORD err = GetLastError(); + QString additionalMessage; + if (err == ERROR_BLOCK_TOO_MANY_REFERENCES) { + static const int MaxClonesPerFile = 8175; + additionalMessage = + QString( + " This is ERROR_BLOCK_TOO_MANY_REFERENCES and may mean you have surpassed the maximum " + "allowed %1 references for a single file. " + "See " + "https://docs.microsoft.com/en-us/windows-server/storage/refs/block-cloning#functionality-restrictions-and-remarks") + .arg(MaxClonesPerFile); + } + ec = std::error_code(err, std::system_category()); + qDebug() << "Failed copy-on-write cloning of" << src_path.c_str() << "to" << dst_path.c_str() << "with error" << err + << additionalMessage; + CloseHandle(hSourceFile); + CloseHandle(hDestFile); + return false; + } + } + + if (!(sourceFileBasicInfo.FileAttributes & FILE_ATTRIBUTE_SPARSE_FILE)) { + FILE_SET_SPARSE_BUFFER setDestSparse = { FALSE }; + if (!DeviceIoControl(hDestFile, FSCTL_SET_SPARSE, &setDestSparse, sizeof(setDestSparse), nullptr, 0, &junk, nullptr)) { + qDebug() << "Failed to set dest file sparseness" << dst_path.c_str(); + CloseHandle(hSourceFile); + CloseHandle(hDestFile); + return false; + } + } + + sourceFileBasicInfo.CreationTime.QuadPart = 0; + if (!SetFileInformationByHandle(hDestFile, FileBasicInfo, &sourceFileBasicInfo, sizeof(sourceFileBasicInfo))) { + qDebug() << "Failed to set dest file creation time" << dst_path.c_str(); + CloseHandle(hSourceFile); + CloseHandle(hDestFile); + return false; + } + if (!FlushFileBuffers(hDestFile)) { + qDebug() << "Failed to flush dest file buffer" << dst_path.c_str(); + CloseHandle(hSourceFile); + CloseHandle(hDestFile); + return false; + } + destFileDispose = { FALSE }; + bool result = !!SetFileInformationByHandle(hDestFile, FileDispositionInfo, &destFileDispose, sizeof(destFileDispose)); + + CloseHandle(hSourceFile); + CloseHandle(hDestFile); + + return result; +} + +#elif defined(Q_OS_LINUX) + +bool linux_ficlone(const std::string& src_path, const std::string& dst_path, std::error_code& ec) +{ + // https://man7.org/linux/man-pages/man2/ioctl_ficlone.2.html + + int src_fd = open(src_path.c_str(), O_RDONLY); + if (src_fd == -1) { + qDebug() << "Failed to open file:" << src_path.c_str(); + qDebug() << "Error:" << strerror(errno); + ec = std::make_error_code(static_cast(errno)); + return false; + } + int dst_fd = open(dst_path.c_str(), O_CREAT | O_WRONLY | O_TRUNC, S_IRWXU | S_IRWXG | S_IROTH | S_IXOTH); + if (dst_fd == -1) { + qDebug() << "Failed to open file:" << dst_path.c_str(); + qDebug() << "Error:" << strerror(errno); + ec = std::make_error_code(static_cast(errno)); + close(src_fd); + return false; + } + // attempt to clone + if (ioctl(dst_fd, FICLONE, src_fd) == -1) { + qDebug() << "Failed to clone file:" << src_path.c_str() << "to" << dst_path.c_str(); + qDebug() << "Error:" << strerror(errno); + ec = std::make_error_code(static_cast(errno)); + close(src_fd); + close(dst_fd); + return false; + } + if (close(src_fd)) { + qDebug() << "Failed to close file:" << src_path.c_str(); + qDebug() << "Error:" << strerror(errno); + } + if (close(dst_fd)) { + qDebug() << "Failed to close file:" << dst_path.c_str(); + qDebug() << "Error:" << strerror(errno); + } + return true; +} + +#elif defined(Q_OS_MACOS) + +bool macos_bsd_clonefile(const std::string& src_path, const std::string& dst_path, std::error_code& ec) +{ + // clonefile(const char * src, const char * dst, int flags); + // https://www.manpagez.com/man/2/clonefile/ + + qDebug() << "attempting file clone via clonefile" << src_path.c_str() << "to" << dst_path.c_str(); + if (clonefile(src_path.c_str(), dst_path.c_str(), 0) == -1) { + qDebug() << "Failed to clone file:" << src_path.c_str() << "to" << dst_path.c_str(); + qDebug() << "Error:" << strerror(errno); + ec = std::make_error_code(static_cast(errno)); + return false; + } + return true; +} +#endif + +/** + * @brief if the Filesystem is symlink capable + * + */ +bool canLinkOnFS(const QString& path) +{ + FilesystemInfo info = statFS(path); + return canLinkOnFS(info); +} +bool canLinkOnFS(const FilesystemInfo& info) +{ + return canLinkOnFS(info.fsType); +} +bool canLinkOnFS(FilesystemType type) +{ + return !s_non_link_filesystems.contains(type); +} +/** + * @brief if the Filesystem is symlink capable on both ends + * + */ +bool canLink(const QString& src, const QString& dst) +{ + return canLinkOnFS(src) && canLinkOnFS(dst); +} + +uintmax_t hardLinkCount(const QString& path) +{ + std::error_code err; + int count = fs::hard_link_count(StringUtils::toStdString(path), err); + if (err) { + qWarning() << "Failed to count hard links for" << path << ":" << QString::fromStdString(err.message()); + count = 0; + } + return count; +} + +#ifdef Q_OS_WIN +// returns 8.3 file format from long path +QString shortPathName(const QString& file) +{ + auto input = file.toStdWString(); + std::wstring output; + long length = GetShortPathNameW(input.c_str(), NULL, 0); + if (length == 0) + return {}; + // NOTE: this resizing might seem weird... + // when GetShortPathNameW fails, it returns length including null character + // when it succeeds, it returns length excluding null character + // See: https://msdn.microsoft.com/en-us/library/windows/desktop/aa364989(v=vs.85).aspx + output.resize(length); + if (GetShortPathNameW(input.c_str(), (LPWSTR)output.c_str(), length) == 0) + return {}; + output.resize(length - 1); + QString ret = QString::fromStdWString(output); + return ret; +} + +// if the string survives roundtrip through local 8bit encoding... +bool fitsInLocal8bit(const QString& string) +{ + return string == QString::fromLocal8Bit(string.toLocal8Bit()); +} + +QString getPathNameInLocal8bit(const QString& file) +{ + if (!fitsInLocal8bit(file)) { + auto path = shortPathName(file); + if (!path.isEmpty()) { + return path; + } + // in case shortPathName fails just return the path as is + } + return file; +} +#endif + +QString getUniqueResourceName(const QString& filePath) +{ + auto newFileName = filePath; + if (!newFileName.endsWith(".disabled")) { + return newFileName; // prioritize enabled mods + } + newFileName.chop(9); + if (!QFile::exists(newFileName)) { + return filePath; + } + QFileInfo fileInfo(filePath); + auto baseName = fileInfo.completeBaseName(); + auto path = fileInfo.absolutePath(); + + int counter = 1; + do { + if (counter == 1) { + newFileName = FS::PathCombine(path, baseName + ".duplicate"); + } else { + newFileName = FS::PathCombine(path, baseName + ".duplicate" + QString::number(counter)); + } + counter++; + } while (QFile::exists(newFileName)); + + return newFileName; +} +bool removeFiles(QStringList listFile) +{ + bool ret = true; + // For each file + for (int i = 0; i < listFile.count(); i++) { + // Remove + ret = ret && QFile::remove(listFile.at(i)); + } + return ret; +} +} // namespace FS diff --git a/launcher/FileSystem.h b/launcher/FileSystem.h new file mode 100644 index 0000000..f2676b1 --- /dev/null +++ b/launcher/FileSystem.h @@ -0,0 +1,571 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2022 TheKodeToad + * Copyright (C) 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "Exception.h" +#include "Filter.h" + +#include + +#include +#include +#include +#include +#include +#include + +namespace FS { + +class FileSystemException : public ::Exception { + public: + FileSystemException(const QString& message) : Exception(message) {} +}; + +/** + * write data to a file safely + */ +void write(const QString& filename, const QByteArray& data); + +/** + * append data to a file safely + */ +void appendSafe(const QString& filename, const QByteArray& data); + +/** + * append data to a file + */ +void append(const QString& filename, const QByteArray& data); + +/** + * read data from a file safely + */ +QByteArray read(const QString& filename); + +/** + * Update the last changed timestamp of an existing file + */ +bool updateTimestamp(const QString& filename); + +/** + * Creates all the folders in a path for the specified path + * last segment of the path is treated as a file name and is ignored! + */ +bool ensureFilePathExists(QString filenamepath); + +/** + * Creates all the folders in a path for the specified path + * last segment of the path is treated as a folder name and is created! + */ +bool ensureFolderPathExists(const QFileInfo folderPath); + +/** + * Creates all the folders in a path for the specified path + * last segment of the path is treated as a folder name and is created! + */ +bool ensureFolderPathExists(const QString folderPathName); + +/** + * @brief Copies a directory and it's contents from src to dest + */ +class copy : public QObject { + Q_OBJECT + public: + copy(const QString& src, const QString& dst, QObject* parent = nullptr) : QObject(parent) + { + m_src.setPath(src); + m_dst.setPath(dst); + } + copy& followSymlinks(const bool follow) + { + m_followSymlinks = follow; + return *this; + } + copy& matcher(Filter filter) + { + m_matcher = std::move(filter); + return *this; + } + copy& whitelist(bool whitelist) + { + m_whitelist = whitelist; + return *this; + } + copy& overwrite(const bool overwrite) + { + m_overwrite = overwrite; + return *this; + } + + bool operator()(bool dryRun = false) { return operator()(QString(), dryRun); } + + qsizetype totalCopied() { return m_copied; } + qsizetype totalFailed() { return m_failedPaths.length(); } + QStringList failed() { return m_failedPaths; } + + signals: + void fileCopied(const QString& relativeName); + void copyFailed(const QString& relativeName); + // TODO: maybe add a "shouldCopy" signal in the future? + + private: + bool operator()(const QString& offset, bool dryRun = false); + + private: + bool m_followSymlinks = true; + Filter m_matcher = nullptr; + bool m_whitelist = false; + bool m_overwrite = false; + QDir m_src; + QDir m_dst; + qsizetype m_copied; + QStringList m_failedPaths; +}; + +struct LinkPair { + QString src; + QString dst; +}; + +struct LinkResult { + QString src; + QString dst; + QString err_msg; + int err_value; +}; + +class ExternalLinkFileProcess : public QThread { + Q_OBJECT + public: + ExternalLinkFileProcess(QString server, bool useHardLinks, QObject* parent = nullptr) + : QThread(parent), m_useHardLinks(useHardLinks), m_server(server) + {} + + void run() override + { + runLinkFile(); + emit processExited(); + } + + signals: + void processExited(); + + private: + void runLinkFile(); + + bool m_useHardLinks = false; + + QString m_server; +}; + +/** + * @brief links (a file / a directory and it's contents) from src to dest + */ +class create_link : public QObject { + Q_OBJECT + public: + create_link(const QList path_pairs, QObject* parent = nullptr) : QObject(parent) { m_path_pairs.append(path_pairs); } + create_link(const QString& src, const QString& dst, QObject* parent = nullptr) : QObject(parent) + { + LinkPair pair = { src, dst }; + m_path_pairs.append(pair); + } + create_link& useHardLinks(const bool useHard) + { + m_useHardLinks = useHard; + return *this; + } + create_link& matcher(Filter filter) + { + m_matcher = std::move(filter); + return *this; + } + create_link& whitelist(bool whitelist) + { + m_whitelist = whitelist; + return *this; + } + create_link& linkRecursively(bool recursive) + { + m_recursive = recursive; + return *this; + } + create_link& setMaxDepth(int depth) + { + m_max_depth = depth; + return *this; + } + create_link& debug(bool d) + { + m_debug = d; + return *this; + } + + std::error_code getOSError() { return m_os_err; } + + bool operator()(bool dryRun = false) { return operator()(QString(), dryRun); } + + int totalLinked() { return m_linked; } + int totalToLink() { return static_cast(m_links_to_make.size()); } + + void runPrivileged() { runPrivileged(QString()); } + void runPrivileged(const QString& offset); + + QList getResults() { return m_path_results; } + + signals: + void fileLinked(const QString& srcName, const QString& dstName); + void linkFailed(const QString& srcName, const QString& dstName, const QString& err_msg, int err_value); + void finished(); + void finishedPrivileged(bool gotResults); + + private: + bool operator()(const QString& offset, bool dryRun = false); + void make_link_list(const QString& offset); + bool make_links(); + + private: + bool m_useHardLinks = false; + Filter m_matcher = nullptr; + bool m_whitelist = false; + bool m_recursive = true; + + /// @brief >= -1 = infinite, 0 = link files at src/* to dest/*, 1 = link files at src/*/* to dest/*/*, etc. + int m_max_depth = -1; + + QList m_path_pairs; + QList m_path_results; + QList m_links_to_make; + + int m_linked; + bool m_debug = false; + std::error_code m_os_err; + + QLocalServer m_linkServer; +}; + +/** + * @brief moves a file by renaming it + * @param source source file path + * @param dest destination filepath + * + */ +bool move(const QString& source, const QString& dest); + +/** + * Delete a folder recursively + */ +bool deletePath(QString path); + +bool removeFiles(QStringList listFile); + +/** + * Trash a folder / file + */ +bool trash(QString path, QString* pathInTrash = nullptr); + +QString PathCombine(const QString& path1, const QString& path2); +QString PathCombine(const QString& path1, const QString& path2, const QString& path3); +QString PathCombine(const QString& path1, const QString& path2, const QString& path3, const QString& path4); + +QString AbsolutePath(const QString& path); + +/** + * @brief depth of path. "foo.txt" -> 0 , "bar/foo.txt" -> 1, /baz/bar/foo.txt -> 2, etc. + * + * @param path path to measure + * @return int number of components before base path + */ +int pathDepth(const QString& path); + +/** + * @brief cut off segments of path until it is a max of length depth + * + * @param path path to truncate + * @param depth max depth of new path + * @return QString truncated path + */ +QString pathTruncate(const QString& path, int depth); + +/** + * Resolve an executable + * + * Will resolve: + * single executable (by name) + * relative path + * absolute path + * + * @return absolute path to executable or null string + */ +QString ResolveExecutable(QString path); + +/** + * Normalize path + * + * Any paths inside the current directory will be normalized to relative paths (to current) + * Other paths will be made absolute + * + * Returns false if the path logic somehow filed (and normalizedPath in invalid) + */ +QString NormalizePath(QString path); + +QString RemoveInvalidFilenameChars(QString string, QChar replaceWith = '-'); + +QString RemoveInvalidPathChars(QString string, QChar replaceWith = '-'); + +QString DirNameFromString(QString string, QString inDir = "."); + +/// Checks if the a given Path contains "!" +bool checkProblemticPathJava(QDir folder); + +// Get the Directory representing the User's Desktop +QString getDesktopDir(); + +// Get the Directory representing the User's Applications directory +QString getApplicationsDir(); + +// Overrides one folder with the contents of another, preserving items exclusive to the first folder +// Equivalent to doing QDir::rename, but allowing for overrides +bool overrideFolder(QString overwritten_path, QString override_path); + +/** + * Creates a shortcut to the specified target file at the specified destination path. + * Returns null QString if creation failed; otherwise returns the path to the created shortcut. + */ +QString createShortcut(QString destination, QString target, QStringList args, QString name, QString icon); + +enum class FilesystemType { + FAT, + NTFS, + REFS, + EXT, + EXT_2_OLD, + EXT_2_3_4, + XFS, + BTRFS, + NFS, + ZFS, + APFS, + HFS, + HFSPLUS, + HFSX, + FUSEBLK, + F2FS, + BCACHEFS, + UNKNOWN +}; + +/** + * @brief Ordered Mapping of enum types to reported filesystem names + * this mapping is non exsaustive, it just attempts to capture the filesystems which could be reasonalbly be in use . + * all string values are in uppercase, use `QString.toUpper()` or equivalent during lookup. + * + * QMap is ordered + * + */ +static const QMap s_filesystem_type_names = { { FilesystemType::FAT, { "FAT" } }, + { FilesystemType::NTFS, { "NTFS" } }, + { FilesystemType::REFS, { "REFS" } }, + { FilesystemType::EXT_2_OLD, { "EXT_2_OLD", "EXT2_OLD" } }, + { FilesystemType::EXT_2_3_4, + { "EXT2/3/4", "EXT_2_3_4", "EXT2", "EXT3", "EXT4" } }, + { FilesystemType::EXT, { "EXT" } }, + { FilesystemType::XFS, { "XFS" } }, + { FilesystemType::BTRFS, { "BTRFS" } }, + { FilesystemType::NFS, { "NFS" } }, + { FilesystemType::ZFS, { "ZFS" } }, + { FilesystemType::APFS, { "APFS" } }, + { FilesystemType::HFS, { "HFS" } }, + { FilesystemType::HFSPLUS, { "HFSPLUS" } }, + { FilesystemType::HFSX, { "HFSX" } }, + { FilesystemType::FUSEBLK, { "FUSEBLK" } }, + { FilesystemType::F2FS, { "F2FS" } }, + { FilesystemType::BCACHEFS, { "BCACHEFS" } }, + { FilesystemType::UNKNOWN, { "UNKNOWN" } } }; + +/** + * @brief Get the string name of Filesystem enum object + * + * @param type + * @return QString + */ +QString getFilesystemTypeName(FilesystemType type); + +/** + * @brief Get the Filesystem enum object from a name + * Does a lookup of the type name and returns an exact match + * + * @param name + * @return FilesystemType + */ +FilesystemType getFilesystemType(const QString& name); + +/** + * @brief Get the Filesystem enum object from a name + * Does a fuzzy lookup of the type name and returns an apropreate match + * + * @param name + * @return FilesystemType + */ +FilesystemType getFilesystemTypeFuzzy(const QString& name); + +struct FilesystemInfo { + FilesystemType fsType = FilesystemType::UNKNOWN; + QString fsTypeName; + int blockSize; + qint64 bytesAvailable; + qint64 bytesFree; + qint64 bytesTotal; + QString name; + QString rootPath; +}; + +/** + * @brief path to the near ancestor that exists + * + */ +QString nearestExistentAncestor(const QString& path); + +/** + * @brief colect information about the filesystem under a file + * + */ +FilesystemInfo statFS(const QString& path); + +static const QList s_clone_filesystems = { FilesystemType::BTRFS, FilesystemType::APFS, FilesystemType::ZFS, + FilesystemType::XFS, FilesystemType::REFS, FilesystemType::BCACHEFS }; + +/** + * @brief if the Filesystem is reflink/clone capable + * + */ +bool canCloneOnFS(const QString& path); +bool canCloneOnFS(const FilesystemInfo& info); +bool canCloneOnFS(FilesystemType type); + +/** + * @brief if the Filesystems are reflink/clone capable and both are on the same device + * + */ +bool canClone(const QString& src, const QString& dst); + +/** + * @brief Copies a directory and it's contents from src to dest + */ +class clone : public QObject { + Q_OBJECT + public: + clone(const QString& src, const QString& dst, QObject* parent = nullptr) : QObject(parent) + { + m_src.setPath(src); + m_dst.setPath(dst); + } + clone& matcher(Filter filter) + { + m_matcher = std::move(filter); + return *this; + } + clone& whitelist(bool whitelist) + { + m_whitelist = whitelist; + return *this; + } + + bool operator()(bool dryRun = false) { return operator()(QString(), dryRun); } + + qsizetype totalCloned() { return m_cloned; } + qsizetype totalFailed() { return m_failedClones.length(); } + + QList> failed() { return m_failedClones; } + + signals: + void fileCloned(const QString& src, const QString& dst); + void cloneFailed(const QString& src, const QString& dst); + + private: + bool operator()(const QString& offset, bool dryRun = false); + + private: + Filter m_matcher = nullptr; + bool m_whitelist = false; + QDir m_src; + QDir m_dst; + qsizetype m_cloned; + QList> m_failedClones; +}; + +/** + * @brief clone/reflink file from src to dst + * + */ +bool clone_file(const QString& src, const QString& dst, std::error_code& ec); + +#if defined(Q_OS_WIN) +bool win_ioctl_clone(const std::wstring& src_path, const std::wstring& dst_path, std::error_code& ec); +#elif defined(Q_OS_LINUX) +bool linux_ficlone(const std::string& src_path, const std::string& dst_path, std::error_code& ec); +#elif defined(Q_OS_MACOS) || defined(Q_OS_FREEBSD) || defined(Q_OS_OPENBSD) +bool macos_bsd_clonefile(const std::string& src_path, const std::string& dst_path, std::error_code& ec); +#endif + +static const QList s_non_link_filesystems = { + FilesystemType::FAT, +}; + +/** + * @brief if the Filesystem is symlink capable + * + */ +bool canLinkOnFS(const QString& path); +bool canLinkOnFS(const FilesystemInfo& info); +bool canLinkOnFS(FilesystemType type); + +/** + * @brief if the Filesystem is symlink capable on both ends + * + */ +bool canLink(const QString& src, const QString& dst); + +uintmax_t hardLinkCount(const QString& path); + +#ifdef Q_OS_WIN +QString getPathNameInLocal8bit(const QString& file); +#endif + +QString getUniqueResourceName(const QString& filePath); + +} // namespace FS diff --git a/launcher/Filter.h b/launcher/Filter.h new file mode 100644 index 0000000..317f5b0 --- /dev/null +++ b/launcher/Filter.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#include + +using Filter = std::function; + +namespace Filters { +inline Filter inverse(Filter filter) +{ + return [filter = std::move(filter)](const QString& src) { return !filter(src); }; +} + +inline Filter any(QList filters) +{ + return [filters = std::move(filters)](const QString& src) { + for (auto& filter : filters) + if (filter(src)) + return true; + + return false; + }; +} + +inline Filter equals(QString pattern) +{ + return [pattern = std::move(pattern)](const QString& src) { return src == pattern; }; +} + +inline Filter equalsAny(QStringList patterns = {}) +{ + return [patterns = std::move(patterns)](const QString& src) { return patterns.isEmpty() || patterns.contains(src); }; +} + +inline Filter equalsOrEmpty(QString pattern) +{ + return [pattern = std::move(pattern)](const QString& src) { return src.isEmpty() || src == pattern; }; +} + +inline Filter contains(QString pattern) +{ + return [pattern = std::move(pattern)](const QString& src) { return src.contains(pattern); }; +} + +inline Filter startsWith(QString pattern) +{ + return [pattern = std::move(pattern)](const QString& src) { return src.startsWith(pattern); }; +} + +inline Filter regexp(QRegularExpression pattern) +{ + return [pattern = std::move(pattern)](const QString& src) { return pattern.match(src).hasMatch(); }; +} +} // namespace Filters diff --git a/launcher/GZip.cpp b/launcher/GZip.cpp new file mode 100644 index 0000000..201dcd5 --- /dev/null +++ b/launcher/GZip.cpp @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "GZip.h" +#include +#include +#include +#include + +bool GZip::unzip(const QByteArray& compressedBytes, QByteArray& uncompressedBytes) +{ + if (compressedBytes.size() == 0) { + uncompressedBytes = compressedBytes; + return true; + } + + unsigned uncompLength = compressedBytes.size(); + uncompressedBytes.clear(); + uncompressedBytes.resize(uncompLength); + + z_stream strm; + memset(&strm, 0, sizeof(strm)); + strm.next_in = (Bytef*)compressedBytes.data(); + strm.avail_in = compressedBytes.size(); + + bool done = false; + + if (inflateInit2(&strm, (16 + MAX_WBITS)) != Z_OK) { + return false; + } + + int err = Z_OK; + + while (!done) { + // If our output buffer is too small + if (strm.total_out >= uncompLength) { + uncompressedBytes.resize(uncompLength * 2); + uncompLength *= 2; + } + + strm.next_out = reinterpret_cast((uncompressedBytes.data() + strm.total_out)); + strm.avail_out = uncompLength - strm.total_out; + + // Inflate another chunk. + err = inflate(&strm, Z_SYNC_FLUSH); + if (err == Z_STREAM_END) + done = true; + else if (err != Z_OK) { + break; + } + } + + if (inflateEnd(&strm) != Z_OK || !done) { + return false; + } + + uncompressedBytes.resize(strm.total_out); + return true; +} + +bool GZip::zip(const QByteArray& uncompressedBytes, QByteArray& compressedBytes) +{ + if (uncompressedBytes.size() == 0) { + compressedBytes = uncompressedBytes; + return true; + } + + unsigned compLength = qMin(uncompressedBytes.size(), 16); + compressedBytes.clear(); + compressedBytes.resize(compLength); + + z_stream zs; + memset(&zs, 0, sizeof(zs)); + + if (deflateInit2(&zs, Z_DEFAULT_COMPRESSION, Z_DEFLATED, (16 + MAX_WBITS), 8, Z_DEFAULT_STRATEGY) != Z_OK) { + return false; + } + + zs.next_in = (Bytef*)uncompressedBytes.data(); + zs.avail_in = uncompressedBytes.size(); + + int ret; + compressedBytes.resize(uncompressedBytes.size()); + + unsigned offset = 0; + unsigned temp = 0; + do { + auto remaining = compressedBytes.size() - offset; + if (remaining < 1) { + compressedBytes.resize(compressedBytes.size() * 2); + } + zs.next_out = reinterpret_cast((compressedBytes.data() + offset)); + temp = zs.avail_out = compressedBytes.size() - offset; + ret = deflate(&zs, Z_FINISH); + offset += temp - zs.avail_out; + } while (ret == Z_OK); + + compressedBytes.resize(offset); + + if (deflateEnd(&zs) != Z_OK) { + return false; + } + + if (ret != Z_STREAM_END) { + return false; + } + return true; +} + +int inf(QFile* source, std::function handleBlock) +{ + constexpr auto CHUNK = 16384; + int ret; + unsigned have; + z_stream strm; + memset(&strm, 0, sizeof(strm)); + char in[CHUNK]; + unsigned char out[CHUNK]; + + ret = inflateInit2(&strm, (16 + MAX_WBITS)); + if (ret != Z_OK) + return ret; + + /* decompress until deflate stream ends or end of file */ + do { + strm.avail_in = source->read(in, CHUNK); + if (source->error()) { + (void)inflateEnd(&strm); + return Z_ERRNO; + } + if (strm.avail_in == 0) + break; + strm.next_in = reinterpret_cast(in); + + /* run inflate() on input until output buffer not full */ + do { + strm.avail_out = CHUNK; + strm.next_out = out; + ret = inflate(&strm, Z_NO_FLUSH); + assert(ret != Z_STREAM_ERROR); /* state not clobbered */ + switch (ret) { + case Z_NEED_DICT: + ret = Z_DATA_ERROR; + [[fallthrough]]; + case Z_DATA_ERROR: + case Z_MEM_ERROR: + (void)inflateEnd(&strm); + return ret; + } + have = CHUNK - strm.avail_out; + if (!handleBlock(QByteArray(reinterpret_cast(out), have))) { + (void)inflateEnd(&strm); + return Z_OK; + } + + } while (strm.avail_out == 0); + + /* done when inflate() says it's done */ + } while (ret != Z_STREAM_END); + + /* clean up and return */ + (void)inflateEnd(&strm); + return ret == Z_STREAM_END ? Z_OK : Z_DATA_ERROR; +} + +QString zerr(int ret) +{ + switch (ret) { + case Z_ERRNO: + return QObject::tr("error handling file"); + case Z_STREAM_ERROR: + return QObject::tr("invalid compression level"); + case Z_DATA_ERROR: + return QObject::tr("invalid or incomplete deflate data"); + case Z_MEM_ERROR: + return QObject::tr("out of memory"); + case Z_VERSION_ERROR: + return QObject::tr("zlib version mismatch!"); + } + return {}; +} + +QString GZip::readGzFileByBlocks(QFile* source, std::function handleBlock) +{ + auto ret = inf(source, handleBlock); + return zerr(ret); +} diff --git a/launcher/GZip.h b/launcher/GZip.h new file mode 100644 index 0000000..b736ca9 --- /dev/null +++ b/launcher/GZip.h @@ -0,0 +1,11 @@ +#pragma once +#include +#include + +namespace GZip { + +bool unzip(const QByteArray& compressedBytes, QByteArray& uncompressedBytes); +bool zip(const QByteArray& uncompressedBytes, QByteArray& compressedBytes); +QString readGzFileByBlocks(QFile* source, std::function handleBlock); + +} // namespace GZip diff --git a/launcher/HardwareInfo.cpp b/launcher/HardwareInfo.cpp new file mode 100644 index 0000000..5937c33 --- /dev/null +++ b/launcher/HardwareInfo.cpp @@ -0,0 +1,333 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2026 Octol1ttle + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "HardwareInfo.h" + +#include +#include +#include +#include +#include "BuildConfig.h" + +#ifndef Q_OS_MACOS +#include +#include +#endif + +namespace { +bool vulkanInfo(QStringList& out) +{ + if (!QProcessEnvironment::systemEnvironment() + .value(QStringLiteral("%1_DISABLE_GLVULKAN").arg(BuildConfig.LAUNCHER_ENVNAME)) + .isEmpty()) { + return false; + } +#ifndef Q_OS_MACOS + QVulkanInstance inst; + if (!inst.create()) { + qWarning() << "Vulkan instance creation failed, VkResult:" << inst.errorCode(); + out << "Couldn't get Vulkan device information"; + return false; + } + + QVulkanWindow window; + window.setVulkanInstance(&inst); + + for (auto device : window.availablePhysicalDevices()) { + const auto supportedVulkanVersion = QVersionNumber(VK_API_VERSION_MAJOR(device.apiVersion), VK_API_VERSION_MINOR(device.apiVersion), + VK_API_VERSION_PATCH(device.apiVersion)); + out << QString("Found Vulkan device: %1 (API version %2)").arg(device.deviceName).arg(supportedVulkanVersion.toString()); + } +#endif + + return true; +} + +bool openGlInfo(QStringList& out) +{ + if (!QProcessEnvironment::systemEnvironment() + .value(QStringLiteral("%1_DISABLE_GLVULKAN").arg(BuildConfig.LAUNCHER_ENVNAME)) + .isEmpty()) { + return false; + } + QOpenGLContext ctx; + if (!ctx.create()) { + qWarning() << "OpenGL context creation failed"; + out << "Couldn't get OpenGL device information"; + return false; + } + + QOffscreenSurface surface; + surface.create(); + ctx.makeCurrent(&surface); + + auto* f = ctx.functions(); + f->initializeOpenGLFunctions(); + + auto toQString = [](const GLubyte* str) { return QString(reinterpret_cast(str)); }; + out << "OpenGL driver vendor: " + toQString(f->glGetString(GL_VENDOR)); + out << "OpenGL renderer: " + toQString(f->glGetString(GL_RENDERER)); + out << "OpenGL driver version: " + toQString(f->glGetString(GL_VERSION)); + + return true; +} +} // namespace + +#ifndef Q_OS_LINUX +QStringList HardwareInfo::gpuInfo() +{ + QStringList info; + vulkanInfo(info); + openGlInfo(info); + return info; +} +#endif + +#ifdef Q_OS_WINDOWS +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include + +#include "windows.h" + +QString HardwareInfo::cpuInfo() +{ + const QSettings registry(R"(HKEY_LOCAL_MACHINE\HARDWARE\DESCRIPTION\System\CentralProcessor\0)", QSettings::NativeFormat); + return registry.value("ProcessorNameString").toString(); +} + +uint64_t HardwareInfo::totalRamMiB() +{ + MEMORYSTATUSEX status; + status.dwLength = sizeof status; + + if (GlobalMemoryStatusEx(&status) == TRUE) { + // transforming bytes -> mib + return status.ullTotalPhys / 1024 / 1024; + } + + qWarning() << "Could not get total RAM: GlobalMemoryStatusEx"; + return 0; +} + +uint64_t HardwareInfo::availableRamMiB() +{ + MEMORYSTATUSEX status; + status.dwLength = sizeof status; + + if (GlobalMemoryStatusEx(&status) == TRUE) { + // transforming bytes -> mib + return status.ullAvailPhys / 1024 / 1024; + } + + qWarning() << "Could not get available RAM: GlobalMemoryStatusEx"; + return 0; +} + +#elif defined(Q_OS_MACOS) +#include "mach/mach.h" +#include "sys/sysctl.h" + +QString HardwareInfo::cpuInfo() +{ + std::array buffer; + size_t bufferSize = buffer.size(); + if (sysctlbyname("machdep.cpu.brand_string", &buffer, &bufferSize, nullptr, 0) == 0) { + return QString(buffer.data()); + } + + qWarning() << "Could not get CPU model: sysctlbyname"; + return ""; +} + +uint64_t HardwareInfo::totalRamMiB() +{ + uint64_t memsize; + size_t memsizeSize = sizeof memsize; + if (sysctlbyname("hw.memsize", &memsize, &memsizeSize, nullptr, 0) == 0) { + // transforming bytes -> mib + return memsize / 1024 / 1024; + } + + qWarning() << "Could not get total RAM: sysctlbyname"; + return 0; +} + +uint64_t HardwareInfo::availableRamMiB() +{ + mach_port_t host_port = mach_host_self(); + mach_msg_type_number_t count = HOST_VM_INFO64_COUNT; + + vm_statistics64_data_t vm_stats; + + if (host_statistics64(host_port, HOST_VM_INFO64, reinterpret_cast(&vm_stats), &count) == KERN_SUCCESS) { + // transforming bytes -> mib + return (vm_stats.free_count + vm_stats.inactive_count) * vm_page_size / 1024 / 1024; + } + + qWarning() << "Could not get available RAM: host_statistics64"; + return 0; +} + +#elif defined(Q_OS_LINUX) +#include + +namespace { +QString afterColon(QString& str) +{ + return str.remove(0, str.indexOf(':') + 2).trimmed(); +} +} // namespace + +QString HardwareInfo::cpuInfo() +{ + std::ifstream cpuin("/proc/cpuinfo"); + for (std::string line; std::getline(cpuin, line);) { + // model name : AMD Ryzen 7 5800X 8-Core Processor + if (QString str = QString::fromStdString(line); str.startsWith("model name")) { + return afterColon(str); + } + } + + qWarning() << "Could not get CPU model: /proc/cpuinfo"; + return "unknown"; +} + +uint64_t readMemInfo(QString searchTarget) +{ + std::ifstream memin("/proc/meminfo"); + for (std::string line; std::getline(memin, line);) { + // MemTotal: 16287480 kB + if (QString str = QString::fromStdString(line); str.startsWith(searchTarget)) { + bool ok = false; + const uint total = str.simplified().section(' ', 1, 1).toUInt(&ok); + if (!ok) { + qWarning() << "Could not read /proc/meminfo: failed to parse string:" << str; + return 0; + } + + // transforming kib -> mib + return total / 1024; + } + } + + qWarning() << "Could not read /proc/meminfo: search target not found:" << searchTarget; + return 0; +} + +uint64_t HardwareInfo::totalRamMiB() +{ + return readMemInfo("MemTotal"); +} + +uint64_t HardwareInfo::availableRamMiB() +{ + return readMemInfo("MemAvailable"); +} + +QStringList HardwareInfo::gpuInfo() +{ + QStringList list; + const bool vulkanSuccess = vulkanInfo(list); + const bool openGlSuccess = openGlInfo(list); + if (vulkanSuccess || openGlSuccess) { + return list; + } + + std::array buffer; + FILE* lspci = popen("lspci -k", "r"); + + if (!lspci) { + return { "Could not detect GPUs: lspci is not present" }; + } + + bool readingGpuInfo = false; + QString currentModel = ""; + while (fgets(buffer.data(), 512, lspci) != nullptr) { + QString str(buffer.data()); + // clang-format off + // 04:00.0 VGA compatible controller: Advanced Micro Devices, Inc. [AMD/ATI] Ellesmere [Radeon RX 470/480/570/570X/580/580X/590] (rev e7) + // Subsystem: Sapphire Technology Limited Radeon RX 580 Pulse 4GB + // Kernel driver in use: amdgpu + // Kernel modules: amdgpu + // clang-format on + if (str.contains("VGA compatible controller")) { + readingGpuInfo = true; + } else if (!str.startsWith('\t')) { + readingGpuInfo = false; + } + if (!readingGpuInfo) { + continue; + } + + if (str.contains("Subsystem")) { + currentModel = "Found GPU: " + afterColon(str); + } + if (str.contains("Kernel driver in use")) { + currentModel += " (using driver " + afterColon(str); + } + if (str.contains("Kernel modules")) { + currentModel += ", available drivers: " + afterColon(str) + ")"; + list.append(currentModel); + } + } + pclose(lspci); + return list; +} + +#else + +QString HardwareInfo::cpuInfo() +{ + return "unknown"; +} + +#if defined(Q_OS_FREEBSD) || defined(Q_OS_OPENBSD) +#include + +uint64_t HardwareInfo::totalRamMiB() +{ + char buff[512]; + FILE* fp = popen("sysctl hw.physmem", "r"); + if (fp != nullptr) { + if (fgets(buff, 512, fp) != nullptr) { + std::string str(buff); + uint64_t mem = std::stoull(str.substr(12, std::string::npos)); + + // transforming kib -> mib + return mem / 1024; + } + } + + return 0; +} + +#else +uint64_t HardwareInfo::totalRamMiB() +{ + return 0; +} +#endif + +uint64_t HardwareInfo::availableRamMiB() +{ + return 0; +} + +#endif diff --git a/launcher/HardwareInfo.h b/launcher/HardwareInfo.h new file mode 100644 index 0000000..00e19f2 --- /dev/null +++ b/launcher/HardwareInfo.h @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2026 Octol1ttle + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include + +namespace HardwareInfo { +QString cpuInfo(); +uint64_t totalRamMiB(); +uint64_t availableRamMiB(); +QStringList gpuInfo(); +} // namespace HardwareInfo diff --git a/launcher/InstanceCopyPrefs.cpp b/launcher/InstanceCopyPrefs.cpp new file mode 100644 index 0000000..087b373 --- /dev/null +++ b/launcher/InstanceCopyPrefs.cpp @@ -0,0 +1,192 @@ +// +// Created by marcelohdez on 10/22/22. +// + +#include "InstanceCopyPrefs.h" + +bool InstanceCopyPrefs::allTrue() const +{ + return copySaves && keepPlaytime && copyGameOptions && copyResourcePacks && copyShaderPacks && copyServers && copyMods && + copyScreenshots; +} + +// Returns a single RegEx string of the selected folders/files to filter out (ex: ".minecraft/saves|.minecraft/server.dat") +QString InstanceCopyPrefs::getSelectedFiltersAsRegex() const +{ + return getSelectedFiltersAsRegex({}); +} +QString InstanceCopyPrefs::getSelectedFiltersAsRegex(const QStringList& additionalFilters) const +{ + QStringList filters; + + if (!copySaves) + filters << "saves"; + + if (!copyGameOptions) + filters << "options.txt"; + + if (!copyResourcePacks) + filters << "resourcepacks" + << "texturepacks"; + + if (!copyShaderPacks) + filters << "shaderpacks"; + + if (!copyServers) + filters << "servers.dat" + << "servers.dat_old" + << "server-resource-packs"; + + if (!copyMods) + filters << "coremods" + << "mods" + << "config"; + + if (!copyScreenshots) + filters << "screenshots"; + + for (auto filter : additionalFilters) { + filters << filter; + } + + // If we have any filters to add, join them as a single regex string to return: + if (!filters.isEmpty()) { + const QString MC_ROOT = "[.]?minecraft/"; + // Ensure first filter starts with root, then join other filters with OR regex before root (ex: ".minecraft/saves|.minecraft/mods"): + return MC_ROOT + filters.join("|" + MC_ROOT); + } + + return {}; +} + +// ======= Getters ======= +bool InstanceCopyPrefs::isCopySavesEnabled() const +{ + return copySaves; +} + +bool InstanceCopyPrefs::isKeepPlaytimeEnabled() const +{ + return keepPlaytime; +} + +bool InstanceCopyPrefs::isCopyGameOptionsEnabled() const +{ + return copyGameOptions; +} + +bool InstanceCopyPrefs::isCopyResourcePacksEnabled() const +{ + return copyResourcePacks; +} + +bool InstanceCopyPrefs::isCopyShaderPacksEnabled() const +{ + return copyShaderPacks; +} + +bool InstanceCopyPrefs::isCopyServersEnabled() const +{ + return copyServers; +} + +bool InstanceCopyPrefs::isCopyModsEnabled() const +{ + return copyMods; +} + +bool InstanceCopyPrefs::isCopyScreenshotsEnabled() const +{ + return copyScreenshots; +} + +bool InstanceCopyPrefs::isUseSymLinksEnabled() const +{ + return useSymLinks; +} + +bool InstanceCopyPrefs::isUseHardLinksEnabled() const +{ + return useHardLinks; +} + +bool InstanceCopyPrefs::isLinkRecursivelyEnabled() const +{ + return linkRecursively; +} + +bool InstanceCopyPrefs::isDontLinkSavesEnabled() const +{ + return dontLinkSaves; +} + +bool InstanceCopyPrefs::isUseCloneEnabled() const +{ + return useClone; +} + +// ======= Setters ======= +void InstanceCopyPrefs::enableCopySaves(bool b) +{ + copySaves = b; +} + +void InstanceCopyPrefs::enableKeepPlaytime(bool b) +{ + keepPlaytime = b; +} + +void InstanceCopyPrefs::enableCopyGameOptions(bool b) +{ + copyGameOptions = b; +} + +void InstanceCopyPrefs::enableCopyResourcePacks(bool b) +{ + copyResourcePacks = b; +} + +void InstanceCopyPrefs::enableCopyShaderPacks(bool b) +{ + copyShaderPacks = b; +} + +void InstanceCopyPrefs::enableCopyServers(bool b) +{ + copyServers = b; +} + +void InstanceCopyPrefs::enableCopyMods(bool b) +{ + copyMods = b; +} + +void InstanceCopyPrefs::enableCopyScreenshots(bool b) +{ + copyScreenshots = b; +} + +void InstanceCopyPrefs::enableUseSymLinks(bool b) +{ + useSymLinks = b; +} + +void InstanceCopyPrefs::enableLinkRecursively(bool b) +{ + linkRecursively = b; +} + +void InstanceCopyPrefs::enableUseHardLinks(bool b) +{ + useHardLinks = b; +} + +void InstanceCopyPrefs::enableDontLinkSaves(bool b) +{ + dontLinkSaves = b; +} + +void InstanceCopyPrefs::enableUseClone(bool b) +{ + useClone = b; +} diff --git a/launcher/InstanceCopyPrefs.h b/launcher/InstanceCopyPrefs.h new file mode 100644 index 0000000..1c3c0c9 --- /dev/null +++ b/launcher/InstanceCopyPrefs.h @@ -0,0 +1,57 @@ +// +// Created by marcelohdez on 10/22/22. +// + +#pragma once + +#include + +struct InstanceCopyPrefs { + public: + bool allTrue() const; + QString getSelectedFiltersAsRegex() const; + QString getSelectedFiltersAsRegex(const QStringList& additionalFilters) const; + // Getters + bool isCopySavesEnabled() const; + bool isKeepPlaytimeEnabled() const; + bool isCopyGameOptionsEnabled() const; + bool isCopyResourcePacksEnabled() const; + bool isCopyShaderPacksEnabled() const; + bool isCopyServersEnabled() const; + bool isCopyModsEnabled() const; + bool isCopyScreenshotsEnabled() const; + bool isUseSymLinksEnabled() const; + bool isLinkRecursivelyEnabled() const; + bool isUseHardLinksEnabled() const; + bool isDontLinkSavesEnabled() const; + bool isUseCloneEnabled() const; + // Setters + void enableCopySaves(bool b); + void enableKeepPlaytime(bool b); + void enableCopyGameOptions(bool b); + void enableCopyResourcePacks(bool b); + void enableCopyShaderPacks(bool b); + void enableCopyServers(bool b); + void enableCopyMods(bool b); + void enableCopyScreenshots(bool b); + void enableUseSymLinks(bool b); + void enableLinkRecursively(bool b); + void enableUseHardLinks(bool b); + void enableDontLinkSaves(bool b); + void enableUseClone(bool b); + + protected: // data + bool copySaves = true; + bool keepPlaytime = true; + bool copyGameOptions = true; + bool copyResourcePacks = true; + bool copyShaderPacks = true; + bool copyServers = true; + bool copyMods = true; + bool copyScreenshots = true; + bool useSymLinks = false; + bool linkRecursively = false; + bool useHardLinks = false; + bool dontLinkSaves = false; + bool useClone = false; +}; diff --git a/launcher/InstanceCopyTask.cpp b/launcher/InstanceCopyTask.cpp new file mode 100644 index 0000000..e32cdf0 --- /dev/null +++ b/launcher/InstanceCopyTask.cpp @@ -0,0 +1,199 @@ +#include "InstanceCopyTask.h" +#include +#include +#include +#include "FileSystem.h" +#include "Filter.h" +#include "NullInstance.h" +#include "settings/INISettingsObject.h" +#include "tasks/Task.h" + +InstanceCopyTask::InstanceCopyTask(BaseInstance* origInstance, const InstanceCopyPrefs& prefs) +{ + m_origInstance = origInstance; + m_keepPlaytime = prefs.isKeepPlaytimeEnabled(); + m_useLinks = prefs.isUseSymLinksEnabled(); + m_linkRecursively = prefs.isLinkRecursivelyEnabled(); + m_useHardLinks = prefs.isLinkRecursivelyEnabled() && prefs.isUseHardLinksEnabled(); + m_copySaves = prefs.isLinkRecursivelyEnabled() && prefs.isDontLinkSavesEnabled() && prefs.isCopySavesEnabled(); + m_useClone = prefs.isUseCloneEnabled(); + + QString filters = prefs.getSelectedFiltersAsRegex(); + if (m_useLinks || m_useHardLinks) { + if (!filters.isEmpty()) + filters += "|"; + filters += "instance.cfg"; + } + + qDebug() << "CopyFilters:" << filters; + + if (!filters.isEmpty()) { + // Set regex filter: + // FIXME: get this from the original instance type... + QRegularExpression regexp(filters, QRegularExpression::CaseInsensitiveOption); + m_matcher = Filters::regexp(regexp); + } +} + +void InstanceCopyTask::executeTask() +{ + setStatus(tr("Copying instance %1").arg(m_origInstance->name())); + + m_copyFuture = QtConcurrent::run(QThreadPool::globalInstance(), [this] { + if (m_useClone) { + FS::clone folderClone(m_origInstance->instanceRoot(), m_stagingPath); + folderClone.matcher(m_matcher); + + folderClone(true); + setProgress(0, folderClone.totalCloned()); + connect(&folderClone, &FS::clone::fileCloned, + [this](QString src, QString dst) { setProgress(m_progress + 1, m_progressTotal); }); + return folderClone(); + } + if (m_useLinks || m_useHardLinks) { + std::unique_ptr savesCopy; + if (m_copySaves) { + QFileInfo mcDir(FS::PathCombine(m_stagingPath, "minecraft")); + QFileInfo dotMCDir(FS::PathCombine(m_stagingPath, ".minecraft")); + + QString staging_mc_dir; + if (dotMCDir.exists() && !mcDir.exists()) + staging_mc_dir = dotMCDir.filePath(); + else + staging_mc_dir = mcDir.filePath(); + + savesCopy = std::make_unique(FS::PathCombine(m_origInstance->gameRoot(), "saves"), + FS::PathCombine(staging_mc_dir, "saves")); + (*savesCopy)(true); + setProgress(0, savesCopy->totalCopied()); + connect(savesCopy.get(), &FS::copy::fileCopied, [this](QString src) { setProgress(m_progress + 1, m_progressTotal); }); + } + FS::create_link folderLink(m_origInstance->instanceRoot(), m_stagingPath); + int depth = m_linkRecursively ? -1 : 0; // we need to at least link the top level instead of the instance folder + folderLink.linkRecursively(true).setMaxDepth(depth).useHardLinks(m_useHardLinks).matcher(m_matcher); + + folderLink(true); + setProgress(0, m_progressTotal + folderLink.totalToLink()); + connect(&folderLink, &FS::create_link::fileLinked, + [this](QString src, QString dst) { setProgress(m_progress + 1, m_progressTotal); }); + bool there_were_errors = false; + + if (!folderLink()) { +#if defined Q_OS_WIN32 + if (!m_useHardLinks) { + setProgress(0, m_progressTotal); + qDebug() << "EXPECTED: Link failure, Windows requires permissions for symlinks"; + + qDebug() << "attempting to run with privelage"; + + QEventLoop loop; + bool got_priv_results = false; + + connect(&folderLink, &FS::create_link::finishedPrivileged, this, [&got_priv_results, &loop](bool gotResults) { + if (!gotResults) { + qDebug() << "Privileged run exited without results!"; + } + got_priv_results = gotResults; + loop.quit(); + }); + folderLink.runPrivileged(); + + loop.exec(); // wait for the finished signal + + for (auto result : folderLink.getResults()) { + if (result.err_value != 0) { + there_were_errors = true; + } + } + + if (savesCopy) { + there_were_errors |= !(*savesCopy)(); + } + + return got_priv_results && !there_were_errors; + } +#else + qDebug() << "Link Failed!" << folderLink.getOSError().value() << folderLink.getOSError().message().c_str(); +#endif + return false; + } + + if (savesCopy) { + there_were_errors |= !(*savesCopy)(); + } + + return !there_were_errors; + } + FS::copy folderCopy(m_origInstance->instanceRoot(), m_stagingPath); + folderCopy.matcher(m_matcher); + + folderCopy(true); + setProgress(0, folderCopy.totalCopied()); + connect(&folderCopy, &FS::copy::fileCopied, [this]() { setProgress(m_progress + 1, m_progressTotal); }); + return folderCopy(); + }); + connect(&m_copyFutureWatcher, &QFutureWatcher::finished, this, &InstanceCopyTask::copyFinished); + connect(&m_copyFutureWatcher, &QFutureWatcher::canceled, this, &InstanceCopyTask::copyAborted); + m_copyFutureWatcher.setFuture(m_copyFuture); +} + +void InstanceCopyTask::copyFinished() +{ + auto successful = m_copyFuture.result(); + if (!successful) { + emitFailed(tr("Instance folder copy failed.")); + return; + } + + // FIXME: shouldn't this be able to report errors? + auto instanceSettings = std::make_unique(FS::PathCombine(m_stagingPath, "instance.cfg")); + + BaseInstance* inst(new NullInstance(m_globalSettings, std::move(instanceSettings), m_stagingPath)); + inst->setName(name()); + inst->setIconKey(m_instIcon); + if (!m_keepPlaytime) { + inst->resetTimePlayed(); + } + if (m_useLinks) { + inst->addLinkedInstanceId(m_origInstance->id()); + auto allowed_symlinks_file = QFileInfo(FS::PathCombine(inst->gameRoot(), "allowed_symlinks.txt")); + + QByteArray allowed_symlinks; + if (allowed_symlinks_file.exists()) { + allowed_symlinks.append(FS::read(allowed_symlinks_file.filePath())); + if (allowed_symlinks.right(1) != "\n") + allowed_symlinks.append("\n"); // we want to be on a new line + } + allowed_symlinks.append(m_origInstance->gameRoot().toUtf8()); + allowed_symlinks.append("\n"); + if (allowed_symlinks_file.isSymLink()) + FS::deletePath( + allowed_symlinks_file + .filePath()); // we dont want to modify the original. also make sure the resulting file is not itself a link. + + try { + FS::write(allowed_symlinks_file.filePath(), allowed_symlinks); + } catch (const FS::FileSystemException& e) { + qCritical() << "Failed to write symlink :" << e.cause(); + } + } + + emitSucceeded(); +} + +void InstanceCopyTask::copyAborted() +{ + emitFailed(tr("Instance folder copy has been aborted.")); + return; +} + +bool InstanceCopyTask::abort() +{ + if (m_copyFutureWatcher.isRunning()) { + m_copyFutureWatcher.cancel(); + // NOTE: Here we don't do `emitAborted()` because it will be done when `m_copyFutureWatcher` actually cancels, which may not occur + // immediately. + return true; + } + return false; +} diff --git a/launcher/InstanceCopyTask.h b/launcher/InstanceCopyTask.h new file mode 100644 index 0000000..a926af8 --- /dev/null +++ b/launcher/InstanceCopyTask.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include +#include "BaseInstance.h" +#include "BaseVersion.h" +#include "Filter.h" +#include "InstanceCopyPrefs.h" +#include "InstanceTask.h" +#include "net/NetJob.h" +#include "settings/SettingsObject.h" +#include "tasks/Task.h" + +class InstanceCopyTask : public InstanceTask { + Q_OBJECT + public: + explicit InstanceCopyTask(BaseInstance* origInstance, const InstanceCopyPrefs& prefs); + + protected: + //! Entry point for tasks. + virtual void executeTask() override; + bool abort() override; + void copyFinished(); + void copyAborted(); + + private: + /* data */ + BaseInstance* m_origInstance; + QFuture m_copyFuture; + QFutureWatcher m_copyFutureWatcher; + Filter m_matcher; + bool m_keepPlaytime; + bool m_useLinks = false; + bool m_useHardLinks = false; + bool m_copySaves = false; + bool m_linkRecursively = false; + bool m_useClone = false; +}; diff --git a/launcher/InstanceCreationTask.cpp b/launcher/InstanceCreationTask.cpp new file mode 100644 index 0000000..ecc9fe5 --- /dev/null +++ b/launcher/InstanceCreationTask.cpp @@ -0,0 +1,135 @@ +#include "InstanceCreationTask.h" + +#include +#include + +#include "InstanceTask.h" +#include "minecraft/MinecraftLoadAndCheck.h" +#include "tasks/SequentialTask.h" + +bool InstanceCreationTask::abort() +{ + if (!canAbort()) { + return false; + } + + m_abort = true; + if (m_gameFilesTask) { + return m_gameFilesTask->abort(); + } + + return true; +} + +void InstanceCreationTask::executeTask() +{ + setAbortable(true); + + if (updateInstance()) { + emitSucceeded(); + return; + } + + // When the user aborted in the update stage. + if (m_abort) { + emitAborted(); + return; + } + + m_instance = createInstance(); + if (!m_instance) { + if (m_abort) + return; + + qWarning() << "Instance creation failed!"; + if (!m_error_message.isEmpty()) { + qWarning() << "Reason:" << m_error_message; + emitFailed(tr("Error while creating new instance:\n%1").arg(m_error_message)); + } else { + emitFailed(tr("Error while creating new instance.")); + } + + return; + } + + // If this is set, it means we're updating an instance. So, we now need to remove the + // files scheduled to, and we'd better not let the user abort in the middle of it, since it'd + // put the instance in an invalid state. + if (shouldOverride()) { + bool deleteFailed = false; + + setAbortable(false); + setStatus(tr("Removing old conflicting files...")); + qDebug() << "Removing old files"; + + for (const QString& path : m_filesToRemove) { + if (!QFile::exists(path)) + continue; + + qDebug() << "Removing" << path; + + if (!QFile::remove(path)) { + qCritical() << "Could not remove" << path; + deleteFailed = true; + } + } + + if (deleteFailed) { + emitFailed(tr("Failed to remove old conflicting files.")); + return; + } + } + + if (!m_abort) { + setAbortable(true); + setAbortButtonText(tr("Skip")); + qDebug() << "Downloading game files"; + + auto updateTasks = m_instance->createUpdateTask(); + if (updateTasks.isEmpty()) { + emitSucceeded(); + return; + } + auto task = makeShared(); + task->addTask(makeShared(m_instance.get(), Net::Mode::Online)); + for (const auto& t : updateTasks) { + task->addTask(t); + } + connect(task.get(), &Task::finished, this, [this, task] { + if (task->wasSuccessful() || m_abort) { + emitSucceeded(); + } else { + emitFailed(tr("Could not download game files: %1").arg(task->failReason())); + } + }); + propagateFromOther(task.get()); + setDetails(tr("Downloading game files")); + + m_gameFilesTask = task; + m_gameFilesTask->start(); + } +} + +void InstanceCreationTask::scheduleToDelete(QWidget* parent, QDir dir, QString path, bool checkDisabled) +{ + if (path.isEmpty()) { + return; + } + if (path.startsWith("saves/")) { + if (m_shouldDeleteSaves == ShouldDeleteSaves::NotAsked) { + m_shouldDeleteSaves = askIfShouldDeleteSaves(parent); + } + if (m_shouldDeleteSaves == ShouldDeleteSaves::No) { + return; + } + } + qDebug() << "Scheduling" << path << "for removal"; + m_filesToRemove.append(dir.absoluteFilePath(path)); + if (checkDisabled) { + if (path.endsWith(".disabled")) { // remove it if it was enabled/disabled by user + m_filesToRemove.append(dir.absoluteFilePath(path.chopped(9))); + } else { + m_filesToRemove.append(dir.absoluteFilePath(path + ".disabled")); + } + } +} diff --git a/launcher/InstanceCreationTask.h b/launcher/InstanceCreationTask.h new file mode 100644 index 0000000..416cf81 --- /dev/null +++ b/launcher/InstanceCreationTask.h @@ -0,0 +1,53 @@ +#pragma once + +#include "BaseVersion.h" +#include "InstanceTask.h" +#include "minecraft/MinecraftInstance.h" + +class InstanceCreationTask : public InstanceTask { + Q_OBJECT + public: + InstanceCreationTask() = default; + virtual ~InstanceCreationTask() = default; + + bool abort() override; + + protected: + void executeTask() final override; + + /** + * Tries to update an already existing instance. + * + * This can be implemented by subclasses to provide a way of updating an already existing + * instance, according to that implementation's concept of 'identity' (i.e. instances that + * are updates / downgrades of one another). + * + * If this returns true, createInstance() will not run, so you should do all update steps in here. + * Otherwise, createInstance() is run as normal. + */ + virtual bool updateInstance() { return false; }; + + /** + * Creates a new instance. + * + * Returns the instance if it was created or nullptr otherwise. + */ + virtual std::unique_ptr createInstance() { return nullptr; } + + QString getError() const { return m_error_message; } + + protected: + void setError(const QString& message) { m_error_message = message; }; + void scheduleToDelete(QWidget* parent, QDir dir, QString path, bool checkDisabled = false); + + protected: + bool m_abort = false; + + QStringList m_filesToRemove; + ShouldDeleteSaves m_shouldDeleteSaves; + + private: + QString m_error_message; + std::unique_ptr m_instance; + Task::Ptr m_gameFilesTask; +}; diff --git a/launcher/InstanceDirUpdate.cpp b/launcher/InstanceDirUpdate.cpp new file mode 100644 index 0000000..75fbdb6 --- /dev/null +++ b/launcher/InstanceDirUpdate.cpp @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "InstanceDirUpdate.h" + +#include + +#include "Application.h" +#include "FileSystem.h" + +#include "InstanceList.h" +#include "ui/dialogs/CustomMessageBox.h" + +QString askToUpdateInstanceDirName(BaseInstance* instance, const QString& oldName, const QString& newName, QWidget* parent) +{ + if (oldName == newName) + return QString(); + + QString renamingMode = APPLICATION->settings()->get("InstRenamingMode").toString(); + if (renamingMode == "MetadataOnly") + return QString(); + + auto oldRoot = instance->instanceRoot(); + auto newDirName = FS::DirNameFromString(newName, QFileInfo(oldRoot).dir().absolutePath()); + auto newRoot = FS::PathCombine(QFileInfo(oldRoot).dir().absolutePath(), newDirName); + if (oldRoot == newRoot) + return QString(); + if (oldRoot == FS::PathCombine(QFileInfo(oldRoot).dir().absolutePath(), newName)) + return QString(); + + // Check for conflict + if (QDir(newRoot).exists()) { + QMessageBox::warning(parent, QObject::tr("Cannot rename instance"), + QObject::tr("New instance root (%1) already exists.
Only the metadata will be renamed.").arg(newRoot)); + return QString(); + } + + // Ask if we should rename + if (renamingMode == "AskEverytime") { + auto checkBox = new QCheckBox(QObject::tr("&Remember my choice"), parent); + auto dialog = + CustomMessageBox::selectable(parent, QObject::tr("Rename instance folder"), + QObject::tr("Would you also like to rename the instance folder?\n\n" + "Old name: %1\n" + "New name: %2") + .arg(oldName, newName), + QMessageBox::Question, QMessageBox::No | QMessageBox::Yes, QMessageBox::NoButton, checkBox); + + auto res = dialog->exec(); + if (checkBox->isChecked()) { + if (res == QMessageBox::Yes) + APPLICATION->settings()->set("InstRenamingMode", "PhysicalDir"); + else + APPLICATION->settings()->set("InstRenamingMode", "MetadataOnly"); + } + if (res == QMessageBox::No) + return QString(); + } + + // Check for linked instances + if (!checkLinkedInstances(instance->id(), parent, QObject::tr("Renaming"))) + return QString(); + + // Now we can confirm that a renaming is happening + if (!instance->syncInstanceDirName(newRoot)) { + QMessageBox::warning(parent, QObject::tr("Cannot rename instance"), + QObject::tr("An error occurred when performing the following renaming operation:
" + " - Old instance root: %1
" + " - New instance root: %2
" + "Only the metadata is renamed.") + .arg(oldRoot, newRoot)); + return QString(); + } + return newRoot; +} + +bool checkLinkedInstances(const QString& id, QWidget* parent, const QString& verb) +{ + auto linkedInstances = APPLICATION->instances()->getLinkedInstancesById(id); + if (!linkedInstances.empty()) { + auto response = CustomMessageBox::selectable(parent, QObject::tr("There are linked instances"), + QObject::tr("The following instance(s) might reference files in this instance:\n\n" + "%1\n\n" + "%2 it could break the other instance(s), \n\n" + "Do you wish to proceed?", + nullptr, linkedInstances.count()) + .arg(linkedInstances.join("\n")) + .arg(verb), + QMessageBox::Warning, QMessageBox::Yes | QMessageBox::No, QMessageBox::No) + ->exec(); + if (response != QMessageBox::Yes) + return false; + } + return true; +} diff --git a/launcher/InstanceDirUpdate.h b/launcher/InstanceDirUpdate.h new file mode 100644 index 0000000..9da49a9 --- /dev/null +++ b/launcher/InstanceDirUpdate.h @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "BaseInstance.h" + +/// Update instanceRoot to make it sync with name/id; return newRoot if a directory rename happened +QString askToUpdateInstanceDirName(BaseInstance* instance, const QString& oldName, const QString& newName, QWidget* parent); + +/// Check if there are linked instances, and display a warning; return true if the operation should proceed +bool checkLinkedInstances(const QString& id, QWidget* parent, const QString& verb); diff --git a/launcher/InstanceImportTask.cpp b/launcher/InstanceImportTask.cpp new file mode 100644 index 0000000..9b04f99 --- /dev/null +++ b/launcher/InstanceImportTask.cpp @@ -0,0 +1,432 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (c) 2022 flowln + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "InstanceImportTask.h" + +#include "Application.h" +#include "FileSystem.h" +#include "NullInstance.h" + +#include "QObjectPtr.h" +#include "archive/ArchiveReader.h" +#include "archive/ExtractZipTask.h" +#include "icons/IconList.h" +#include "icons/IconUtils.h" + +#include "modplatform/flame/FlameInstanceCreationTask.h" +#include "modplatform/modrinth/ModrinthInstanceCreationTask.h" +#include "modplatform/technic/TechnicPackProcessor.h" + +#include "settings/INISettingsObject.h" +#include "tasks/Task.h" + +#include "net/ApiDownload.h" + +#include +#include +#include + +InstanceImportTask::InstanceImportTask(const QUrl& sourceUrl, QWidget* parent, QMap&& extra_info) + : m_sourceUrl(sourceUrl), m_extra_info(extra_info), m_parent(parent) +{} + +bool InstanceImportTask::abort() +{ + if (!canAbort()) + return false; + + bool wasAborted = false; + if (m_task) + wasAborted = m_task->abort(); + return wasAborted; +} + +void InstanceImportTask::executeTask() +{ + setAbortable(true); + + if (m_sourceUrl.isLocalFile()) { + m_archivePath = m_sourceUrl.toLocalFile(); + processZipPack(); + } else { + setStatus(tr("Downloading modpack:\n%1").arg(m_sourceUrl.toString())); + + downloadFromUrl(); + } +} + +void InstanceImportTask::downloadFromUrl() +{ + const QString path(m_sourceUrl.host() + '/' + m_sourceUrl.path()); + + auto entry = APPLICATION->metacache()->resolveEntry("general", path); + entry->setStale(true); + m_archivePath = entry->getFullPath(); + + auto filesNetJob = makeShared(tr("Modpack download"), APPLICATION->network()); + filesNetJob->addNetAction(Net::ApiDownload::makeCached(m_sourceUrl, entry)); + + connect(filesNetJob.get(), &NetJob::succeeded, this, &InstanceImportTask::processZipPack); + connect(filesNetJob.get(), &NetJob::progress, this, &InstanceImportTask::setProgress); + connect(filesNetJob.get(), &NetJob::stepProgress, this, &InstanceImportTask::propagateStepProgress); + connect(filesNetJob.get(), &NetJob::failed, this, &InstanceImportTask::emitFailed); + connect(filesNetJob.get(), &NetJob::aborted, this, &InstanceImportTask::emitAborted); + m_task.reset(filesNetJob); + filesNetJob->start(); +} + +QString cleanPath(QString path) +{ + if (path == ".") + return QString(); + QString result = path; + if (result.startsWith("./")) + result = result.mid(2); + return result; +} + +void InstanceImportTask::processZipPack() +{ + setStatus(tr("Attempting to determine instance type")); + QDir extractDir(m_stagingPath); + qDebug() << "Attempting to create instance from" << m_archivePath; + + // open the zip and find relevant files in it + MMCZip::ArchiveReader packZip(m_archivePath); + qDebug() << "Attempting to determine instance type"; + + QString root; + // NOTE: Prioritize modpack platforms that aren't searched for recursively. + // Especially Flame has a very common filename for its manifest, which may appear inside overrides for example + // https://docs.modrinth.com/docs/modpacks/format_definition/#storage + auto detectInstance = [this, &extractDir, &root](MMCZip::ArchiveReader::File* f, bool& stop) { + if (!isRunning()) { + stop = true; + return true; + } + auto fileName = f->filename(); + if (fileName == "modrinth.index.json") { + // process as Modrinth pack + qDebug() << "Modrinth:" << true; + m_modpackType = ModpackType::Modrinth; + stop = true; + } else if (fileName == "bin/modpack.jar" || fileName == "bin/version.json") { + // process as Technic pack + qDebug() << "Technic:" << true; + extractDir.mkpath("minecraft"); + extractDir.cd("minecraft"); + m_modpackType = ModpackType::Technic; + stop = true; + } else if (fileName == "manifest.json") { + qDebug() << "Flame:" << true; + m_modpackType = ModpackType::Flame; + stop = true; + } else if (QFileInfo fileInfo(fileName); fileInfo.fileName() == "instance.cfg") { + qDebug() << "MultiMC:" << true; + m_modpackType = ModpackType::MultiMC; + root = cleanPath(fileInfo.path()); + stop = true; + } + QCoreApplication::processEvents(); + return true; + }; + if (!packZip.parse(detectInstance)) { + emitFailed(tr("Unable to open supplied modpack zip file.")); + return; + } + if (m_modpackType == ModpackType::Unknown) { + emitFailed(tr("Archive does not contain a recognized modpack type.")); + return; + } + setStatus(tr("Extracting modpack")); + + // make sure we extract just the pack + auto zipTask = makeShared(m_archivePath, extractDir, root); + + auto progressStep = std::make_shared(); + connect(zipTask.get(), &Task::finished, this, [this, progressStep] { + progressStep->state = TaskStepState::Succeeded; + stepProgress(*progressStep); + }); + + connect(zipTask.get(), &Task::succeeded, this, &InstanceImportTask::extractFinished, Qt::QueuedConnection); + connect(zipTask.get(), &Task::aborted, this, &InstanceImportTask::emitAborted); + connect(zipTask.get(), &Task::failed, this, [this, progressStep](QString reason) { + progressStep->state = TaskStepState::Failed; + stepProgress(*progressStep); + emitFailed(reason); + }); + connect(zipTask.get(), &Task::stepProgress, this, &InstanceImportTask::propagateStepProgress); + + connect(zipTask.get(), &Task::progress, this, [this, progressStep](qint64 current, qint64 total) { + progressStep->update(current, total); + stepProgress(*progressStep); + }); + connect(zipTask.get(), &Task::status, this, [this, progressStep](QString status) { + progressStep->status = status; + stepProgress(*progressStep); + }); + connect(zipTask.get(), &Task::warningLogged, this, [this](const QString& line) { m_Warnings.append(line); }); + m_task.reset(zipTask); + zipTask->start(); +} + +void InstanceImportTask::extractFinished() +{ + setAbortable(false); + QDir extractDir(m_stagingPath); + + qDebug() << "Fixing permissions for extracted pack files..."; + QDirIterator it(extractDir, QDirIterator::Subdirectories); + while (it.hasNext()) { + auto filepath = it.next(); + QFileInfo file(filepath); + auto permissions = QFile::permissions(filepath); + auto origPermissions = permissions; + if (file.isDir()) { + // Folder +rwx for current user + permissions |= QFileDevice::Permission::ReadUser | QFileDevice::Permission::WriteUser | QFileDevice::Permission::ExeUser; + } else { + // File +rw for current user + permissions |= QFileDevice::Permission::ReadUser | QFileDevice::Permission::WriteUser; + } + if (origPermissions != permissions) { + if (!QFile::setPermissions(filepath, permissions)) { + logWarning(tr("Could not fix permissions for %1").arg(filepath)); + } else { + qDebug() << "Fixed" << filepath; + } + } + } + + switch (m_modpackType) { + case ModpackType::MultiMC: + processMultiMC(); + return; + case ModpackType::Technic: + processTechnic(); + return; + case ModpackType::Flame: + processFlame(); + return; + case ModpackType::Modrinth: + processModrinth(); + return; + case ModpackType::Unknown: + emitFailed(tr("Archive does not contain a recognized modpack type.")); + return; + } +} + +bool installIcon(QString root, QString instIconKey) +{ + auto importIconPath = IconUtils::findBestIconIn(root, instIconKey); + if (importIconPath.isNull() || !QFile::exists(importIconPath)) + importIconPath = IconUtils::findBestIconIn(root, "icon.png"); + if (importIconPath.isNull() || !QFile::exists(importIconPath)) + importIconPath = IconUtils::findBestIconIn(FS::PathCombine(root, "overrides"), "icon.png"); + if (!importIconPath.isNull() && QFile::exists(importIconPath)) { + // import icon + auto iconList = APPLICATION->icons(); + if (iconList->iconFileExists(instIconKey)) { + iconList->deleteIcon(instIconKey); + } + iconList->installIcon(importIconPath, instIconKey + "." + QFileInfo(importIconPath).suffix()); + return true; + } + return false; +} + +void InstanceImportTask::processFlame() +{ + shared_qobject_ptr inst_creation_task = nullptr; + if (!m_extra_info.isEmpty()) { + auto pack_id_it = m_extra_info.constFind("pack_id"); + Q_ASSERT(pack_id_it != m_extra_info.constEnd()); + auto pack_id = pack_id_it.value(); + + auto pack_version_id_it = m_extra_info.constFind("pack_version_id"); + Q_ASSERT(pack_version_id_it != m_extra_info.constEnd()); + auto pack_version_id = pack_version_id_it.value(); + + QString original_instance_id; + auto original_instance_id_it = m_extra_info.constFind("original_instance_id"); + if (original_instance_id_it != m_extra_info.constEnd()) + original_instance_id = original_instance_id_it.value(); + + inst_creation_task = + makeShared(m_stagingPath, m_globalSettings, m_parent, pack_id, pack_version_id, original_instance_id); + } else { + // FIXME: Find a way to get IDs in directly imported ZIPs + inst_creation_task = makeShared(m_stagingPath, m_globalSettings, m_parent, QString(), QString()); + } + + inst_creation_task->setName(*this); + // if the icon was specified by user, use that. otherwise pull icon from the pack + if (m_instIcon == "default") { + auto iconKey = QString("Flame_%1_Icon").arg(name()); + + if (installIcon(m_stagingPath, iconKey)) { + m_instIcon = iconKey; + } + } + inst_creation_task->setIcon(m_instIcon); + inst_creation_task->setGroup(m_instGroup); + inst_creation_task->setConfirmUpdate(shouldConfirmUpdate()); + + auto weak = inst_creation_task.toWeakRef(); + connect(inst_creation_task.get(), &Task::succeeded, this, [this, weak] { + if (auto sp = weak.lock()) { + setOverride(sp->shouldOverride(), sp->originalInstanceID()); + } + emitSucceeded(); + }); + connect(inst_creation_task.get(), &Task::failed, this, &InstanceImportTask::emitFailed); + connect(inst_creation_task.get(), &Task::progress, this, &InstanceImportTask::setProgress); + connect(inst_creation_task.get(), &Task::stepProgress, this, &InstanceImportTask::propagateStepProgress); + connect(inst_creation_task.get(), &Task::status, this, &InstanceImportTask::setStatus); + connect(inst_creation_task.get(), &Task::details, this, &InstanceImportTask::setDetails); + + connect(inst_creation_task.get(), &Task::aborted, this, &InstanceImportTask::emitAborted); + connect(inst_creation_task.get(), &Task::abortStatusChanged, this, &Task::setAbortable); + connect(inst_creation_task.get(), &Task::abortButtonTextChanged, this, &Task::setAbortButtonText); + + connect(inst_creation_task.get(), &Task::warningLogged, this, [this](const QString& line) { m_Warnings.append(line); }); + + m_task.reset(inst_creation_task); + setAbortable(true); + m_task->start(); +} + +void InstanceImportTask::processTechnic() +{ + shared_qobject_ptr packProcessor{ new Technic::TechnicPackProcessor }; + connect(packProcessor.get(), &Technic::TechnicPackProcessor::succeeded, this, &InstanceImportTask::emitSucceeded); + connect(packProcessor.get(), &Technic::TechnicPackProcessor::failed, this, &InstanceImportTask::emitFailed); + packProcessor->run(m_globalSettings, name(), m_instIcon, m_stagingPath); +} + +void InstanceImportTask::processMultiMC() +{ + QString configPath = FS::PathCombine(m_stagingPath, "instance.cfg"); + auto instanceSettings = std::make_unique(configPath); + + NullInstance instance(m_globalSettings, std::move(instanceSettings), m_stagingPath); + + // reset time played on import... because packs. + instance.resetTimePlayed(); + + // set a new nice name + instance.setName(name()); + + // if the icon was specified by user, use that. otherwise pull icon from the pack + if (m_instIcon != "default") { + instance.setIconKey(m_instIcon); + } else { + m_instIcon = instance.iconKey(); + + installIcon(instance.instanceRoot(), m_instIcon); + } + emitSucceeded(); +} + +void InstanceImportTask::processModrinth() +{ + shared_qobject_ptr inst_creation_task = nullptr; + if (!m_extra_info.isEmpty()) { + auto pack_id_it = m_extra_info.constFind("pack_id"); + Q_ASSERT(pack_id_it != m_extra_info.constEnd()); + auto pack_id = pack_id_it.value(); + + QString pack_version_id; + auto pack_version_id_it = m_extra_info.constFind("pack_version_id"); + if (pack_version_id_it != m_extra_info.constEnd()) + pack_version_id = pack_version_id_it.value(); + + QString original_instance_id; + auto original_instance_id_it = m_extra_info.constFind("original_instance_id"); + if (original_instance_id_it != m_extra_info.constEnd()) + original_instance_id = original_instance_id_it.value(); + + inst_creation_task = + makeShared(m_stagingPath, m_globalSettings, m_parent, pack_id, pack_version_id, original_instance_id); + } else { + QString pack_id; + if (!m_sourceUrl.isEmpty()) { + static const QRegularExpression s_regex(R"(data\/([^\/]*)\/versions)"); + pack_id = s_regex.match(m_sourceUrl.toString()).captured(1); + } + + // FIXME: Find a way to get the ID in directly imported ZIPs + inst_creation_task = makeShared(m_stagingPath, m_globalSettings, m_parent, pack_id); + } + + inst_creation_task->setName(*this); + // if the icon was specified by user, use that. otherwise pull icon from the pack + if (m_instIcon == "default") { + auto iconKey = QString("Modrinth_%1_Icon").arg(name()); + + if (installIcon(m_stagingPath, iconKey)) { + m_instIcon = iconKey; + } + } + inst_creation_task->setIcon(m_instIcon); + inst_creation_task->setGroup(m_instGroup); + inst_creation_task->setConfirmUpdate(shouldConfirmUpdate()); + + auto weak = inst_creation_task.toWeakRef(); + connect(inst_creation_task.get(), &Task::succeeded, this, [this, weak] { + if (auto sp = weak.lock()) { + setOverride(sp->shouldOverride(), sp->originalInstanceID()); + } + emitSucceeded(); + }); + connect(inst_creation_task.get(), &Task::failed, this, &InstanceImportTask::emitFailed); + connect(inst_creation_task.get(), &Task::progress, this, &InstanceImportTask::setProgress); + connect(inst_creation_task.get(), &Task::stepProgress, this, &InstanceImportTask::propagateStepProgress); + connect(inst_creation_task.get(), &Task::status, this, &InstanceImportTask::setStatus); + connect(inst_creation_task.get(), &Task::details, this, &InstanceImportTask::setDetails); + + connect(inst_creation_task.get(), &Task::aborted, this, &InstanceImportTask::emitAborted); + connect(inst_creation_task.get(), &Task::abortStatusChanged, this, &Task::setAbortable); + connect(inst_creation_task.get(), &Task::abortButtonTextChanged, this, &Task::setAbortButtonText); + + connect(inst_creation_task.get(), &Task::warningLogged, this, [this](const QString& line) { m_Warnings.append(line); }); + + m_task.reset(inst_creation_task); + setAbortable(true); + m_task->start(); +} diff --git a/launcher/InstanceImportTask.h b/launcher/InstanceImportTask.h new file mode 100644 index 0000000..c92e229 --- /dev/null +++ b/launcher/InstanceImportTask.h @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include "InstanceTask.h" + +class InstanceImportTask : public InstanceTask { + Q_OBJECT + public: + explicit InstanceImportTask(const QUrl& sourceUrl, QWidget* parent = nullptr, QMap&& extra_info = {}); + virtual ~InstanceImportTask() = default; + bool abort() override; + + protected: + //! Entry point for tasks. + virtual void executeTask() override; + + private: + void processMultiMC(); + void processTechnic(); + void processFlame(); + void processModrinth(); + + private slots: + void processZipPack(); + void extractFinished(); + + private: /* data */ + QUrl m_sourceUrl; + QString m_archivePath; + Task::Ptr m_task; + enum class ModpackType { + Unknown, + MultiMC, + Technic, + Flame, + Modrinth, + } m_modpackType = ModpackType::Unknown; + + // Extra info we might need, that's available before, but can't be derived from + // the source URL / the resource it points to alone. + QMap m_extra_info; + + // FIXME: nuke + QWidget* m_parent; + void downloadFromUrl(); +}; diff --git a/launcher/InstanceList.cpp b/launcher/InstanceList.cpp new file mode 100644 index 0000000..1339499 --- /dev/null +++ b/launcher/InstanceList.cpp @@ -0,0 +1,1087 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "InstanceList.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "BaseInstance.h" +#include "ExponentialSeries.h" +#include "FileSystem.h" + +#include "InstanceTask.h" +#include "NullInstance.h" +#include "WatchLock.h" +#include "minecraft/MinecraftInstance.h" +#include "settings/INISettingsObject.h" + +#ifdef Q_OS_WIN32 +#include +#endif + +const static int GROUP_FILE_FORMAT_VERSION = 1; + +InstanceList::InstanceList(SettingsObject* settings, const QString& instDir, QObject* parent) + : QAbstractListModel(parent), m_globalSettings(settings) +{ + resumeWatch(); + // Create aand normalize path + if (!QDir::current().exists(instDir)) { + QDir::current().mkpath(instDir); + } + + connect(this, &InstanceList::instancesChanged, this, &InstanceList::providerUpdated); + + // NOTE: canonicalPath requires the path to exist. Do not move this above the creation block! + m_instDir = QDir(instDir).canonicalPath(); + m_watcher = new QFileSystemWatcher(this); + connect(m_watcher, &QFileSystemWatcher::directoryChanged, this, &InstanceList::instanceDirContentsChanged); + m_watcher->addPath(m_instDir); +} + +InstanceList::~InstanceList() {} + +Qt::DropActions InstanceList::supportedDragActions() const +{ + return Qt::MoveAction; +} + +Qt::DropActions InstanceList::supportedDropActions() const +{ + return Qt::MoveAction; +} + +bool InstanceList::canDropMimeData(const QMimeData* data, + [[maybe_unused]] Qt::DropAction action, + [[maybe_unused]] int row, + [[maybe_unused]] int column, + [[maybe_unused]] const QModelIndex& parent) const +{ + if (data && data->hasFormat("application/x-instanceid")) { + return true; + } + return false; +} + +bool InstanceList::dropMimeData(const QMimeData* data, + [[maybe_unused]] Qt::DropAction action, + [[maybe_unused]] int row, + [[maybe_unused]] int column, + [[maybe_unused]] const QModelIndex& parent) +{ + if (data && data->hasFormat("application/x-instanceid")) { + return true; + } + return false; +} + +QStringList InstanceList::mimeTypes() const +{ + auto types = QAbstractListModel::mimeTypes(); + types.push_back("application/x-instanceid"); + return types; +} + +QMimeData* InstanceList::mimeData(const QModelIndexList& indexes) const +{ + auto mimeData = QAbstractListModel::mimeData(indexes); + if (indexes.size() == 1) { + auto instanceId = data(indexes[0], InstanceIDRole).toString(); + mimeData->setData("application/x-instanceid", instanceId.toUtf8()); + } + return mimeData; +} + +QStringList InstanceList::getLinkedInstancesById(const QString& id) const +{ + QStringList linkedInstances; + for (auto& inst : m_instances) { + if (inst->isLinkedToInstanceId(id)) + linkedInstances.append(inst->id()); + } + return linkedInstances; +} + +int InstanceList::rowCount(const QModelIndex& parent) const +{ + Q_UNUSED(parent); + return count(); +} + +QModelIndex InstanceList::index(int row, int column, const QModelIndex& parent) const +{ + Q_UNUSED(parent); + if (row < 0 || row >= count()) + return QModelIndex(); + return createIndex(row, column, m_instances.at(row).get()); +} + +QVariant InstanceList::data(const QModelIndex& index, int role) const +{ + if (!index.isValid()) { + return QVariant(); + } + BaseInstance* pdata = static_cast(index.internalPointer()); + switch (role) { + case InstancePointerRole: { + QVariant v = QVariant::fromValue((void*)pdata); + return v; + } + case InstanceIDRole: { + return pdata->id(); + } + case Qt::EditRole: + case Qt::DisplayRole: { + return pdata->name(); + } + case Qt::AccessibleTextRole: { + return tr("%1 Instance").arg(pdata->name()); + } + case Qt::ToolTipRole: { + return pdata->instanceRoot(); + } + case Qt::DecorationRole: { + return pdata->iconKey(); + } + // HACK: see InstanceView.h in gui! + case GroupRole: { + return getInstanceGroup(pdata->id()); + } + default: + break; + } + return QVariant(); +} + +bool InstanceList::setData(const QModelIndex& index, const QVariant& value, int role) +{ + if (!index.isValid()) { + return false; + } + if (role != Qt::EditRole) { + return false; + } + BaseInstance* pdata = static_cast(index.internalPointer()); + auto newName = value.toString(); + if (pdata->name() == newName) { + return true; + } + pdata->setName(newName); + return true; +} + +Qt::ItemFlags InstanceList::flags(const QModelIndex& index) const +{ + Qt::ItemFlags f; + if (index.isValid()) { + f |= (Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsEditable); + } + return f; +} + +GroupId InstanceList::getInstanceGroup(const InstanceId& id) const +{ + auto inst = getInstanceById(id); + if (!inst) { + return GroupId(); + } + auto iter = m_instanceGroupIndex.find(inst->id()); + if (iter != m_instanceGroupIndex.end()) { + return *iter; + } + return GroupId(); +} + +void InstanceList::setInstanceGroup(const InstanceId& id, GroupId name) +{ + if (name.isEmpty() && !name.isNull()) + name = QString(); + + auto inst = getInstanceById(id); + if (!inst) { + qDebug() << "Attempt to set a null instance's group"; + return; + } + + bool changed = false; + auto iter = m_instanceGroupIndex.find(inst->id()); + if (iter != m_instanceGroupIndex.end()) { + if (*iter != name) { + decreaseGroupCount(*iter); + *iter = name; + changed = true; + } + } else { + changed = true; + m_instanceGroupIndex[id] = name; + } + + if (changed) { + increaseGroupCount(name); + auto idx = getInstIndex(inst); + emit dataChanged(index(idx), index(idx), { GroupRole }); + saveGroupList(); + } +} + +QStringList InstanceList::getGroups() +{ + return m_groupNameCache.keys(); +} + +void InstanceList::deleteGroup(const GroupId& name) +{ + m_groupNameCache.remove(name); + m_collapsedGroups.remove(name); + + bool removed = false; + qDebug() << "Delete group" << name; + for (auto& instance : m_instances) { + const QString& instID = instance->id(); + const QString instGroupName = getInstanceGroup(instID); + if (instGroupName == name) { + m_instanceGroupIndex.remove(instID); + qDebug() << "Remove" << instID << "from group" << name; + removed = true; + auto idx = getInstIndex(instance.get()); + if (idx >= 0) + emit dataChanged(index(idx), index(idx), { GroupRole }); + } + } + if (removed) + saveGroupList(); +} + +void InstanceList::renameGroup(const QString& src, const QString& dst) +{ + m_groupNameCache.remove(src); + if (m_collapsedGroups.remove(src)) + m_collapsedGroups.insert(dst); + + bool modified = false; + qDebug() << "Rename group" << src << "to" << dst; + for (auto& instance : m_instances) { + const QString& instID = instance->id(); + const QString instGroupName = getInstanceGroup(instID); + if (instGroupName == src) { + m_instanceGroupIndex[instID] = dst; + increaseGroupCount(dst); + qDebug() << "Set" << instID << "group to" << dst; + modified = true; + auto idx = getInstIndex(instance.get()); + if (idx >= 0) + emit dataChanged(index(idx), index(idx), { GroupRole }); + } + } + if (modified) + saveGroupList(); +} + +bool InstanceList::isGroupCollapsed(const QString& group) +{ + return m_collapsedGroups.contains(group); +} + +bool InstanceList::trashInstance(const InstanceId& id) +{ + auto inst = getInstanceById(id); + if (!inst) { + qWarning() << "Cannot trash instance" << id << ". No such instance is present (deleted externally?)."; + return false; + } + + QString cachedGroupId = m_instanceGroupIndex[id]; + + qDebug() << "Will trash instance" << id; + QString trashedLoc; + + if (m_instanceGroupIndex.remove(id)) { + decreaseGroupCount(cachedGroupId); + saveGroupList(); + } + + if (!FS::trash(inst->instanceRoot(), &trashedLoc)) { + qWarning() << "Trash of instance" << id << "has not been completely successful..."; + return false; + } + + qDebug() << "Instance" << id << "has been trashed by the launcher."; + m_trashHistory.push({ id, inst->instanceRoot(), trashedLoc, cachedGroupId }); + + // Also trash all of its shortcuts; we remove the shortcuts if trash fails since it is invalid anyway + for (const auto& [name, filePath, target] : inst->shortcuts()) { + if (!FS::trash(filePath, &trashedLoc)) { + qWarning() << "Trash of shortcut" << name << "at path" << filePath << "for instance" << id + << "has not been successful, trying to delete it instead..."; + if (!FS::deletePath(filePath)) { + qWarning() << "Deletion of shortcut" << name << "at path" << filePath << "for instance" << id + << "has not been successful, given up..."; + } else { + qDebug() << "Shortcut" << name << "at path" << filePath << "for instance" << id << "has been deleted by the launcher."; + } + continue; + } + qDebug() << "Shortcut" << name << "at path" << filePath << "for instance" << id << "has been trashed by the launcher."; + m_trashHistory.top().shortcuts.append({ { name, filePath, target }, trashedLoc }); + } + + return true; +} + +bool InstanceList::trashedSomething() const +{ + return !m_trashHistory.empty(); +} + +bool InstanceList::undoTrashInstance() +{ + if (m_trashHistory.empty()) { + qWarning() << "Nothing to recover from trash."; + return true; + } + + auto top = m_trashHistory.pop(); + + while (QDir(top.path).exists()) { + top.id += "1"; + top.path += "1"; + } + + if (!QFile(top.trashPath).rename(top.path)) { + qWarning() << "Moving" << top.trashPath << "back to" << top.path << "failed!"; + return false; + } + qDebug() << "Moving" << top.trashPath << "back to" << top.path; + + bool ok = true; + for (const auto& [data, trashPath] : top.shortcuts) { + if (QDir(data.filePath).exists()) { + // Don't try to append 1 here as the shortcut may have suffixes like .app, just warn and skip it + qWarning() << "Shortcut" << trashPath << "original directory" << data.filePath << "already exists!"; + ok = false; + continue; + } + if (!QFile(trashPath).rename(data.filePath)) { + qWarning() << "Moving shortcut from" << trashPath << "back to" << data.filePath << "failed!"; + ok = false; + continue; + } + qDebug() << "Moving shortcut from" << trashPath << "back to" << data.filePath; + } + + m_instanceGroupIndex[top.id] = top.groupName; + increaseGroupCount(top.groupName); + + saveGroupList(); + emit instancesChanged(); + return ok; +} + +void InstanceList::deleteInstance(const InstanceId& id) +{ + auto inst = getInstanceById(id); + if (!inst) { + qWarning() << "Cannot delete instance" << id << ". No such instance is present (deleted externally?)."; + return; + } + + QString cachedGroupId = m_instanceGroupIndex[id]; + + if (m_instanceGroupIndex.remove(id)) { + decreaseGroupCount(cachedGroupId); + saveGroupList(); + } + + qDebug() << "Will delete instance" << id; + if (!FS::deletePath(inst->instanceRoot())) { + qWarning() << "Deletion of instance" << id << "has not been completely successful..."; + return; + } + + qDebug() << "Instance" << id << "has been deleted by the launcher."; + + for (const auto& [name, filePath, target] : inst->shortcuts()) { + if (!FS::deletePath(filePath)) { + qWarning() << "Deletion of shortcut" << name << "at path" << filePath << "for instance" << id << "has not been successful..."; + continue; + } + qDebug() << "Shortcut" << name << "at path" << filePath << "for instance" << id << "has been deleted by the launcher."; + } +} + +static QMap getIdMapping(const std::vector>& list) +{ + QMap out; + int i = 0; + for (auto& item : list) { + auto id = item->id(); + if (out.contains(id)) { + qWarning() << "Duplicate ID" << id << "in instance list"; + } + out[id] = std::make_pair(item.get(), i); + i++; + } + return out; +} + +QList InstanceList::discoverInstances() +{ + qInfo() << "Discovering instances in" << m_instDir; + QList out; + QDirIterator iter(m_instDir, QDir::Dirs | QDir::NoDot | QDir::NoDotDot | QDir::Readable | QDir::Hidden, QDirIterator::FollowSymlinks); + while (iter.hasNext()) { + QString subDir = iter.next(); + QFileInfo dirInfo(subDir); + if (!QFileInfo(FS::PathCombine(subDir, "instance.cfg")).exists()) + continue; + // if it is a symlink, ignore it if it goes to the instance folder + if (dirInfo.isSymLink()) { + QFileInfo targetInfo(dirInfo.symLinkTarget()); + QFileInfo instDirInfo(m_instDir); + if (targetInfo.canonicalPath() == instDirInfo.canonicalFilePath()) { + qDebug() << "Ignoring symlink" << subDir << "that leads into the instances folder"; + continue; + } + } + auto id = dirInfo.fileName(); + out.append(id); + qInfo() << "Found instance ID" << id; + } + instanceSet = QSet(out.begin(), out.end()); + m_instancesProbed = true; + return out; +} + +InstanceList::InstListError InstanceList::loadList() +{ + auto existingIds = getIdMapping(m_instances); + + std::vector> newList; + + for (auto& id : discoverInstances()) { + if (existingIds.contains(id)) { + existingIds.remove(id); + qInfo() << "Should keep and soft-reload" << id; + } else { + std::unique_ptr instPtr = loadInstance(id); + if (instPtr) { + newList.push_back(std::move(instPtr)); + } + } + } + + // TODO: looks like a general algorithm with a few specifics inserted. Do something about it. + if (!existingIds.isEmpty()) { + // get the list of removed instances and sort it by their original index, from last to first + auto deadList = existingIds.values(); + auto orderSortPredicate = [](const InstanceLocator& a, const InstanceLocator& b) -> bool { return a.second > b.second; }; + std::sort(deadList.begin(), deadList.end(), orderSortPredicate); + // remove the contiguous ranges of rows + int front_bookmark = -1; + int back_bookmark = -1; + int currentItem = -1; + auto removeNow = [this, &front_bookmark, &back_bookmark, ¤tItem]() { + beginRemoveRows(QModelIndex(), front_bookmark, back_bookmark); + m_instances.erase(m_instances.begin() + front_bookmark, m_instances.begin() + back_bookmark + 1); + endRemoveRows(); + front_bookmark = -1; + back_bookmark = currentItem; + }; + for (auto& removedItem : deadList) { + auto instPtr = removedItem.first; + instPtr->invalidate(); + currentItem = removedItem.second; + if (back_bookmark == -1) { + // no bookmark yet + back_bookmark = currentItem; + } else if (currentItem == front_bookmark - 1) { + // part of contiguous sequence, continue + } else { + // seam between previous and current item + removeNow(); + } + front_bookmark = currentItem; + } + if (back_bookmark != -1) { + removeNow(); + } + } + if (newList.size()) { + add(newList); + } + m_dirty = false; + updateTotalPlayTime(); + return NoError; +} + +void InstanceList::updateTotalPlayTime() +{ + totalPlayTime = 0; + for (const auto& itr : m_instances) { + totalPlayTime += itr->totalTimePlayed(); + } +} + +void InstanceList::saveNow() +{ + for (auto& item : m_instances) { + item->saveNow(); + } +} + +void InstanceList::add(std::vector>& t) +{ + beginInsertRows(QModelIndex(), count(), static_cast(count() + t.size() - 1)); + for (auto& ptr : t) { + m_instances.push_back(std::move(ptr)); + connect(m_instances.back().get(), &BaseInstance::propertiesChanged, this, &InstanceList::propertiesChanged); + } + endInsertRows(); +} + +void InstanceList::resumeWatch() +{ + if (m_watchLevel > 0) { + qWarning() << "Bad suspend level resume in instance list"; + return; + } + m_watchLevel++; + if (m_watchLevel > 0 && m_dirty) { + loadList(); + } +} + +void InstanceList::suspendWatch() +{ + m_watchLevel--; +} + +void InstanceList::providerUpdated() +{ + m_dirty = true; + if (m_watchLevel == 1) { + loadList(); + } +} + +BaseInstance* InstanceList::getInstanceById(QString instId) const +{ + if (instId.isEmpty()) + return nullptr; + for (auto& inst : m_instances) { + if (inst->id() == instId) { + return inst.get(); + } + } + return nullptr; +} + +BaseInstance* InstanceList::getInstanceByManagedName(const QString& managed_name) const +{ + if (managed_name.isEmpty()) + return {}; + + for (auto& instance : m_instances) { + if (instance->getManagedPackName() == managed_name) + return instance.get(); + } + + return {}; +} + +QModelIndex InstanceList::getInstanceIndexById(const QString& id) const +{ + return index(getInstIndex(getInstanceById(id))); +} + +int InstanceList::getInstIndex(BaseInstance* inst) const +{ + int count = this->count(); + for (int i = 0; i < count; i++) { + if (inst == m_instances.at(i).get()) { + return i; + } + } + return -1; +} + +void InstanceList::propertiesChanged(BaseInstance* inst) +{ + int i = getInstIndex(inst); + if (i != -1) { + emit dataChanged(index(i), index(i)); + updateTotalPlayTime(); + } +} + +std::unique_ptr InstanceList::loadInstance(const InstanceId& id) +{ + if (!m_groupsLoaded) { + loadGroupList(); + } + + auto instanceRoot = FS::PathCombine(m_instDir, id); + auto instanceSettings = std::make_unique(FS::PathCombine(instanceRoot, "instance.cfg")); + std::unique_ptr inst; + + instanceSettings->registerSetting("InstanceType", ""); + + QString inst_type = instanceSettings->get("InstanceType").toString(); + + // NOTE: Some launcher versions didn't save the InstanceType properly. We will just bank on the probability that this is probably a + // OneSix instance + if (inst_type == "OneSix" || inst_type.isEmpty()) { + inst.reset(new MinecraftInstance(m_globalSettings, std::move(instanceSettings), instanceRoot)); + } else { + inst.reset(new NullInstance(m_globalSettings, std::move(instanceSettings), instanceRoot)); + } + qDebug() << "Loaded instance" << inst->name() << "from" << inst->instanceRoot(); + + auto shortcut = inst->shortcuts(); + if (!shortcut.isEmpty()) + qDebug() << "Loaded" << shortcut.size() << "shortcut(s) for instance" << inst->name(); + + return inst; +} + +void InstanceList::increaseGroupCount(const QString& group) +{ + if (group.isEmpty()) + return; + + ++m_groupNameCache[group]; +} + +void InstanceList::decreaseGroupCount(const QString& group) +{ + if (group.isEmpty()) + return; + + if (--m_groupNameCache[group] < 1) { + m_groupNameCache.remove(group); + m_collapsedGroups.remove(group); + } +} + +void InstanceList::saveGroupList() +{ + qDebug() << "Will save group list now."; + if (!m_instancesProbed) { + qDebug() << "Group saving prevented because we don't know the full list of instances yet."; + return; + } + WatchLock foo(m_watcher, m_instDir); + QString groupFileName = m_instDir + "/instgroups.json"; + QMap> reverseGroupMap; + for (auto iter = m_instanceGroupIndex.begin(); iter != m_instanceGroupIndex.end(); iter++) { + const QString& id = iter.key(); + QString group = iter.value(); + if (group.isEmpty()) + continue; + if (!instanceSet.contains(id)) { + qDebug() << "Skipping saving missing instance" << id << "to groups list."; + continue; + } + + if (!reverseGroupMap.count(group)) { + QSet set; + set.insert(id); + reverseGroupMap[group] = set; + } else { + QSet& set = reverseGroupMap[group]; + set.insert(id); + } + } + QJsonObject toplevel; + toplevel.insert("formatVersion", QJsonValue(QString("1"))); + QJsonObject groupsArr; + for (auto iter = reverseGroupMap.begin(); iter != reverseGroupMap.end(); iter++) { + auto list = iter.value(); + auto name = iter.key(); + QJsonObject groupObj; + QJsonArray instanceArr; + groupObj.insert("hidden", QJsonValue(m_collapsedGroups.contains(name))); + for (auto item : list) { + instanceArr.append(QJsonValue(item)); + } + groupObj.insert("instances", instanceArr); + groupsArr.insert(name, groupObj); + } + toplevel.insert("groups", groupsArr); + // empty string represents ungrouped "group" + if (m_collapsedGroups.contains("")) { + QJsonObject ungrouped; + ungrouped.insert("hidden", QJsonValue(true)); + toplevel.insert("ungrouped", ungrouped); + } + QJsonDocument doc(toplevel); + try { + FS::write(groupFileName, doc.toJson()); + qDebug() << "Group list saved."; + } catch (const FS::FileSystemException& e) { + qCritical() << "Failed to write instance group file :" << e.cause(); + } +} + +void InstanceList::loadGroupList() +{ + qDebug() << "Will load group list now."; + + QString groupFileName = m_instDir + "/instgroups.json"; + + // if there's no group file, fail + if (!QFileInfo(groupFileName).exists()) + return; + + QByteArray jsonData; + try { + jsonData = FS::read(groupFileName); + } catch (const FS::FileSystemException& e) { + qCritical() << "Failed to read instance group file :" << e.cause(); + return; + } + + QJsonParseError error; + QJsonDocument jsonDoc = QJsonDocument::fromJson(jsonData, &error); + + // if the json was bad, fail + if (error.error != QJsonParseError::NoError) { + qCritical() << QString("Failed to parse instance group file: %1 at offset %2") + .arg(error.errorString(), QString::number(error.offset)) + .toUtf8(); + return; + } + + // if the root of the json wasn't an object, fail + if (!jsonDoc.isObject()) { + qWarning() << "Invalid group file. Root entry should be an object."; + return; + } + + QJsonObject rootObj = jsonDoc.object(); + + // Make sure the format version matches, otherwise fail. + if (rootObj.value("formatVersion").toVariant().toInt() != GROUP_FILE_FORMAT_VERSION) + return; + + // Get the groups. if it's not an object, fail + if (!rootObj.value("groups").isObject()) { + qWarning() << "Invalid group list JSON: 'groups' should be an object."; + return; + } + + m_instanceGroupIndex.clear(); + m_groupNameCache.clear(); + + // Iterate through all the groups. + QJsonObject groupMapping = rootObj.value("groups").toObject(); + for (QJsonObject::iterator iter = groupMapping.begin(); iter != groupMapping.end(); iter++) { + QString groupName = iter.key(); + + if (iter.key().isEmpty()) { + qWarning() << "Redundant empty group found"; + continue; + } + + // If not an object, complain and skip to the next one. + if (!iter.value().isObject()) { + qWarning() << QString("Group '%1' in the group list should be an object").arg(groupName).toUtf8(); + continue; + } + + QJsonObject groupObj = iter.value().toObject(); + if (!groupObj.value("instances").isArray()) { + qWarning() << QString("Group '%1' in the group list is invalid. It should contain an array called 'instances'.") + .arg(groupName) + .toUtf8(); + continue; + } + + auto hidden = groupObj.value("hidden").toBool(false); + if (hidden) + m_collapsedGroups.insert(groupName); + + // Iterate through the list of instances in the group. + QJsonArray instancesArray = groupObj.value("instances").toArray(); + + for (auto value : instancesArray) { + m_instanceGroupIndex[value.toString()] = groupName; + increaseGroupCount(groupName); + } + } + + bool ungroupedHidden = false; + if (rootObj.value("ungrouped").isObject()) { + QJsonObject ungrouped = rootObj.value("ungrouped").toObject(); + ungroupedHidden = ungrouped.value("hidden").toBool(false); + } + if (ungroupedHidden) { + // empty string represents ungrouped "group" + m_collapsedGroups.insert(""); + } + m_groupsLoaded = true; + qDebug() << "Group list loaded."; +} + +void InstanceList::instanceDirContentsChanged(const QString& path) +{ + Q_UNUSED(path); + emit instancesChanged(); +} + +void InstanceList::on_InstFolderChanged([[maybe_unused]] const Setting& setting, QVariant value) +{ + QString newInstDir = QDir(value.toString()).canonicalPath(); + if (newInstDir != m_instDir) { + if (m_groupsLoaded) { + saveGroupList(); + } + m_instDir = newInstDir; + m_groupsLoaded = false; + beginRemoveRows(QModelIndex(), 0, count()); + m_instances.erase(m_instances.begin(), m_instances.end()); + endRemoveRows(); + emit instancesChanged(); + } +} + +void InstanceList::on_GroupStateChanged(const QString& group, bool collapsed) +{ + qDebug() << "Group" << group << (collapsed ? "collapsed" : "expanded"); + if (collapsed) { + m_collapsedGroups.insert(group); + } else { + m_collapsedGroups.remove(group); + } + saveGroupList(); +} + +class InstanceStaging : public Task { + Q_OBJECT + const unsigned minBackoff = 1; + const unsigned maxBackoff = 16; + + public: + InstanceStaging(InstanceList* parent, InstanceTask* child, SettingsObject* settings) : m_parent(parent), backoff(minBackoff, maxBackoff) + { + m_stagingPath = parent->getStagedInstancePath(); + + m_child.reset(child); + + m_child->setStagingPath(m_stagingPath); + m_child->setParentSettings(settings); + + connect(child, &Task::succeeded, this, &InstanceStaging::childSucceeded); + connect(child, &Task::failed, this, &InstanceStaging::childFailed); + connect(child, &Task::aborted, this, &InstanceStaging::childAborted); + connect(child, &Task::abortStatusChanged, this, &InstanceStaging::setAbortable); + connect(child, &Task::abortButtonTextChanged, this, &InstanceStaging::setAbortButtonText); + connect(child, &Task::status, this, &InstanceStaging::setStatus); + connect(child, &Task::details, this, &InstanceStaging::setDetails); + connect(child, &Task::progress, this, &InstanceStaging::setProgress); + connect(child, &Task::stepProgress, this, &InstanceStaging::propagateStepProgress); + connect(&m_backoffTimer, &QTimer::timeout, this, &InstanceStaging::childSucceeded); + } + + ~InstanceStaging() override = default; + + // FIXME/TODO: add ability to abort during instance commit retries + bool abort() override + { + if (!canAbort()) { + return false; + } + + return m_child->abort(); + } + bool canAbort() const override { return (m_child && m_child->canAbort()); } + + protected: + void executeTask() override + { + if (m_stagingPath.isNull()) { + emitFailed(tr("Could not create staging folder")); + return; + } + + m_child->start(); + } + QStringList warnings() const override { return m_child->warnings(); } + + private slots: + void childSucceeded() + { + unsigned sleepTime = backoff(); + if (m_parent->commitStagedInstance(m_stagingPath, *m_child, m_child->group(), *m_child)) { + m_backoffTimer.stop(); + emitSucceeded(); + return; + } + // we actually failed, retry? + if (sleepTime == maxBackoff) { + m_backoffTimer.stop(); + emitFailed(tr("Failed to commit instance, even after multiple retries. It is being blocked by something.")); + return; + } + qDebug() << "Failed to commit instance" << m_child->name() << "Initiating backoff:" << sleepTime; + m_backoffTimer.start(sleepTime * 500); + } + void childFailed(const QString& reason) + { + m_backoffTimer.stop(); + m_parent->destroyStagingPath(m_stagingPath); + emitFailed(reason); + } + + void childAborted() + { + m_backoffTimer.stop(); + m_parent->destroyStagingPath(m_stagingPath); + emitAborted(); + } + + private: + InstanceList* m_parent; + /* + * WHY: the whole reason why this uses an exponential backoff retry scheme is antivirus on Windows. + * Basically, it starts messing things up while the launcher is extracting/creating instances + * and causes that horrible failure that is NTFS to lock files in place because they are open. + */ + ExponentialSeries backoff; + QString m_stagingPath; + std::unique_ptr m_child; + QTimer m_backoffTimer; +}; + +Task* InstanceList::wrapInstanceTask(InstanceTask* task) +{ + return new InstanceStaging(this, task, m_globalSettings); +} + +QString InstanceList::getStagedInstancePath() +{ + const QString tempRoot = FS::PathCombine(m_instDir, ".tmp"); + + QString result; + int tries = 0; + + do { + if (++tries > 256) + return {}; + + const QString key = QUuid::createUuid().toString(QUuid::Id128).left(6); + result = FS::PathCombine(tempRoot, key); + } while (QFileInfo::exists(result)); + + if (!QDir::current().mkpath(result)) + return {}; +#ifdef Q_OS_WIN32 + SetFileAttributesA(tempRoot.toStdString().c_str(), FILE_ATTRIBUTE_HIDDEN | FILE_ATTRIBUTE_NOT_CONTENT_INDEXED); +#endif + return result; +} + +bool InstanceList::commitStagedInstance(const QString& path, + const InstanceName& instanceName, + QString groupName, + const InstanceTask& commiting) +{ + if (groupName.isEmpty() && !groupName.isNull()) + groupName = QString(); + + QString instID; + + auto should_override = commiting.shouldOverride(); + + if (should_override) { + instID = commiting.originalInstanceID(); + } else { + instID = FS::DirNameFromString(instanceName.modifiedName(), m_instDir); + } + + Q_ASSERT(!instID.isEmpty()); + + { + WatchLock lock(m_watcher, m_instDir); + QString destination = FS::PathCombine(m_instDir, instID); + + if (should_override) { + if (!FS::overrideFolder(destination, path)) { + qWarning() << "Failed to override" << path << "to" << destination; + return false; + } + } else { + if (!FS::move(path, destination)) { + qWarning() << "Failed to move" << path << "to" << destination; + return false; + } + + m_instanceGroupIndex[instID] = groupName; + increaseGroupCount(groupName); + } + + instanceSet.insert(instID); + + emit instancesChanged(); + emit instanceSelectRequest(instID); + } + + saveGroupList(); + return true; +} + +bool InstanceList::destroyStagingPath(const QString& keyPath) +{ + return FS::deletePath(keyPath); +} + +int InstanceList::getTotalPlayTime() +{ + updateTotalPlayTime(); + return totalPlayTime; +} + +#include "InstanceList.moc" diff --git a/launcher/InstanceList.h b/launcher/InstanceList.h new file mode 100644 index 0000000..f0a92d2 --- /dev/null +++ b/launcher/InstanceList.h @@ -0,0 +1,210 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "BaseInstance.h" + +class QFileSystemWatcher; +class InstanceTask; +struct InstanceName; + +using InstanceId = QString; +using GroupId = QString; +using InstanceLocator = std::pair; + +enum class InstCreateError { NoCreateError = 0, NoSuchVersion, UnknownCreateError, InstExists, CantCreateDir }; + +enum class GroupsState { NotLoaded, Steady, Dirty }; + +struct TrashShortcutItem { + ShortcutData data; + QString trashPath; +}; + +struct TrashHistoryItem { + QString id; + QString path; + QString trashPath; + QString groupName; + QList shortcuts; +}; + +class InstanceList : public QAbstractListModel { + Q_OBJECT + + public: + explicit InstanceList(SettingsObject* settings, const QString& instDir, QObject* parent = 0); + virtual ~InstanceList(); + + public: + QModelIndex index(int row, int column = 0, const QModelIndex& parent = QModelIndex()) const override; + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + QVariant data(const QModelIndex& index, int role) const override; + Qt::ItemFlags flags(const QModelIndex& index) const override; + + bool setData(const QModelIndex& index, const QVariant& value, int role) override; + + enum AdditionalRoles { + GroupRole = Qt::UserRole, + InstancePointerRole = 0x34B1CB48, ///< Return pointer to real instance + InstanceIDRole = 0x34B1CB49 ///< Return id if the instance + }; + /*! + * \brief Error codes returned by functions in the InstanceList class. + * NoError Indicates that no error occurred. + * UnknownError indicates that an unspecified error occurred. + */ + enum InstListError { NoError = 0, UnknownError }; + + BaseInstance* at(int i) const { return m_instances.at(i).get(); } + + int count() const { return static_cast(m_instances.size()); } + + InstListError loadList(); + void saveNow(); + + /* O(n) */ + BaseInstance* getInstanceById(QString id) const; + /* O(n) */ + BaseInstance* getInstanceByManagedName(const QString& managed_name) const; + QModelIndex getInstanceIndexById(const QString& id) const; + QStringList getGroups(); + bool isGroupCollapsed(const QString& groupName); + + GroupId getInstanceGroup(const InstanceId& id) const; + void setInstanceGroup(const InstanceId& id, GroupId name); + + void deleteGroup(const GroupId& name); + void renameGroup(const GroupId& src, const GroupId& dst); + bool trashInstance(const InstanceId& id); + bool trashedSomething() const; + bool undoTrashInstance(); + void deleteInstance(const InstanceId& id); + + // Wrap an instance creation task in some more task machinery and make it ready to be used + Task* wrapInstanceTask(InstanceTask* task); + + /** + * Create a new empty staging area for instance creation and @return a path/key top commit it later. + * Used by instance manipulation tasks. + */ + QString getStagedInstancePath(); + + /** + * Commit the staging area given by @keyPath to the provider - used when creation succeeds. + * Used by instance manipulation tasks. + * should_override is used when another similar instance already exists, and we want to override it + * - for instance, when updating it. + */ + bool commitStagedInstance(const QString& keyPath, const InstanceName& instanceName, QString groupName, const InstanceTask&); + + /** + * Destroy a previously created staging area given by @keyPath - used when creation fails. + * Used by instance manipulation tasks. + */ + bool destroyStagingPath(const QString& keyPath); + + int getTotalPlayTime(); + + Qt::DropActions supportedDragActions() const override; + + Qt::DropActions supportedDropActions() const override; + + bool canDropMimeData(const QMimeData* data, Qt::DropAction action, int row, int column, const QModelIndex& parent) const override; + + bool dropMimeData(const QMimeData* data, Qt::DropAction action, int row, int column, const QModelIndex& parent) override; + + QStringList mimeTypes() const override; + QMimeData* mimeData(const QModelIndexList& indexes) const override; + + QStringList getLinkedInstancesById(const QString& id) const; + + signals: + void dataIsInvalid(); + void instancesChanged(); + void instanceSelectRequest(QString instanceId); + void groupsChanged(QSet groups); + + public slots: + void on_InstFolderChanged(const Setting& setting, QVariant value); + void on_GroupStateChanged(const QString& group, bool collapsed); + + private slots: + void propertiesChanged(BaseInstance* inst); + void providerUpdated(); + void instanceDirContentsChanged(const QString& path); + + private: + int getInstIndex(BaseInstance* inst) const; + void updateTotalPlayTime(); + void suspendWatch(); + void resumeWatch(); + void add(std::vector>& list); + void loadGroupList(); + void saveGroupList(); + QList discoverInstances(); + std::unique_ptr loadInstance(const InstanceId& id); + + void increaseGroupCount(const QString& group); + void decreaseGroupCount(const QString& group); + + private: + int m_watchLevel = 0; + int totalPlayTime = 0; + bool m_dirty = false; + std::vector> m_instances; + // id -> refs + QMap m_groupNameCache; + + SettingsObject* m_globalSettings; + QString m_instDir; + QFileSystemWatcher* m_watcher; + // FIXME: this is so inefficient that looking at it is almost painful. + QSet m_collapsedGroups; + QMap m_instanceGroupIndex; + QSet instanceSet; + bool m_groupsLoaded = false; + bool m_instancesProbed = false; + + QStack m_trashHistory; +}; diff --git a/launcher/InstancePageProvider.h b/launcher/InstancePageProvider.h new file mode 100644 index 0000000..134fb8f --- /dev/null +++ b/launcher/InstancePageProvider.h @@ -0,0 +1,56 @@ +#pragma once +#include +#include +#include "minecraft/MinecraftInstance.h" +#include "ui/pages/BasePage.h" +#include "ui/pages/BasePageProvider.h" +#include "ui/pages/instance/InstanceSettingsPage.h" +#include "ui/pages/instance/LogPage.h" +#include "ui/pages/instance/ManagedPackPage.h" +#include "ui/pages/instance/ModFolderPage.h" +#include "ui/pages/instance/NotesPage.h" +#include "ui/pages/instance/OtherLogsPage.h" +#include "ui/pages/instance/ResourcePackPage.h" +#include "ui/pages/instance/ScreenshotsPage.h" +#include "ui/pages/instance/ServersPage.h" +#include "ui/pages/instance/ShaderPackPage.h" +#include "ui/pages/instance/TexturePackPage.h" +#include "ui/pages/instance/VersionPage.h" +#include "ui/pages/instance/WorldListPage.h" + +class InstancePageProvider : protected QObject, public BasePageProvider { + Q_OBJECT + public: + explicit InstancePageProvider(BaseInstance* parent) { inst = parent; } + + virtual ~InstancePageProvider() = default; + virtual QList getPages() override + { + QList values; + values.append(new LogPage(inst)); + MinecraftInstance* onesix = dynamic_cast(inst); + values.append(new VersionPage(onesix)); + values.append(ManagedPackPage::createPage(onesix)); + auto modsPage = new ModFolderPage(onesix, onesix->loaderModList()); + modsPage->setFilter("%1 (*.zip *.jar *.litemod *.nilmod)"); + values.append(modsPage); + values.append(new CoreModFolderPage(onesix, onesix->coreModList())); + values.append(new NilModFolderPage(onesix, onesix->nilModList())); + values.append(new ResourcePackPage(onesix, onesix->resourcePackList())); + values.append(new GlobalDataPackPage(onesix)); + values.append(new TexturePackPage(onesix, onesix->texturePackList())); + values.append(new ShaderPackPage(onesix, onesix->shaderPackList())); + values.append(new NotesPage(onesix)); + values.append(new WorldListPage(onesix, onesix->worldList())); + values.append(new ServersPage(onesix)); + values.append(new ScreenshotsPage(FS::PathCombine(onesix->gameRoot(), "screenshots"))); + values.append(new InstanceSettingsPage(onesix)); + values.append(new OtherLogsPage("logs", tr("Other Logs"), "Other-Logs", inst)); + return values; + } + + virtual QString dialogTitle() override { return tr("Edit Instance (%1)").arg(inst->name()); } + + protected: + BaseInstance* inst; +}; diff --git a/launcher/InstanceTask.cpp b/launcher/InstanceTask.cpp new file mode 100644 index 0000000..01998a7 --- /dev/null +++ b/launcher/InstanceTask.cpp @@ -0,0 +1,95 @@ +#include "InstanceTask.h" +#include + +#include "Application.h" +#include "settings/SettingsObject.h" +#include "ui/dialogs/CustomMessageBox.h" + +#include + +InstanceNameChange askForChangingInstanceName(QWidget* parent, const QString& old_name, const QString& new_name) +{ + auto dialog = + CustomMessageBox::selectable(parent, QObject::tr("Change instance name"), + QObject::tr("The instance's name seems to include the old version. Would you like to update it?\n\n" + "Old name: %1\n" + "New name: %2") + .arg(old_name, new_name), + QMessageBox::Question, QMessageBox::No | QMessageBox::Yes); + auto result = dialog->exec(); + + if (result == QMessageBox::Yes) + return InstanceNameChange::ShouldChange; + return InstanceNameChange::ShouldKeep; +} + +ShouldUpdate askIfShouldUpdate(QWidget* parent, QString original_version_name) +{ + if (APPLICATION->settings()->get("SkipModpackUpdatePrompt").toBool()) + return ShouldUpdate::SkipUpdating; + + auto info = CustomMessageBox::selectable( + parent, QObject::tr("Similar modpack was found!"), + QObject::tr( + "One or more of your instances are from this same modpack%1. Do you want to create a " + "separate instance, or update the existing one?\n\nNOTE: Make sure you made a backup of your important instance data before " + "updating, as worlds can be corrupted and some configuration may be lost (due to pack overrides).") + .arg(original_version_name), + QMessageBox::Information, QMessageBox::Cancel); + QAbstractButton* update = info->addButton(QObject::tr("Update existing instance"), QMessageBox::AcceptRole); + QAbstractButton* skip = info->addButton(QObject::tr("Create new instance"), QMessageBox::ResetRole); + + info->exec(); + + if (info->clickedButton() == update) + return ShouldUpdate::Update; + if (info->clickedButton() == skip) + return ShouldUpdate::SkipUpdating; + return ShouldUpdate::Cancel; +} + +QString InstanceName::name() const +{ + if (!m_modified_name.isEmpty()) + return modifiedName(); + if (!m_original_version.isEmpty()) + return QString("%1 %2").arg(m_original_name, m_original_version); + + return m_original_name; +} + +QString InstanceName::originalName() const +{ + return m_original_name; +} + +QString InstanceName::modifiedName() const +{ + if (!m_modified_name.isEmpty()) + return m_modified_name; + return m_original_name; +} + +QString InstanceName::version() const +{ + return m_original_version; +} + +void InstanceName::setName(InstanceName& other) +{ + m_original_name = other.m_original_name; + m_original_version = other.m_original_version; + m_modified_name = other.m_modified_name; +} + +InstanceTask::InstanceTask() : Task(), InstanceName() {} + +ShouldDeleteSaves askIfShouldDeleteSaves(QWidget* parent) +{ + auto dialog = CustomMessageBox::selectable(parent, QObject::tr("Delete Existing Save Files"), + QObject::tr("An earlier version of this mod pack installed save files.\n" + "Would you like to remove those existing saves as part of this update?"), + QMessageBox::Question, QMessageBox::No | QMessageBox::Yes); + auto result = dialog->exec(); + return result == QMessageBox::Yes ? ShouldDeleteSaves::Yes : ShouldDeleteSaves::No; +} diff --git a/launcher/InstanceTask.h b/launcher/InstanceTask.h new file mode 100644 index 0000000..125930a --- /dev/null +++ b/launcher/InstanceTask.h @@ -0,0 +1,74 @@ +#pragma once + +#include "settings/SettingsObject.h" +#include "tasks/Task.h" + +/* Helpers */ +enum class InstanceNameChange { ShouldChange, ShouldKeep }; +[[nodiscard]] InstanceNameChange askForChangingInstanceName(QWidget* parent, const QString& old_name, const QString& new_name); +enum class ShouldUpdate { Update, SkipUpdating, Cancel }; +[[nodiscard]] ShouldUpdate askIfShouldUpdate(QWidget* parent, QString original_version_name); +enum class ShouldDeleteSaves { NotAsked, Yes, No }; +[[nodiscard]] ShouldDeleteSaves askIfShouldDeleteSaves(QWidget* parent); + +struct InstanceName { + public: + InstanceName() = default; + InstanceName(QString name, QString version) : m_original_name(std::move(name)), m_original_version(std::move(version)) {} + + QString modifiedName() const; + QString originalName() const; + QString name() const; + QString version() const; + + void setName(QString name) { m_modified_name = name; } + void setName(InstanceName& other); + + protected: + QString m_original_name; + QString m_original_version; + + QString m_modified_name; +}; + +class InstanceTask : public Task, public InstanceName { + Q_OBJECT + public: + InstanceTask(); + ~InstanceTask() override = default; + + void setParentSettings(SettingsObject* settings) { m_globalSettings = settings; } + + void setStagingPath(const QString& stagingPath) { m_stagingPath = stagingPath; } + + void setIcon(const QString& icon) { m_instIcon = icon; } + + void setGroup(const QString& group) { m_instGroup = group; } + QString group() const { return m_instGroup; } + + bool shouldConfirmUpdate() const { return m_confirm_update; } + void setConfirmUpdate(bool confirm) { m_confirm_update = confirm; } + + bool shouldOverride() const { return m_override_existing; } + + QString originalInstanceID() const { return m_original_instance_id; }; + + protected: + void setOverride(bool override, QString instance_id_to_override = {}) + { + m_override_existing = override; + if (!instance_id_to_override.isEmpty()) + m_original_instance_id = instance_id_to_override; + } + + protected: /* data */ + SettingsObject* m_globalSettings; + QString m_instIcon; + QString m_instGroup; + QString m_stagingPath; + + bool m_override_existing = false; + bool m_confirm_update = true; + + QString m_original_instance_id; +}; diff --git a/launcher/JavaCommon.cpp b/launcher/JavaCommon.cpp new file mode 100644 index 0000000..7bb674d --- /dev/null +++ b/launcher/JavaCommon.cpp @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "JavaCommon.h" +#include "java/JavaUtils.h" +#include "ui/dialogs/CustomMessageBox.h" + +#include + +bool JavaCommon::checkJVMArgs(QString jvmargs, QWidget* parent) +{ + static const QRegularExpression s_memRegex("-Xm[sx]"); + static const QRegularExpression s_versionRegex("-version:.*"); + if (jvmargs.contains("-XX:PermSize=") || jvmargs.contains(s_memRegex) || jvmargs.contains("-XX-MaxHeapSize") || + jvmargs.contains("-XX:InitialHeapSize")) { + auto warnStr = QObject::tr( + "You tried to manually set a JVM memory option (using \"-XX:PermSize\", \"-XX-MaxHeapSize\", \"-XX:InitialHeapSize\", \"-Xmx\" " + "or \"-Xms\").\n" + "There are dedicated boxes for these in the settings (Java tab, in the Memory group at the top).\n" + "This message will be displayed until you remove them from the JVM arguments."); + CustomMessageBox::selectable(parent, QObject::tr("JVM arguments warning"), warnStr, QMessageBox::Warning)->exec(); + return false; + } + // block lunacy with passing required version to the JVM + if (jvmargs.contains(s_versionRegex)) { + auto warnStr = QObject::tr( + "You tried to pass required Java version argument to the JVM (using \"-version:xxx\"). This is not safe and will not be " + "allowed.\n" + "This message will be displayed until you remove this from the JVM arguments."); + CustomMessageBox::selectable(parent, QObject::tr("JVM arguments warning"), warnStr, QMessageBox::Warning)->exec(); + return false; + } + return true; +} + +void JavaCommon::javaWasOk(QWidget* parent, const JavaChecker::Result& result) +{ + QString text; + text += QObject::tr( + "Java test succeeded!
Platform reported: %1
Java version " + "reported: %2
Java vendor " + "reported: %3
") + .arg(result.realPlatform, result.javaVersion.toString(), result.javaVendor); + if (result.errorLog.size()) { + auto htmlError = result.errorLog; + htmlError.replace('\n', "
"); + text += QObject::tr("
Warnings:
%1").arg(htmlError); + } + CustomMessageBox::selectable(parent, QObject::tr("Java test success"), text, QMessageBox::Information)->show(); +} + +void JavaCommon::javaArgsWereBad(QWidget* parent, const JavaChecker::Result& result) +{ + auto htmlError = result.errorLog; + QString text; + htmlError.replace('\n', "
"); + text += QObject::tr("The specified Java binary didn't work with the arguments you provided:
"); + text += QString("%1").arg(htmlError); + CustomMessageBox::selectable(parent, QObject::tr("Java test failure"), text, QMessageBox::Warning)->show(); +} + +void JavaCommon::javaBinaryWasBad(QWidget* parent, const JavaChecker::Result& result) +{ + QString text; + text += QObject::tr( + "The specified Java binary didn't work.
You should press 'Detect', " + "or set the path to the Java executable.
"); + CustomMessageBox::selectable(parent, QObject::tr("Java test failure"), text, QMessageBox::Warning)->show(); +} + +void JavaCommon::javaCheckNotFound(QWidget* parent) +{ + QString text; + text += QObject::tr("Java checker library could not be found. Please check your installation."); + CustomMessageBox::selectable(parent, QObject::tr("Java test failure"), text, QMessageBox::Warning)->show(); +} + +void JavaCommon::TestCheck::run() +{ + if (!JavaCommon::checkJVMArgs(m_args, m_parent)) { + emit finished(); + return; + } + if (JavaUtils::getJavaCheckPath().isEmpty()) { + javaCheckNotFound(m_parent); + emit finished(); + return; + } + checker.reset(new JavaChecker(m_path, "", 0, 0, 0, 0)); + connect(checker.get(), &JavaChecker::checkFinished, this, &JavaCommon::TestCheck::checkFinished); + checker->start(); +} + +void JavaCommon::TestCheck::checkFinished(const JavaChecker::Result& result) +{ + if (result.validity != JavaChecker::Result::Validity::Valid) { + javaBinaryWasBad(m_parent, result); + emit finished(); + return; + } + checker.reset(new JavaChecker(m_path, m_args, m_maxMem, m_maxMem, result.javaVersion.requiresPermGen() ? m_permGen : 0, 0)); + connect(checker.get(), &JavaChecker::checkFinished, this, &JavaCommon::TestCheck::checkFinishedWithArgs); + checker->start(); +} + +void JavaCommon::TestCheck::checkFinishedWithArgs(const JavaChecker::Result& result) +{ + if (result.validity == JavaChecker::Result::Validity::Valid) { + javaWasOk(m_parent, result); + emit finished(); + return; + } + javaArgsWereBad(m_parent, result); + emit finished(); +} diff --git a/launcher/JavaCommon.h b/launcher/JavaCommon.h new file mode 100644 index 0000000..0e4aa2b --- /dev/null +++ b/launcher/JavaCommon.h @@ -0,0 +1,47 @@ +#pragma once +#include + +class QWidget; + +/** + * Common UI bits for the java pages to use. + */ +namespace JavaCommon { +bool checkJVMArgs(QString args, QWidget* parent); + +// Show a dialog saying that the Java binary was usable +void javaWasOk(QWidget* parent, const JavaChecker::Result& result); +// Show a dialog saying that the Java binary was not usable because of bad options +void javaArgsWereBad(QWidget* parent, const JavaChecker::Result& result); +// Show a dialog saying that the Java binary was not usable +void javaBinaryWasBad(QWidget* parent, const JavaChecker::Result& result); +// Show a dialog if we couldn't find Java Checker +void javaCheckNotFound(QWidget* parent); + +class TestCheck : public QObject { + Q_OBJECT + public: + TestCheck(QWidget* parent, QString path, QString args, int minMem, int maxMem, int permGen) + : m_parent(parent), m_path(path), m_args(args), m_minMem(minMem), m_maxMem(maxMem), m_permGen(permGen) + {} + virtual ~TestCheck() = default; + + void run(); + + signals: + void finished(); + + private slots: + void checkFinished(const JavaChecker::Result& result); + void checkFinishedWithArgs(const JavaChecker::Result& result); + + private: + JavaChecker::Ptr checker; + QWidget* m_parent = nullptr; + QString m_path; + QString m_args; + int m_minMem = 0; + int m_maxMem = 0; + int m_permGen = 64; +}; +} // namespace JavaCommon diff --git a/launcher/Json.cpp b/launcher/Json.cpp new file mode 100644 index 0000000..2d3372e --- /dev/null +++ b/launcher/Json.cpp @@ -0,0 +1,341 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "Json.h" + +#include + +#include +#include "FileSystem.h" + +namespace Json { +void write(const QJsonDocument& doc, const QString& filename) +{ + FS::write(filename, doc.toJson()); +} +void write(const QJsonObject& object, const QString& filename) +{ + write(QJsonDocument(object), filename); +} +void write(const QJsonArray& array, const QString& filename) +{ + write(QJsonDocument(array), filename); +} + +QByteArray toText(const QJsonObject& obj) +{ + return QJsonDocument(obj).toJson(QJsonDocument::Compact); +} +QByteArray toText(const QJsonArray& array) +{ + return QJsonDocument(array).toJson(QJsonDocument::Compact); +} + +static bool isBinaryJson(const QByteArray& data) +{ + decltype(QJsonDocument::BinaryFormatTag) tag = QJsonDocument::BinaryFormatTag; + return memcmp(data.constData(), &tag, sizeof(QJsonDocument::BinaryFormatTag)) == 0; +} +QJsonDocument requireDocument(const QByteArray& data, const QString& what) +{ + if (isBinaryJson(data)) { + // FIXME: Is this needed? + throw JsonException(what + ": Invalid JSON. Binary JSON unsupported"); + } else { + QJsonParseError error; + QJsonDocument doc = QJsonDocument::fromJson(data, &error); + if (error.error != QJsonParseError::NoError) { + throw JsonException(what + ": Error parsing JSON: " + error.errorString()); + } + return doc; + } +} +QJsonDocument requireDocument(const QString& filename, const QString& what) +{ + return requireDocument(FS::read(filename), what); +} +QJsonObject requireObject(const QJsonDocument& doc, const QString& what) +{ + if (!doc.isObject()) { + throw JsonException(what + " is not an object"); + } + return doc.object(); +} +QJsonArray requireArray(const QJsonDocument& doc, const QString& what) +{ + if (!doc.isArray()) { + throw JsonException(what + " is not an array"); + } + return doc.array(); +} + +QJsonDocument parseUntilGarbage(const QByteArray& json, QJsonParseError* error, QString* garbage) +{ + auto doc = QJsonDocument::fromJson(json, error); + if (error->error == QJsonParseError::GarbageAtEnd) { + qsizetype offset = error->offset; + QByteArray validJson = json.left(offset); + doc = QJsonDocument::fromJson(validJson, error); + + if (garbage) + *garbage = json.right(json.size() - offset); + } + + return doc; +} + +void writeString(QJsonObject& to, const QString& key, const QString& value) +{ + if (!value.isEmpty()) { + to.insert(key, value); + } +} + +void writeStringList(QJsonObject& to, const QString& key, const QStringList& values) +{ + if (!values.isEmpty()) { + QJsonArray array; + for (auto value : values) { + array.append(value); + } + to.insert(key, array); + } +} + +template <> +QJsonValue toJson(const QUrl& url) +{ + return QJsonValue(url.toString(QUrl::FullyEncoded)); +} +template <> +QJsonValue toJson(const QByteArray& data) +{ + return QJsonValue(QString::fromLatin1(data.toHex())); +} +template <> +QJsonValue toJson(const QDateTime& datetime) +{ + return QJsonValue(datetime.toString(Qt::ISODate)); +} +template <> +QJsonValue toJson(const QDir& dir) +{ + return QDir::current().relativeFilePath(dir.absolutePath()); +} +template <> +QJsonValue toJson(const QUuid& uuid) +{ + return uuid.toString(); +} +template <> +QJsonValue toJson(const QVariant& variant) +{ + return QJsonValue::fromVariant(variant); +} + +template <> +QByteArray requireIsType(const QJsonValue& value, const QString& what) +{ + const QString string = value.toString(what); + // ensure that the string can be safely cast to Latin1 + if (string != QString::fromLatin1(string.toLatin1())) { + throw JsonException(what + " is not encodable as Latin1"); + } + return QByteArray::fromHex(string.toLatin1()); +} + +template <> +QJsonArray requireIsType(const QJsonValue& value, const QString& what) +{ + if (!value.isArray()) { + throw JsonException(what + " is not an array"); + } + return value.toArray(); +} + +template <> +QString requireIsType(const QJsonValue& value, const QString& what) +{ + if (!value.isString()) { + throw JsonException(what + " is not a string"); + } + return value.toString(); +} + +template <> +bool requireIsType(const QJsonValue& value, const QString& what) +{ + if (!value.isBool()) { + throw JsonException(what + " is not a bool"); + } + return value.toBool(); +} + +template <> +double requireIsType(const QJsonValue& value, const QString& what) +{ + if (!value.isDouble()) { + throw JsonException(what + " is not a double"); + } + return value.toDouble(); +} + +template <> +int requireIsType(const QJsonValue& value, const QString& what) +{ + const double doubl = requireIsType(value, what); + if (fmod(doubl, 1) != 0) { + throw JsonException(what + " is not an integer"); + } + return int(doubl); +} + +template <> +QDateTime requireIsType(const QJsonValue& value, const QString& what) +{ + const QString string = requireIsType(value, what); + const QDateTime datetime = QDateTime::fromString(string, Qt::ISODate); + if (!datetime.isValid()) { + throw JsonException(what + " is not a ISO formatted date/time value"); + } + return datetime; +} + +template <> +QUrl requireIsType(const QJsonValue& value, const QString& what) +{ + const QString string = value.toString(what); + if (string.isEmpty()) { + return QUrl(); + } + const QUrl url = QUrl(string, QUrl::StrictMode); + if (!url.isValid()) { + throw JsonException(what + " is not a correctly formatted URL"); + } + return url; +} + +template <> +QDir requireIsType(const QJsonValue& value, const QString& what) +{ + const QString string = requireIsType(value, what); + // FIXME: does not handle invalid characters! + return QDir::current().absoluteFilePath(string); +} + +template <> +QUuid requireIsType(const QJsonValue& value, const QString& what) +{ + const QString string = requireIsType(value, what); + const QUuid uuid = QUuid(string); + if (uuid.toString() != string) // converts back => valid + { + throw JsonException(what + " is not a valid UUID"); + } + return uuid; +} + +template <> +QJsonObject requireIsType(const QJsonValue& value, const QString& what) +{ + if (!value.isObject()) { + throw JsonException(what + " is not an object"); + } + return value.toObject(); +} + +template <> +QVariant requireIsType(const QJsonValue& value, const QString& what) +{ + if (value.isNull() || value.isUndefined()) { + throw JsonException(what + " is null or undefined"); + } + return value.toVariant(); +} + +template <> +QJsonValue requireIsType(const QJsonValue& value, const QString& what) +{ + if (value.isNull() || value.isUndefined()) { + throw JsonException(what + " is null or undefined"); + } + return value; +} + +QStringList toStringList(const QString& jsonString) +{ + QJsonParseError parseError; + QJsonDocument doc = QJsonDocument::fromJson(jsonString.toUtf8(), &parseError); + + if (parseError.error != QJsonParseError::NoError || !doc.isArray()) + return {}; + try { + return requireIsArrayOf(doc); + } catch (Json::JsonException&) { + return {}; + } +} + +QString fromStringList(const QStringList& list) +{ + QJsonArray array; + for (const QString& str : list) { + array.append(str); + } + + QJsonDocument doc(toJsonArray(list)); + return QString::fromUtf8(doc.toJson(QJsonDocument::Compact)); +} + +QVariantMap toMap(const QString& jsonString) +{ + QJsonParseError parseError; + QJsonDocument doc = QJsonDocument::fromJson(jsonString.toUtf8(), &parseError); + + if (parseError.error != QJsonParseError::NoError || !doc.isObject()) + return {}; + + QJsonObject obj = doc.object(); + return obj.toVariantMap(); +} + +QString fromMap(const QVariantMap& map) +{ + QJsonObject obj = QJsonObject::fromVariantMap(map); + QJsonDocument doc(obj); + return QString::fromUtf8(doc.toJson(QJsonDocument::Compact)); +} + +} // namespace Json diff --git a/launcher/Json.h b/launcher/Json.h new file mode 100644 index 0000000..7a50af1 --- /dev/null +++ b/launcher/Json.h @@ -0,0 +1,234 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Exception.h" + +namespace Json { +class JsonException : public ::Exception { + public: + JsonException(const QString& message) : Exception(message) {} +}; + +/// @throw FileSystemException +void write(const QJsonDocument& doc, const QString& filename); +/// @throw FileSystemException +void write(const QJsonObject& object, const QString& filename); +/// @throw FileSystemException +void write(const QJsonArray& array, const QString& filename); + +QByteArray toText(const QJsonObject& obj); +QByteArray toText(const QJsonArray& array); + +/// @throw JsonException +QJsonDocument requireDocument(const QByteArray& data, const QString& what = "Document"); +/// @throw JsonException +QJsonDocument requireDocument(const QString& filename, const QString& what = "Document"); +/// @throw JsonException +QJsonObject requireObject(const QJsonDocument& doc, const QString& what = "Document"); +/// @throw JsonException +QJsonArray requireArray(const QJsonDocument& doc, const QString& what = "Document"); + +/////////////////// WRITING //////////////////// + +void writeString(QJsonObject& to, const QString& key, const QString& value); +void writeStringList(QJsonObject& to, const QString& key, const QStringList& values); + +template +QJsonValue toJson(const T& t) +{ + return QJsonValue(t); +} +template <> +QJsonValue toJson(const QUrl& url); +template <> +QJsonValue toJson(const QByteArray& data); +template <> +QJsonValue toJson(const QDateTime& datetime); +template <> +QJsonValue toJson(const QDir& dir); +template <> +QJsonValue toJson(const QUuid& uuid); +template <> +QJsonValue toJson(const QVariant& variant); + +template +QJsonArray toJsonArray(const QList& container) +{ + QJsonArray array; + for (const T& item : container) { + array.append(toJson(item)); + } + return array; +} + +////////////////// READING //////////////////// + +// Attempt to parse JSON up until garbage is encountered +QJsonDocument parseUntilGarbage(const QByteArray& json, QJsonParseError* error = nullptr, QString* garbage = nullptr); + +/// @throw JsonException +template +T requireIsType(const QJsonValue& value, const QString& what = "Value"); + +/// @throw JsonException +template <> +double requireIsType(const QJsonValue& value, const QString& what); +/// @throw JsonException +template <> +bool requireIsType(const QJsonValue& value, const QString& what); +/// @throw JsonException +template <> +int requireIsType(const QJsonValue& value, const QString& what); +/// @throw JsonException +template <> +QJsonObject requireIsType(const QJsonValue& value, const QString& what); +/// @throw JsonException +template <> +QJsonArray requireIsType(const QJsonValue& value, const QString& what); +/// @throw JsonException +template <> +QJsonValue requireIsType(const QJsonValue& value, const QString& what); +/// @throw JsonException +template <> +QByteArray requireIsType(const QJsonValue& value, const QString& what); +/// @throw JsonException +template <> +QDateTime requireIsType(const QJsonValue& value, const QString& what); +/// @throw JsonException +template <> +QVariant requireIsType(const QJsonValue& value, const QString& what); +/// @throw JsonException +template <> +QString requireIsType(const QJsonValue& value, const QString& what); +/// @throw JsonException +template <> +QUuid requireIsType(const QJsonValue& value, const QString& what); +/// @throw JsonException +template <> +QDir requireIsType(const QJsonValue& value, const QString& what); +/// @throw JsonException +template <> +QUrl requireIsType(const QJsonValue& value, const QString& what); + +// the following functions are higher level functions, that make use of the above functions for +// type conversion + +/// @throw JsonException +template +T requireIsType(const QJsonObject& parent, const QString& key, const QString& what = "__placeholder__") +{ + const QString localWhat = QString(what).replace("__placeholder__", '\'' + key + '\''); + if (!parent.contains(key)) { + throw JsonException(localWhat + "s parent does not contain " + localWhat); + } + return requireIsType(parent.value(key), localWhat); +} + +template +QList requireIsArrayOf(const QJsonDocument& doc) +{ + const QJsonArray array = requireArray(doc); + QList out; + for (const QJsonValue val : array) { + out.append(requireIsType(val, "Document")); + } + return out; +} + +/// @throw JsonException +template +QList requireIsArrayOf(const QJsonObject& parent, const QString& key, const QString& what = "__placeholder__") +{ + const QString localWhat = QString(what).replace("__placeholder__", '\'' + key + '\''); + if (!parent.contains(key)) { + throw JsonException(localWhat + "s parent does not contain " + localWhat); + } + + const QJsonArray array = parent[key].toArray(); + QList out; + for (const QJsonValue val : array) { + out.append(requireIsType(val, "Document")); + } + return out; +} + +// this macro part could be replaced by variadic functions that just pass on their arguments, but that wouldn't work well with IDE helpers +#define JSON_HELPERFUNCTIONS(NAME, TYPE) \ + inline TYPE require##NAME(const QJsonValue& value, const QString& what = "Value") \ + { \ + return requireIsType(value, what); \ + } \ + inline TYPE require##NAME(const QJsonObject& parent, const QString& key, const QString& what = "__placeholder__") \ + { \ + return requireIsType(parent, key, what); \ + } + +JSON_HELPERFUNCTIONS(Array, QJsonArray) +JSON_HELPERFUNCTIONS(Object, QJsonObject) +JSON_HELPERFUNCTIONS(JsonValue, QJsonValue) +JSON_HELPERFUNCTIONS(String, QString) +JSON_HELPERFUNCTIONS(Boolean, bool) +JSON_HELPERFUNCTIONS(Double, double) +JSON_HELPERFUNCTIONS(Integer, int) +JSON_HELPERFUNCTIONS(DateTime, QDateTime) +JSON_HELPERFUNCTIONS(Url, QUrl) +JSON_HELPERFUNCTIONS(ByteArray, QByteArray) +JSON_HELPERFUNCTIONS(Dir, QDir) +JSON_HELPERFUNCTIONS(Uuid, QUuid) +JSON_HELPERFUNCTIONS(Variant, QVariant) + +#undef JSON_HELPERFUNCTIONS + +// helper functions for settings +QStringList toStringList(const QString& jsonString); +QString fromStringList(const QStringList& list); + +QVariantMap toMap(const QString& jsonString); +QString fromMap(const QVariantMap& map); + +} // namespace Json +using JSONValidationError = Json::JsonException; diff --git a/launcher/KonamiCode.cpp b/launcher/KonamiCode.cpp new file mode 100644 index 0000000..f9ec3b8 --- /dev/null +++ b/launcher/KonamiCode.cpp @@ -0,0 +1,28 @@ +#include "KonamiCode.h" + +#include +#include + +namespace { +const std::array konamiCode = { { Qt::Key_Up, Qt::Key_Up, Qt::Key_Down, Qt::Key_Down, Qt::Key_Left, Qt::Key_Right, + Qt::Key_Left, Qt::Key_Right, Qt::Key_B, Qt::Key_A } }; +} + +KonamiCode::KonamiCode(QObject* parent) : QObject(parent) {} + +void KonamiCode::input(QEvent* event) +{ + if (event->type() == QEvent::KeyPress) { + QKeyEvent* keyEvent = static_cast(event); + auto key = Qt::Key(keyEvent->key()); + if (key == konamiCode[m_progress]) { + m_progress++; + } else { + m_progress = 0; + } + if (m_progress == static_cast(konamiCode.size())) { + m_progress = 0; + emit triggered(); + } + } +} diff --git a/launcher/KonamiCode.h b/launcher/KonamiCode.h new file mode 100644 index 0000000..5c21e2f --- /dev/null +++ b/launcher/KonamiCode.h @@ -0,0 +1,16 @@ +#pragma once + +#include + +class KonamiCode : public QObject { + Q_OBJECT + public: + KonamiCode(QObject* parent = 0); + void input(QEvent* event); + + signals: + void triggered(); + + private: + int m_progress = 0; +}; diff --git a/launcher/LaunchController.cpp b/launcher/LaunchController.cpp new file mode 100644 index 0000000..d263cc5 --- /dev/null +++ b/launcher/LaunchController.cpp @@ -0,0 +1,496 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "LaunchController.h" +#include "Application.h" +#include "launch/steps/PrintServers.h" +#include "minecraft/auth/AccountData.h" +#include "minecraft/auth/AccountList.h" + +#include "ui/InstanceWindow.h" +#include "ui/dialogs/CustomMessageBox.h" +#include "ui/dialogs/MSALoginDialog.h" +#include "ui/dialogs/ProfileSelectDialog.h" +#include "ui/dialogs/ProfileSetupDialog.h" +#include "ui/dialogs/ProgressDialog.h" + +#include +#include +#include +#include + +#include "BuildConfig.h" +#include "JavaCommon.h" +#include "launch/steps/TextPrint.h" +#include "tasks/Task.h" +#include "ui/dialogs/ChooseOfflineNameDialog.h" + +LaunchController::LaunchController() = default; + +void LaunchController::executeTask() +{ + if (!m_instance) { + emitFailed(tr("No instance specified!")); + return; + } + + if (!JavaCommon::checkJVMArgs(m_instance->settings()->get("JvmArgs").toString(), m_parentWidget)) { + emitFailed(tr("Invalid Java arguments specified. Please fix this first.")); + return; + } + + login(); +} + +void LaunchController::decideAccount() +{ + if (m_accountToUse) { + return; + } + + // Select the account to use. If the instance has a specific account set, that will be used. Otherwise, the default account will be used + auto* accounts = APPLICATION->accounts(); + const auto instanceAccountId = m_instance->settings()->get("InstanceAccountId").toString(); + const auto instanceAccountIndex = accounts->findAccountByProfileId(instanceAccountId); + if (instanceAccountIndex == -1 || instanceAccountId.isEmpty()) { + m_accountToUse = accounts->defaultAccount(); + } else { + m_accountToUse = accounts->at(instanceAccountIndex); + } + + if (!accounts->anyAccountIsValid()) { + // Tell the user they need to log in at least one account in order to play. + auto reply = CustomMessageBox::selectable(m_parentWidget, tr("No Accounts"), + tr("In order to play Minecraft, you must have at least one Microsoft " + "account which owns Minecraft logged in. " + "Would you like to open the account manager to add an account now?"), + QMessageBox::Information, QMessageBox::Yes | QMessageBox::No) + ->exec(); + + if (reply == QMessageBox::Yes) { + // Open the account manager. + APPLICATION->ShowGlobalSettings(m_parentWidget, "accounts"); + } else if (reply == QMessageBox::No) { + // Do not open "profile select" dialog. + return; + } + } + + if (!m_accountToUse) { + // If no default account is set, ask the user which one to use. + ProfileSelectDialog selectDialog(tr("Which account would you like to use?"), ProfileSelectDialog::GlobalDefaultCheckbox, + m_parentWidget); + + selectDialog.exec(); + + // Launch the instance with the selected account. + m_accountToUse = selectDialog.selectedAccount(); + + // If the user said to use the account as default, do that. + if (selectDialog.useAsGlobalDefault() && m_accountToUse) { + accounts->setDefaultAccount(m_accountToUse); + } + } +} + +LaunchDecision LaunchController::decideLaunchMode() +{ + if (!m_accountToUse || m_wantedLaunchMode == LaunchMode::Demo) { + m_actualLaunchMode = LaunchMode::Demo; + return LaunchDecision::Continue; + } + + if (m_wantedLaunchMode == LaunchMode::Normal) { + if (m_accountToUse->shouldRefresh() || m_accountToUse->accountState() == AccountState::Offline) { + // Force account refresh on the account used to launch the instance updating the AccountState + // only on first try and if it is not meant to be offline + m_accountToUse->refresh(); + } + } + + const auto* accounts = APPLICATION->accounts(); + MinecraftAccountPtr accountToCheck = nullptr; + + if (m_accountToUse->accountType() != AccountType::Offline) { + accountToCheck = m_accountToUse->ownsMinecraft() ? m_accountToUse : nullptr; + } else if (const auto defaultAccount = accounts->defaultAccount(); defaultAccount && defaultAccount->ownsMinecraft()) { + accountToCheck = defaultAccount; + } else { + for (int i = 0; i < accounts->count(); i++) { + if (const auto account = accounts->at(i); account->ownsMinecraft()) { + accountToCheck = account; + break; + } + } + } + + if (!accountToCheck) { + m_actualLaunchMode = LaunchMode::Demo; + return LaunchDecision::Continue; + } + + auto state = accountToCheck->accountState(); + if (state == AccountState::Unchecked || state == AccountState::Errored) { + accountToCheck->refresh(); + state = AccountState::Working; + } + + if (state == AccountState::Working) { + // refresh is in progress, we need to wait for it to finish to proceed. + ProgressDialog progDialog(m_parentWidget); + progDialog.setSkipButton(true, tr("Abort")); + + // TODO: this relies on tasks' synchronous signal dispatching nature + // TODO: meaning currentTask can't complete and become null while this code is running + // TODO: this code will produce a race condition when tasks become fully async + auto task = accountToCheck->currentTask(); + progDialog.execWithTask(task.get()); + + if (task->getState() == State::AbortedByUser) { + return LaunchDecision::Abort; + } + + state = accountToCheck->accountState(); + } + + QString reauthReason; + switch (state) { + case AccountState::Errored: + reauthReason = tr("An error occurred while refreshing '%1'").arg(accountToCheck->profileName()); + break; + case AccountState::Expired: + reauthReason = tr("'%1' has expired and needs to be reauthenticated").arg(accountToCheck->profileName()); + break; + case AccountState::Disabled: + reauthReason = tr("The launcher's client identification has changed"); + break; + case AccountState::Gone: + reauthReason = tr("'%1' no longer exists on the servers").arg(accountToCheck->profileName()); + break; + default: + m_actualLaunchMode = + state == AccountState::Online && m_wantedLaunchMode == LaunchMode::Normal ? LaunchMode::Normal : LaunchMode::Offline; + return LaunchDecision::Continue; // All good to go + } + + if (reauthenticateAccount(accountToCheck, reauthReason)) { + return LaunchDecision::Undecided; + } + + return LaunchDecision::Abort; +} + +bool LaunchController::askPlayDemo() const +{ + QMessageBox box(m_parentWidget); + box.setWindowTitle(tr("Play demo?")); + QString text = m_accountToUse + ? tr("This account does not own Minecraft.\nYou need to purchase the game first to play the full version.") + : tr("No account was selected for launch."); + text += tr("\n\nDo you want to play the demo?"); + box.setText(text); + box.setIcon(QMessageBox::Warning); + const auto* demoButton = box.addButton(tr("Play Demo"), QMessageBox::ButtonRole::YesRole); + auto* cancelButton = box.addButton(tr("Cancel"), QMessageBox::ButtonRole::NoRole); + box.setDefaultButton(cancelButton); + + box.exec(); + return box.clickedButton() == demoButton; +} + +QString LaunchController::askOfflineName(const QString& playerName, bool* ok) const +{ + if (ok != nullptr) { + *ok = false; + } + + QString message; + switch (m_actualLaunchMode) { + case LaunchMode::Normal: + Q_ASSERT(false); + return ""; + case LaunchMode::Demo: + message = tr("Choose your demo mode player name"); + break; + case LaunchMode::Offline: + if (m_wantedLaunchMode == LaunchMode::Normal) { + message = tr("You are not connected to the Internet, launching in offline mode\n\n"); + } + message += tr("Choose your offline mode player name"); + break; + } + + const QString lastOfflinePlayerName = APPLICATION->settings()->get("LastOfflinePlayerName").toString(); + QString usedname = lastOfflinePlayerName.isEmpty() ? playerName : lastOfflinePlayerName; + + ChooseOfflineNameDialog dialog(message, m_parentWidget); + dialog.setWindowTitle(tr("Player name")); + dialog.setUsername(usedname); + if (dialog.exec() != QDialog::Accepted) { + return {}; + } + + usedname = dialog.getUsername(); + APPLICATION->settings()->set("LastOfflinePlayerName", usedname); + + if (ok != nullptr) { + *ok = true; + } + return usedname; +} + +void LaunchController::login() +{ + decideAccount(); + + LaunchDecision decision = decideLaunchMode(); + while (decision == LaunchDecision::Undecided) { + decision = decideLaunchMode(); + } + if (decision == LaunchDecision::Abort) { + emitAborted(); + return; + } + + if (m_actualLaunchMode == LaunchMode::Demo) { + if (m_wantedLaunchMode == LaunchMode::Demo || askPlayDemo()) { + bool ok = false; + auto name = askOfflineName("Player", &ok); + if (ok) { + m_session = std::make_shared(); + m_session->MakeDemo(name, MinecraftAccount::uuidFromUsername(name).toString(QUuid::Id128)); + launchInstance(); + return; + } + } + + emitFailed(tr("No account selected for launch")); + return; + } + + m_session = std::make_shared(); + m_session->launchMode = m_actualLaunchMode; + m_accountToUse->fillSession(m_session); + + if (m_accountToUse->accountType() != AccountType::Offline) { + if (m_actualLaunchMode == LaunchMode::Normal && !m_accountToUse->hasProfile()) { + // Now handle setting up a profile name here... + if (ProfileSetupDialog dialog(m_accountToUse, m_parentWidget); dialog.exec() != QDialog::Accepted) { + emitAborted(); + return; + } + } + + if (m_actualLaunchMode == LaunchMode::Offline && m_accountToUse->accountType() != AccountType::Offline) { + bool ok = false; + QString name = m_offlineName; + if (name.isEmpty()) { + name = askOfflineName(m_session->player_name, &ok); + if (!ok) { + emitAborted(); + return; + } + } + m_session->MakeOffline(name); + } + } + + launchInstance(); +} + +bool LaunchController::reauthenticateAccount(const MinecraftAccountPtr& account, const QString& reason) +{ + auto button = QMessageBox::warning( + m_parentWidget, tr("Account refresh failed"), tr("%1. Do you want to reauthenticate this account?").arg(reason), + QMessageBox::StandardButton::Yes | QMessageBox::StandardButton::No, QMessageBox::StandardButton::Yes); + if (button == QMessageBox::StandardButton::Yes) { + auto* accounts = APPLICATION->accounts(); + const bool isDefault = accounts->defaultAccount() == account; + accounts->removeAccount(accounts->index(accounts->findAccountByProfileId(account->profileId()))); + if (account->accountType() == AccountType::MSA) { + auto newAccount = MSALoginDialog::newAccount(m_parentWidget); + + if (newAccount != nullptr) { + accounts->addAccount(newAccount); + + if (isDefault) { + accounts->setDefaultAccount(newAccount); + } + + if (m_accountToUse == account) { + m_accountToUse = nullptr; + decideAccount(); + } + return true; + } + } + } + + return false; +} + +void LaunchController::launchInstance() +{ + Q_ASSERT(m_instance != nullptr); + Q_ASSERT(m_session.get() != nullptr); + + if (!m_instance->reloadSettings()) { + QMessageBox::critical(m_parentWidget, tr("Error!"), tr("Couldn't load the instance profile.")); + emitFailed(tr("Couldn't load the instance profile.")); + return; + } + + m_launcher = m_instance->createLaunchTask(m_session, m_targetToJoin); + if (!m_launcher) { + emitFailed(tr("Couldn't instantiate a launcher.")); + return; + } + + const auto* console = qobject_cast(m_parentWidget); + const auto showConsole = m_instance->settings()->get("ShowConsole").toBool(); + if (!console && showConsole) { + APPLICATION->showInstanceWindow(m_instance); + } + connect(m_launcher, &LaunchTask::readyForLaunch, this, &LaunchController::readyForLaunch); + connect(m_launcher, &LaunchTask::succeeded, this, &LaunchController::onSucceeded); + connect(m_launcher, &LaunchTask::failed, this, &LaunchController::onFailed); + connect(m_launcher, &LaunchTask::requestProgress, this, &LaunchController::onProgressRequested); + + // Prepend Online and Auth Status + QString online_mode; + if (m_actualLaunchMode == LaunchMode::Normal) { + online_mode = "online"; + + // Prepend Server Status + const QStringList servers = { "login.microsoftonline.com", "session.minecraft.net", "textures.minecraft.net", "api.mojang.com" }; + + m_launcher->prependStep(makeShared(m_launcher, servers)); + } else { + online_mode = m_actualLaunchMode == LaunchMode::Demo ? "demo" : "offline"; + } + + m_launcher->prependStep(makeShared(m_launcher, "Launched instance in " + online_mode + " mode\n", MessageLevel::Launcher)); + + // Prepend Version + { + auto versionString = QString("%1 version: %2 (%3)") + .arg(BuildConfig.LAUNCHER_DISPLAYNAME, BuildConfig.printableVersionString(), BuildConfig.BUILD_PLATFORM); + m_launcher->prependStep(makeShared(m_launcher, versionString + "\n", MessageLevel::Launcher)); + } + m_launcher->start(); +} + +void LaunchController::readyForLaunch() +{ + if (!m_profiler) { + m_launcher->proceed(); + return; + } + + QString error; + if (!m_profiler->check(&error)) { + m_launcher->abort(); + emitFailed("Profiler startup failed!"); + QMessageBox::critical(m_parentWidget, tr("Error!"), tr("Profiler check for %1 failed: %2").arg(m_profiler->name(), error)); + return; + } + BaseProfiler* profilerInstance = m_profiler->createProfiler(m_launcher->instance(), this); + + connect(profilerInstance, &BaseProfiler::readyToLaunch, [this](const QString& message) { + QMessageBox msg(m_parentWidget); + msg.setText(tr("The game launch is delayed until you press the " + "button. This is the right time to setup the profiler, as the " + "profiler server is running now.\n\n%1") + .arg(message)); + msg.setWindowTitle(tr("Waiting.")); + msg.setIcon(QMessageBox::Information); + msg.addButton(tr("&Launch"), QMessageBox::AcceptRole); + msg.exec(); + m_launcher->proceed(); + }); + connect(profilerInstance, &BaseProfiler::abortLaunch, [this](const QString& message) { + QMessageBox msg; + msg.setText(tr("Couldn't start the profiler: %1").arg(message)); + msg.setWindowTitle(tr("Error")); + msg.setIcon(QMessageBox::Critical); + msg.addButton(QMessageBox::Ok); + msg.setModal(true); + msg.exec(); + m_launcher->abort(); + emitFailed("Profiler startup failed!"); + }); + profilerInstance->beginProfiling(m_launcher); +} + +void LaunchController::onSucceeded() +{ + emitSucceeded(); +} + +void LaunchController::onFailed(QString reason) +{ + if (m_instance->settings()->get("ShowConsoleOnError").toBool()) { + APPLICATION->showInstanceWindow(m_instance, "console"); + } + emitFailed(std::move(reason)); +} + +void LaunchController::onProgressRequested(Task* task) const +{ + ProgressDialog progDialog(m_parentWidget); + progDialog.setSkipButton(true, tr("Abort")); + m_launcher->proceed(); + progDialog.execWithTask(task); +} + +bool LaunchController::abort() +{ + if (!m_launcher) { + return true; + } + if (!m_launcher->canAbort()) { + return false; + } + auto response = CustomMessageBox::selectable(m_parentWidget, tr("Kill Minecraft?"), + tr("This can cause the instance to get corrupted and should only be used if Minecraft " + "is frozen for some reason"), + QMessageBox::Question, QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes) + ->exec(); + if (response == QMessageBox::Yes) { + return m_launcher->abort(); + } + return false; +} diff --git a/launcher/LaunchController.h b/launcher/LaunchController.h new file mode 100644 index 0000000..742f205 --- /dev/null +++ b/launcher/LaunchController.h @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once +#include +#include + +#include "minecraft/auth/MinecraftAccount.h" +#include "minecraft/launch/MinecraftTarget.h" + +class InstanceWindow; + +enum class LaunchDecision { Undecided, Continue, Abort }; + +class LaunchController : public Task { + Q_OBJECT + public: + void executeTask() override; + + LaunchController(); + ~LaunchController() override = default; + + void setInstance(BaseInstance* instance) { m_instance = instance; } + + BaseInstance* instance() const { return m_instance; } + + void setLaunchMode(const LaunchMode mode) { m_wantedLaunchMode = mode; } + + void setOfflineName(const QString& offlineName) { m_offlineName = offlineName; } + + void setProfiler(BaseProfilerFactory* profiler) { m_profiler = profiler; } + + void setParentWidget(QWidget* widget) { m_parentWidget = widget; } + + void setTargetToJoin(MinecraftTarget::Ptr targetToJoin) { m_targetToJoin = std::move(targetToJoin); } + + void setAccountToUse(MinecraftAccountPtr accountToUse) { m_accountToUse = std::move(accountToUse); } + + QString id() const { return m_instance->id(); } + + bool abort() override; + + private: + void login(); + void launchInstance(); + void decideAccount(); + LaunchDecision decideLaunchMode(); + bool askPlayDemo() const; + QString askOfflineName(const QString& playerName, bool* ok = nullptr) const; + bool reauthenticateAccount(const MinecraftAccountPtr& account, const QString& reason); + + private slots: + void readyForLaunch(); + + void onSucceeded(); + void onFailed(QString reason); + void onProgressRequested(Task* task) const; + + private: + LaunchMode m_wantedLaunchMode = LaunchMode::Normal; + LaunchMode m_actualLaunchMode = LaunchMode::Normal; + BaseProfilerFactory* m_profiler = nullptr; + QString m_offlineName; + BaseInstance* m_instance = nullptr; + QWidget* m_parentWidget = nullptr; + InstanceWindow* m_console = nullptr; + MinecraftAccountPtr m_accountToUse = nullptr; + AuthSessionPtr m_session = nullptr; + LaunchTask* m_launcher = nullptr; + MinecraftTarget::Ptr m_targetToJoin = nullptr; +}; diff --git a/launcher/LaunchMode.h b/launcher/LaunchMode.h new file mode 100644 index 0000000..45cfe50 --- /dev/null +++ b/launcher/LaunchMode.h @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2026 Octol1ttle + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +enum class LaunchMode { + Normal, + Offline, + Demo, +}; diff --git a/launcher/Launcher.in b/launcher/Launcher.in new file mode 100755 index 0000000..28ba32b --- /dev/null +++ b/launcher/Launcher.in @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Basic start script for running the launcher with the libs packaged with it. + +function printerror { + printf "$1" + if which zenity >/dev/null; then zenity --error --text="$1" &>/dev/null; + elif which kdialog >/dev/null; then kdialog --error "$1" &>/dev/null; + fi +} + +if [[ $EUID -eq 0 ]]; then + printerror "This program should not be run using sudo or as the root user!\n" + exit 1 +fi + + +LAUNCHER_NAME=@Launcher_APP_BINARY_NAME@ +LAUNCHER_ENVNAME=@Launcher_ENVName@ +LAUNCHER_DIR="$(dirname "$(readlink -f "$0")")" +echo "Launcher Dir: ${LAUNCHER_DIR}" + +# Makes the launcher use portals for file picking +export QT_QPA_PLATFORMTHEME=xdgdesktopportal + +# disable OpenGL and Vulkan launcher features on sharun until https://github.com/VHSgunzo/sharun/issues/35 +if [[ -f "${LAUNCHER_DIR}/sharun" ]]; then + export ${LAUNCHER_ENVNAME}_DISABLE_GLVULKAN=1 +fi + +# Just to be sure... +chmod +x "${LAUNCHER_DIR}/bin/${LAUNCHER_NAME}" + +ARGS=("${LAUNCHER_DIR}/${LAUNCHER_NAME}" "${LAUNCHER_DIR}/bin/${LAUNCHER_NAME}") + +if [ -f portable.txt ]; then + ARGS+=("-d" "${LAUNCHER_DIR}") +fi + +ARGS+=("$@") + +# Run the launcher +exec -a "${ARGS[@]}" diff --git a/launcher/LibraryUtils.cpp b/launcher/LibraryUtils.cpp new file mode 100644 index 0000000..4ac0381 --- /dev/null +++ b/launcher/LibraryUtils.cpp @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * PrismLauncher - Minecraft Launcher + * Copyright (C) 2022 Jan Drögehoff + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include +#include +#include +#include +#include +#include + +#include "FileSystem.h" +#include "Json.h" +#include "LibraryUtils.h" + +#ifdef __GLIBC__ +#ifndef _GNU_SOURCE +#define _GNU_SOURCE +#define UNDEF_GNU_SOURCE +#endif +#include +#include +#endif + +namespace LibraryUtils { + +QString findMangoHud() +{ + /** + * Guess MangoHud install location by searching for vulkan layers in this order: + * + * $VK_LAYER_PATH + * $XDG_DATA_DIRS (/usr/local/share/:/usr/share/) + * $XDG_DATA_HOME (~/.local/share) + * /etc + * $XDG_CONFIG_DIRS (/etc/xdg) + * $XDG_CONFIG_HOME (~/.config) + * + * @returns Absolute path of libMangoHud.so if found and empty QString otherwise. + */ + QStringList vkLayerList; + { + QString home = QDir::homePath(); + + QString vkLayerPath = qEnvironmentVariable("VK_LAYER_PATH"); + if (!vkLayerPath.isEmpty()) { + vkLayerList << vkLayerPath; + } + + QStringList xdgDataDirs = qEnvironmentVariable("XDG_DATA_DIRS", "/usr/local/share/:/usr/share/").split(QLatin1String(":")); + for (QString dir : xdgDataDirs) { + vkLayerList << FS::PathCombine(dir, "vulkan", "implicit_layer.d"); + } + + QString xdgDataHome = qEnvironmentVariable("XDG_DATA_HOME"); + if (xdgDataHome.isEmpty()) { + xdgDataHome = FS::PathCombine(home, ".local", "share"); + } + vkLayerList << FS::PathCombine(xdgDataHome, "vulkan", "implicit_layer.d"); + + vkLayerList << "/etc"; + + QStringList xdgConfigDirs = qEnvironmentVariable("XDG_CONFIG_DIRS", "/etc/xdg").split(QLatin1String(":")); + for (QString dir : xdgConfigDirs) { + vkLayerList << FS::PathCombine(dir, "vulkan", "implicit_layer.d"); + } + + QString xdgConfigHome = qEnvironmentVariable("XDG_CONFIG_HOME"); + if (xdgConfigHome.isEmpty()) { + xdgConfigHome = FS::PathCombine(home, ".config"); + } + vkLayerList << FS::PathCombine(xdgConfigHome, "vulkan", "implicit_layer.d"); + } + + for (const QString& vkLayer : vkLayerList) { + // prefer to use architecture specific vulkan layers + QString currentArch = QSysInfo::currentCpuArchitecture(); + + if (currentArch == "arm64") { + currentArch = "aarch64"; + } + + QStringList manifestNames = { QString("MangoHud.%1.json").arg(currentArch), "MangoHud.json" }; + + QString filePath{}; + for (const QString& manifestName : manifestNames) { + QString tryPath = FS::PathCombine(vkLayer, manifestName); + if (QFile::exists(tryPath)) { + filePath = tryPath; + break; + } + } + + if (filePath.isEmpty()) { + continue; + } + try { + auto conf = Json::requireDocument(filePath, vkLayer); + auto confObject = Json::requireObject(conf, vkLayer); + auto layer = confObject["layer"].toObject(); + QString libraryName = layer["library_path"].toString(); + + if (libraryName.isEmpty()) { + continue; + } + if (QFileInfo(libraryName).isAbsolute()) { + return libraryName; + } + +#ifdef __GLIBC__ + // Check whether mangohud is usable on a glibc based system + QString libraryPath = find(libraryName); + if (!libraryPath.isEmpty()) { + return libraryPath; + } +#else + // Without glibc return recorded shared library as-is. + return libraryName; +#endif + } catch (const Exception& e) { + } + } + + return {}; +} + +QString find(QString libName) +{ +#ifdef __GLIBC__ + const char* library = libName.toLocal8Bit().constData(); + + void* handle = dlopen(library, RTLD_NOW); + if (!handle) { + qCritical() << "dlopen() failed:" << dlerror(); + return {}; + } + + char path[PATH_MAX]; + if (dlinfo(handle, RTLD_DI_ORIGIN, path) == -1) { + qCritical() << "dlinfo() failed:" << dlerror(); + dlclose(handle); + return {}; + } + + auto fullPath = FS::PathCombine(QString(path), libName); + + dlclose(handle); + return fullPath; +#else + qWarning() << "LibraryUtils::find is not implemented on this platform"; + return {}; +#endif +} +} // namespace LibraryUtils + +#ifdef UNDEF_GNU_SOURCE +#undef _GNU_SOURCE +#undef UNDEF_GNU_SOURCE +#endif diff --git a/launcher/LibraryUtils.h b/launcher/LibraryUtils.h new file mode 100644 index 0000000..6832a96 --- /dev/null +++ b/launcher/LibraryUtils.h @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * PrismLauncher - Minecraft Launcher + * Copyright (C) 2022 Jan Drögehoff + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include + +namespace LibraryUtils { + +QString findMangoHud(); + +QString find(QString libName); +} // namespace LibraryUtils diff --git a/launcher/LoggedProcess.cpp b/launcher/LoggedProcess.cpp new file mode 100644 index 0000000..bae45ad --- /dev/null +++ b/launcher/LoggedProcess.cpp @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022,2023 Sefa Eyeoglu + * Copyright (c) 2023 flowln + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "LoggedProcess.h" +#include +#include +#include "MessageLevel.h" + +LoggedProcess::LoggedProcess(const QStringConverter::Encoding output_codec, QObject* parent) + : QProcess(parent), m_err_decoder(output_codec), m_out_decoder(output_codec) +{ + // QProcess has a strange interface... let's map a lot of those into a few. + connect(this, &QProcess::readyReadStandardOutput, this, &LoggedProcess::on_stdOut); + connect(this, &QProcess::readyReadStandardError, this, &LoggedProcess::on_stdErr); + connect(this, &QProcess::finished, this, &LoggedProcess::on_exit); + connect(this, &QProcess::errorOccurred, this, &LoggedProcess::on_error); + connect(this, &QProcess::stateChanged, this, &LoggedProcess::on_stateChange); +} + +LoggedProcess::~LoggedProcess() +{ + if (m_is_detachable) { + setProcessState(QProcess::NotRunning); + } +} + +QStringList LoggedProcess::reprocess(const QByteArray& data, QStringDecoder& decoder) +{ + QString str = decoder(data); + + if (!m_leftover_line.isEmpty()) { + str.prepend(m_leftover_line); + m_leftover_line = ""; + } + + auto lines = str.remove(QChar::CarriageReturn).split(QChar::LineFeed); + + m_leftover_line = lines.takeLast(); + return lines; +} + +void LoggedProcess::on_stdErr() +{ + auto lines = reprocess(readAllStandardError(), m_err_decoder); + emit log(lines, MessageLevel::StdErr); +} + +void LoggedProcess::on_stdOut() +{ + auto lines = reprocess(readAllStandardOutput(), m_out_decoder); + emit log(lines, MessageLevel::StdOut); +} + +void LoggedProcess::on_exit(int exit_code, QProcess::ExitStatus status) +{ + // save the exit code + m_exit_code = exit_code; + + // based on state, send signals + if (!m_is_aborting) { + if (status == QProcess::NormalExit) { + //: Message displayed on instance exit + emit log({ tr("Process exited with code %1.").arg(exit_code) }, MessageLevel::Launcher); + changeState(LoggedProcess::Finished); + } else { + //: Message displayed on instance crashed + if (exit_code == -1) + emit log({ tr("Process crashed.") }, MessageLevel::Launcher); + else + emit log({ tr("Process crashed with exitcode %1.").arg(exit_code) }, MessageLevel::Launcher); + changeState(LoggedProcess::Crashed); + } + } else { + //: Message displayed after the instance exits due to kill request + emit log({ tr("Process was killed by user.") }, MessageLevel::Error); + changeState(LoggedProcess::Aborted); + } +} + +void LoggedProcess::on_error(QProcess::ProcessError error) +{ + switch (error) { + case QProcess::FailedToStart: { + emit log({ tr("The process failed to start.") }, MessageLevel::Fatal); + changeState(LoggedProcess::FailedToStart); + break; + } + // we'll just ignore those... never needed them + case QProcess::Crashed: + case QProcess::ReadError: + case QProcess::Timedout: + case QProcess::UnknownError: + case QProcess::WriteError: + break; + } +} + +void LoggedProcess::kill() +{ + m_is_aborting = true; + QProcess::kill(); +} + +int LoggedProcess::exitCode() const +{ + return m_exit_code; +} + +void LoggedProcess::changeState(LoggedProcess::State state) +{ + if (state == m_state) + return; + m_state = state; + emit stateChanged(m_state); +} + +LoggedProcess::State LoggedProcess::state() const +{ + return m_state; +} + +void LoggedProcess::on_stateChange(QProcess::ProcessState state) +{ + switch (state) { + case QProcess::NotRunning: + break; // let's not - there are too many that handle this already. + case QProcess::Starting: { + if (m_state != LoggedProcess::NotRunning) { + qWarning() << "Wrong state change for process from state" << m_state << "to" << (int)LoggedProcess::Starting; + } + changeState(LoggedProcess::Starting); + return; + } + case QProcess::Running: { + if (m_state != LoggedProcess::Starting) { + qWarning() << "Wrong state change for process from state" << m_state << "to" << (int)LoggedProcess::Running; + } + changeState(LoggedProcess::Running); + return; + } + } +} + +void LoggedProcess::setDetachable(bool detachable) +{ + m_is_detachable = detachable; +} diff --git a/launcher/LoggedProcess.h b/launcher/LoggedProcess.h new file mode 100644 index 0000000..ce35b27 --- /dev/null +++ b/launcher/LoggedProcess.h @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022,2023 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include "MessageLevel.h" + +/* + * This is a basic process. + * It has line-based logging support and hides some of the nasty bits. + */ +class LoggedProcess : public QProcess { + Q_OBJECT + public: + enum State { NotRunning, Starting, FailedToStart, Running, Finished, Crashed, Aborted }; + + public: + explicit LoggedProcess(QStringConverter::Encoding outputEncoding = QStringConverter::System, QObject* parent = nullptr); + virtual ~LoggedProcess(); + + State state() const; + int exitCode() const; + + void setDetachable(bool detachable); + + signals: + void log(QStringList lines, MessageLevel level); + void stateChanged(LoggedProcess::State state); + + public slots: + /** + * @brief kill the process - equivalent to kill -9 + */ + void kill(); + + private slots: + void on_stdErr(); + void on_stdOut(); + void on_exit(int exit_code, QProcess::ExitStatus status); + void on_error(QProcess::ProcessError error); + void on_stateChange(QProcess::ProcessState); + + private: + void changeState(LoggedProcess::State state); + + QStringList reprocess(const QByteArray& data, QStringDecoder& decoder); + + private: + QStringDecoder m_err_decoder; + QStringDecoder m_out_decoder; + QString m_leftover_line; + bool m_killed = false; + State m_state = NotRunning; + int m_exit_code = 0; + bool m_is_aborting = false; + bool m_is_detachable = false; +}; diff --git a/launcher/MMCTime.cpp b/launcher/MMCTime.cpp new file mode 100644 index 0000000..67c63f5 --- /dev/null +++ b/launcher/MMCTime.cpp @@ -0,0 +1,122 @@ +/* + * Copyright 2015 Petr Mrazek + * Copyright 2021 Jamie Mansfield + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include + +#include +#include +#include + +QString Time::prettifyDuration(int64_t duration, bool noDays) +{ + int seconds = (int)(duration % 60); + duration /= 60; + int minutes = (int)(duration % 60); + duration /= 60; + int hours = (int)(noDays ? duration : (duration % 24)); + int days = (int)(noDays ? 0 : (duration / 24)); + if ((hours == 0) && (days == 0)) { + return QObject::tr("%1m %2s").arg(minutes).arg(seconds); + } + if (days == 0) { + return QObject::tr("%1h %2m").arg(hours).arg(minutes); + } + return QObject::tr("%1d %2h %3m").arg(days).arg(hours).arg(minutes); +} + +QString Time::relativePast(const QDateTime& past) +{ + qint64 secs = past.secsTo(QDateTime::currentDateTime()); + if (secs < 0) + secs = 0; + + if (secs < 60) + return QObject::tr("just now"); + if (secs < 3600) + return QObject::tr("%1m ago").arg(secs / 60); + if (secs < 86400) + return QObject::tr("%1h ago").arg(secs / 3600); + if (secs < 86400 * 14) + return QObject::tr("%1d ago").arg(secs / 86400); + if (secs < 86400 * 60) + return QObject::tr("%1w ago").arg(secs / (86400 * 7)); + if (secs < 86400 * 365) + return QObject::tr("%1mo ago").arg(secs / (86400 * 30)); + return QObject::tr("%1y ago").arg(secs / (86400 * 365)); +} + +QString Time::humanReadableDuration(double duration, int precision) +{ + using days = std::chrono::duration>; + + QString outStr; + QTextStream os(&outStr); + + bool neg = false; + if (duration < 0) { + neg = true; // flag + duration *= -1; // invert + } + + auto std_duration = std::chrono::duration(duration); + auto d = std::chrono::duration_cast(std_duration); + std_duration -= d; + auto h = std::chrono::duration_cast(std_duration); + std_duration -= h; + auto m = std::chrono::duration_cast(std_duration); + std_duration -= m; + auto s = std::chrono::duration_cast(std_duration); + std_duration -= s; + auto ms = std::chrono::duration_cast(std_duration); + + auto dc = d.count(); + auto hc = h.count(); + auto mc = m.count(); + auto sc = s.count(); + auto msc = ms.count(); + + if (neg) { + os << '-'; + } + if (dc) { + os << dc << QObject::tr("days"); + } + if (hc) { + if (dc) + os << " "; + os << qSetFieldWidth(2) << hc << QObject::tr("h"); // hours + } + if (mc) { + if (dc || hc) + os << " "; + os << qSetFieldWidth(2) << mc << QObject::tr("m"); // minutes + } + if (dc || hc || mc || sc) { + if (dc || hc || mc) + os << " "; + os << qSetFieldWidth(2) << sc << QObject::tr("s"); // seconds + } + if ((msc && (precision > 0)) || !(dc || hc || mc || sc)) { + if (dc || hc || mc || sc) + os << " "; + os << qSetFieldWidth(0) << qSetRealNumberPrecision(precision) << msc << QObject::tr("ms"); // miliseconds + } + + os.flush(); + + return outStr; +} diff --git a/launcher/MMCTime.h b/launcher/MMCTime.h new file mode 100644 index 0000000..20d8e58 --- /dev/null +++ b/launcher/MMCTime.h @@ -0,0 +1,37 @@ +/* + * Copyright 2021 Jamie Mansfield + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +namespace Time { + +QString prettifyDuration(int64_t duration, bool noDays = false); + +QString relativePast(const QDateTime& past); + +/** + * @brief Returns a string with short form time duration ie. `2days 1h3m4s56.0ms`. + * miliseconds are only included if `precision` is greater than 0. + * + * @param duration a number of seconds as floating point + * @param precision number of decmial points to display on fractons of a second, defualts to 0. + * @return QString + */ +QString humanReadableDuration(double duration, int precision = 0); +} // namespace Time diff --git a/launcher/MMCZip.cpp b/launcher/MMCZip.cpp new file mode 100644 index 0000000..64be896 --- /dev/null +++ b/launcher/MMCZip.cpp @@ -0,0 +1,332 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (c) 2023-2024 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "MMCZip.h" +#include +#include "FileSystem.h" +#include "archive/ArchiveReader.h" +#include "archive/ArchiveWriter.h" + +#include +#include +#include +#include +#include + +namespace MMCZip { +// ours +using FilterFunction = std::function; +#if defined(LAUNCHER_APPLICATION) +bool mergeZipFiles(ArchiveWriter& into, QFileInfo from, QSet& contained, const FilterFunction& filter = nullptr) +{ + ArchiveReader r(from.absoluteFilePath()); + return r.parse([&into, &contained, &filter, from](ArchiveReader::File* f) { + auto filename = f->filename(); + if (filter && !filter(filename)) { + qDebug() << "Skipping file" << filename << "from" << from.fileName() << "- filtered"; + f->skip(); + return true; + } + if (contained.contains(filename)) { + qDebug() << "Skipping already contained file" << filename << "from" << from.fileName(); + f->skip(); + return true; + } + contained.insert(filename); + if (!into.addFile(f)) { + qCritical() << "Failed to copy data of" << filename << "into the jar"; + return false; + } + return true; + }); +} + +bool compressDirFiles(ArchiveWriter& zip, QString dir, QFileInfoList files) +{ + QDir directory(dir); + if (!directory.exists()) + return false; + + for (auto e : files) { + auto filePath = directory.relativeFilePath(e.absoluteFilePath()); + auto srcPath = e.absoluteFilePath(); + if (!zip.addFile(srcPath, filePath)) + return false; + } + + return true; +} + +// ours +bool createModdedJar(QString sourceJarPath, QString targetJarPath, const QList& mods) +{ + ArchiveWriter zipOut(targetJarPath); + if (!zipOut.open()) { + FS::deletePath(targetJarPath); + qCritical() << "Failed to open the minecraft.jar for modding"; + return false; + } + // Files already added to the jar. + // These files will be skipped. + QSet addedFiles; + + // Modify the jar + // This needs to be done in reverse-order to ensure we respect the loading order of components + for (auto i = mods.crbegin(); i != mods.crend(); i++) { + const auto* mod = *i; + // do not merge disabled mods. + if (!mod->enabled()) + continue; + if (mod->type() == ResourceType::ZIPFILE) { + if (!mergeZipFiles(zipOut, mod->fileinfo(), addedFiles)) { + zipOut.close(); + FS::deletePath(targetJarPath); + qCritical() << "Failed to add" << mod->fileinfo().fileName() << "to the jar."; + return false; + } + } else if (mod->type() == ResourceType::SINGLEFILE) { + // FIXME: buggy - does not work with addedFiles + auto filename = mod->fileinfo(); + if (!zipOut.addFile(filename.absoluteFilePath(), filename.fileName())) { + zipOut.close(); + FS::deletePath(targetJarPath); + qCritical() << "Failed to add" << mod->fileinfo().fileName() << "to the jar."; + return false; + } + addedFiles.insert(filename.fileName()); + } else if (mod->type() == ResourceType::FOLDER) { + // untested, but seems to be unused / not possible to reach + // FIXME: buggy - does not work with addedFiles + auto filename = mod->fileinfo(); + QString what_to_zip = filename.absoluteFilePath(); + QDir dir(what_to_zip); + dir.cdUp(); + QString parent_dir = dir.absolutePath(); + auto files = QFileInfoList(); + collectFileListRecursively(what_to_zip, nullptr, &files, nullptr); + + for (auto e : files) { + if (addedFiles.contains(e.filePath())) + files.removeAll(e); + } + + if (!compressDirFiles(zipOut, parent_dir, files)) { + zipOut.close(); + FS::deletePath(targetJarPath); + qCritical() << "Failed to add" << mod->fileinfo().fileName() << "to the jar."; + return false; + } + qDebug() << "Adding folder" << filename.fileName() << "from" << filename.absoluteFilePath(); + } else { + // Make sure we do not continue launching when something is missing or undefined... + zipOut.close(); + FS::deletePath(targetJarPath); + qCritical() << "Failed to add unknown mod type" << mod->fileinfo().fileName() << "to the jar."; + return false; + } + } + + if (!mergeZipFiles(zipOut, QFileInfo(sourceJarPath), addedFiles, [](const QString key) { return !key.contains("META-INF"); })) { + zipOut.close(); + FS::deletePath(targetJarPath); + qCritical() << "Failed to insert minecraft.jar contents."; + return false; + } + + // Recompress the jar + if (!zipOut.close()) { + FS::deletePath(targetJarPath); + qCritical() << "Failed to finalize minecraft.jar!"; + return false; + } + return true; +} +#endif + +// ours +std::optional extractSubDir(ArchiveReader* zip, const QString& subdir, const QString& target) +{ + auto target_top_dir = QUrl::fromLocalFile(target); + + QStringList extracted; + + qDebug() << "Extracting subdir" << subdir << "from" << zip->getZipName() << "to" << target; + if (!zip->collectFiles()) { + qWarning() << "Failed to enumerate files in archive"; + return std::nullopt; + } + if (zip->getFiles().isEmpty()) { + qDebug() << "Extracting empty archives seems odd..."; + return extracted; + } + + auto extPtr = ArchiveWriter::createDiskWriter(); + auto ext = extPtr.get(); + + if (!zip->parse([&subdir, &target, &target_top_dir, ext, &extracted](ArchiveReader::File* f) { + QString file_name = f->filename(); + file_name = FS::RemoveInvalidPathChars(file_name); + if (!file_name.startsWith(subdir)) { + f->skip(); + return true; + } + + auto relative_file_name = QDir::fromNativeSeparators(file_name.mid(subdir.size())); + auto original_name = relative_file_name; + + // Fix subdirs/files ending with a / getting transformed into absolute paths + if (relative_file_name.startsWith('/')) + relative_file_name = relative_file_name.mid(1); + + // Fix weird "folders with a single file get squashed" thing + QString sub_path; + if (relative_file_name.contains('/') && !relative_file_name.endsWith('/')) { + sub_path = relative_file_name.section('/', 0, -2) + '/'; + FS::ensureFolderPathExists(FS::PathCombine(target, sub_path)); + + relative_file_name = relative_file_name.split('/').last(); + } + QString target_file_path; + if (relative_file_name.isEmpty()) { + target_file_path = target + '/'; + } else { + target_file_path = FS::PathCombine(target_top_dir.toLocalFile(), sub_path, relative_file_name); + if (relative_file_name.endsWith('/') && !target_file_path.endsWith('/')) + target_file_path += '/'; + } + + if (!target_top_dir.isParentOf(QUrl::fromLocalFile(target_file_path))) { + qWarning() << "Extracting" << relative_file_name << "was cancelled, because it was effectively outside of the target path" + << target; + return false; + } + if (!f->writeFile(ext, target_file_path, target)) { + qWarning() << "Failed to extract file" << original_name << "to" << target_file_path; + return false; + } + + extracted.append(target_file_path); + + qDebug() << "Extracted file" << relative_file_name << "to" << target_file_path; + return true; + })) { + qWarning() << "Failed to parse file" << zip->getZipName(); + FS::removeFiles(extracted); + return std::nullopt; + } + + return extracted; +} + +// ours +std::optional extractDir(QString fileCompressed, QString dir) +{ + // check if this is a minimum size empty zip file... + QFileInfo fileInfo(fileCompressed); + if (fileInfo.size() == 22) { + return QStringList(); + } + ArchiveReader zip(fileCompressed); + return extractSubDir(&zip, "", dir); +} + +// ours +std::optional extractDir(QString fileCompressed, QString subdir, QString dir) +{ + // check if this is a minimum size empty zip file... + QFileInfo fileInfo(fileCompressed); + if (fileInfo.size() == 22) { + return QStringList(); + } + ArchiveReader zip(fileCompressed); + return extractSubDir(&zip, subdir, dir); +} + +// ours +bool extractFile(QString fileCompressed, QString file, QString target) +{ + // check if this is a minimum size empty zip file... + QFileInfo fileInfo(fileCompressed); + if (fileInfo.size() == 22) { + return true; + } + ArchiveReader zip(fileCompressed); + auto f = zip.goToFile(file); + if (!f) { + return false; + } + auto extPtr = ArchiveWriter::createDiskWriter(); + auto ext = extPtr.get(); + + return f->writeFile(ext, target); +} + +bool collectFileListRecursively(const QString& rootDir, const QString& subDir, QFileInfoList* files, FilterFileFunction excludeFilter) +{ + QDir rootDirectory(rootDir); + if (!rootDirectory.exists()) + return false; + + QDir directory; + if (subDir == nullptr) + directory = rootDirectory; + else + directory = QDir(subDir); + + if (!directory.exists()) + return false; // shouldn't ever happen + + // recurse directories + QFileInfoList entries = directory.entryInfoList(QDir::AllDirs | QDir::NoDotAndDotDot | QDir::Hidden); + for (const auto& e : entries) { + if (!collectFileListRecursively(rootDir, e.filePath(), files, excludeFilter)) + return false; + } + + // collect files + entries = directory.entryInfoList(QDir::Files); + for (const auto& e : entries) { + if (excludeFilter && excludeFilter(e)) { + QString relativeFilePath = rootDirectory.relativeFilePath(e.absoluteFilePath()); + qDebug() << "Skipping file" << relativeFilePath; + continue; + } + + files->append(e); // we want the original paths for compressDirFiles + } + return true; +} +} // namespace MMCZip diff --git a/launcher/MMCZip.h b/launcher/MMCZip.h new file mode 100644 index 0000000..04fe903 --- /dev/null +++ b/launcher/MMCZip.h @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (c) 2023-2024 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "archive/ArchiveReader.h" + +#if defined(LAUNCHER_APPLICATION) +#include "minecraft/mod/Mod.h" +#endif + +namespace MMCZip { +using FilterFileFunction = std::function; + +#if defined(LAUNCHER_APPLICATION) +/** + * take a source jar, add mods to it, resulting in target jar + */ +bool createModdedJar(QString sourceJarPath, QString targetJarPath, const QList& mods); +#endif + +/** + * Extract a subdirectory from an archive + */ +std::optional extractSubDir(ArchiveReader* zip, const QString& subdir, const QString& target); + +/** + * Extract a whole archive. + * + * \param fileCompressed The name of the archive. + * \param dir The directory to extract to, the current directory if left empty. + * \return The list of the full paths of the files extracted, empty on failure. + */ +std::optional extractDir(QString fileCompressed, QString dir); + +/** + * Extract a subdirectory from an archive + * + * \param fileCompressed The name of the archive. + * \param subdir The directory within the archive to extract + * \param dir The directory to extract to, the current directory if left empty. + * \return The list of the full paths of the files extracted, empty on failure. + */ +std::optional extractDir(QString fileCompressed, QString subdir, QString dir); + +/** + * Extract a single file from an archive into a directory + * + * \param fileCompressed The name of the archive. + * \param file The file within the archive to extract + * \param dir The directory to extract to, the current directory if left empty. + * \return true for success or false for failure + */ +bool extractFile(QString fileCompressed, QString file, QString dir); + +/** + * Populate a QFileInfoList with a directory tree recursively, while allowing to excludeFilter what shouldn't be included. + * \param rootDir directory to start off + * \param subDir subdirectory, should be nullptr for first invocation + * \param files resulting list of QFileInfo + * \param excludeFilter function to excludeFilter which files shouldn't be included (returning true means to excude) + * \return true for success or false for failure + */ +bool collectFileListRecursively(const QString& rootDir, const QString& subDir, QFileInfoList* files, FilterFileFunction excludeFilter); +} // namespace MMCZip diff --git a/launcher/MTPixmapCache.h b/launcher/MTPixmapCache.h new file mode 100644 index 0000000..0ba9c5a --- /dev/null +++ b/launcher/MTPixmapCache.h @@ -0,0 +1,147 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#define GET_TYPE() \ + Qt::ConnectionType type; \ + if (QThread::currentThread() != QCoreApplication::instance()->thread()) \ + type = Qt::BlockingQueuedConnection; \ + else \ + type = Qt::DirectConnection; + +#define DEFINE_FUNC_NO_PARAM(NAME, RET_TYPE) \ + static RET_TYPE NAME() \ + { \ + RET_TYPE ret; \ + GET_TYPE() \ + QMetaObject::invokeMethod(s_instance, "_" #NAME, type, Q_RETURN_ARG(RET_TYPE, ret)); \ + return ret; \ + } +#define DEFINE_FUNC_ONE_PARAM(NAME, RET_TYPE, PARAM_1_TYPE) \ + static RET_TYPE NAME(PARAM_1_TYPE p1) \ + { \ + RET_TYPE ret; \ + GET_TYPE() \ + QMetaObject::invokeMethod(s_instance, "_" #NAME, type, Q_RETURN_ARG(RET_TYPE, ret), Q_ARG(PARAM_1_TYPE, p1)); \ + return ret; \ + } +#define DEFINE_FUNC_TWO_PARAM(NAME, RET_TYPE, PARAM_1_TYPE, PARAM_2_TYPE) \ + static RET_TYPE NAME(PARAM_1_TYPE p1, PARAM_2_TYPE p2) \ + { \ + RET_TYPE ret; \ + GET_TYPE() \ + QMetaObject::invokeMethod(s_instance, "_" #NAME, type, Q_RETURN_ARG(RET_TYPE, ret), Q_ARG(PARAM_1_TYPE, p1), \ + Q_ARG(PARAM_2_TYPE, p2)); \ + return ret; \ + } + +/** A wrapper around QPixmapCache with thread affinity with the main thread. + */ +class PixmapCache final : public QObject { + Q_OBJECT + + public: + PixmapCache(QObject* parent) : QObject(parent) {} + ~PixmapCache() override = default; + + static PixmapCache& instance() { return *s_instance; } + static void setInstance(PixmapCache* i) { s_instance = i; } + + public: + DEFINE_FUNC_NO_PARAM(cacheLimit, int) + DEFINE_FUNC_NO_PARAM(clear, bool) + DEFINE_FUNC_TWO_PARAM(find, bool, const QString&, QPixmap*) + DEFINE_FUNC_TWO_PARAM(find, bool, const QPixmapCache::Key&, QPixmap*) + DEFINE_FUNC_TWO_PARAM(insert, bool, const QString&, const QPixmap&) + DEFINE_FUNC_ONE_PARAM(insert, QPixmapCache::Key, const QPixmap&) + DEFINE_FUNC_ONE_PARAM(remove, bool, const QString&) + DEFINE_FUNC_ONE_PARAM(remove, bool, const QPixmapCache::Key&) + DEFINE_FUNC_TWO_PARAM(replace, bool, const QPixmapCache::Key&, const QPixmap&) + DEFINE_FUNC_ONE_PARAM(setCacheLimit, bool, int) + DEFINE_FUNC_NO_PARAM(markCacheMissByEviciton, bool) + DEFINE_FUNC_ONE_PARAM(setFastEvictionThreshold, bool, int) + + // NOTE: Every function returns something non-void to simplify the macros. + private slots: + int _cacheLimit() { return QPixmapCache::cacheLimit(); } + bool _clear() + { + QPixmapCache::clear(); + return true; + } + bool _find(const QString& key, QPixmap* pixmap) { return QPixmapCache::find(key, pixmap); } + bool _find(const QPixmapCache::Key& key, QPixmap* pixmap) { return QPixmapCache::find(key, pixmap); } + bool _insert(const QString& key, const QPixmap& pixmap) { return QPixmapCache::insert(key, pixmap); } + QPixmapCache::Key _insert(const QPixmap& pixmap) { return QPixmapCache::insert(pixmap); } + bool _remove(const QString& key) + { + QPixmapCache::remove(key); + return true; + } + bool _remove(const QPixmapCache::Key& key) + { + QPixmapCache::remove(key); + return true; + } + bool _replace(const QPixmapCache::Key& key, const QPixmap& pixmap) { return QPixmapCache::replace(key, pixmap); } + bool _setCacheLimit(int n) + { + QPixmapCache::setCacheLimit(n); + return true; + } + + /** + * Mark that a cache miss occurred because of a eviction if too many of these occur too fast the cache size is increased + * @return if the cache size was increased + */ + bool _markCacheMissByEviciton() + { + static constexpr uint maxCache = static_cast(std::numeric_limits::max()) / 4; + static constexpr uint step = 10240; + static constexpr int oneSecond = 1000; + + auto now = QTime::currentTime(); + if (!m_last_cache_miss_by_eviciton.isNull()) { + auto diff = m_last_cache_miss_by_eviciton.msecsTo(now); + if (diff < oneSecond) { // less than a second ago + ++m_consecutive_fast_evicitons; + } else { + m_consecutive_fast_evicitons = 0; + } + } + m_last_cache_miss_by_eviciton = now; + if (m_consecutive_fast_evicitons >= m_consecutive_fast_evicitons_threshold) { + // increase the cache size + uint newSize = _cacheLimit() + step; + if (newSize >= maxCache) { // increase it until you overflow :D + newSize = maxCache; + qDebug() << m_consecutive_fast_evicitons + << tr("pixmap cache misses by eviction happened too fast, doing nothing as the cache size reached it's limit"); + } else { + qDebug() << m_consecutive_fast_evicitons + << tr("pixmap cache misses by eviction happened too fast, increasing cache size to") << static_cast(newSize); + } + _setCacheLimit(static_cast(newSize)); + m_consecutive_fast_evicitons = 0; + return true; + } + return false; + } + + bool _setFastEvictionThreshold(int threshold) + { + m_consecutive_fast_evicitons_threshold = threshold; + return true; + } + + private: + static PixmapCache* s_instance; + QTime m_last_cache_miss_by_eviciton; + int m_consecutive_fast_evicitons = 0; + int m_consecutive_fast_evicitons_threshold = 15; +}; diff --git a/launcher/Markdown.cpp b/launcher/Markdown.cpp new file mode 100644 index 0000000..6f6d348 --- /dev/null +++ b/launcher/Markdown.cpp @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 Joshua Goins + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "Markdown.h" + +QString markdownToHTML(const QString& markdown) +{ + const QByteArray markdownData = markdown.toUtf8(); + char* buffer = cmark_markdown_to_html(markdownData.constData(), markdownData.length(), CMARK_OPT_NOBREAKS | CMARK_OPT_UNSAFE); + + QString htmlStr(buffer); + + free(buffer); + + return htmlStr; +} diff --git a/launcher/Markdown.h b/launcher/Markdown.h new file mode 100644 index 0000000..57a2e54 --- /dev/null +++ b/launcher/Markdown.h @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 Joshua Goins + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include + +QString markdownToHTML(const QString& markdown); diff --git a/launcher/MessageLevel.cpp b/launcher/MessageLevel.cpp new file mode 100644 index 0000000..e5f4eb1 --- /dev/null +++ b/launcher/MessageLevel.cpp @@ -0,0 +1,73 @@ +#include "MessageLevel.h" + +MessageLevel MessageLevel::fromName(const QString& levelName) +{ + QString name = levelName.toUpper(); + if (name == "LAUNCHER") + return MessageLevel::Launcher; + else if (name == "TRACE") + return MessageLevel::Trace; + else if (name == "DEBUG") + return MessageLevel::Debug; + else if (name == "INFO") + return MessageLevel::Info; + else if (name == "MESSAGE") + return MessageLevel::Message; + else if (name == "WARNING" || name == "WARN") + return MessageLevel::Warning; + else if (name == "ERROR" || name == "CRITICAL") + return MessageLevel::Error; + else if (name == "FATAL") + return MessageLevel::Fatal; + // Skip PrePost, it's not exposed to !![]! + // Also skip StdErr and StdOut + else + return MessageLevel::Unknown; +} + +MessageLevel MessageLevel::fromQtMsgType(const QtMsgType& type) +{ + switch (type) { + case QtDebugMsg: + return MessageLevel::Debug; + case QtInfoMsg: + return MessageLevel::Info; + case QtWarningMsg: + return MessageLevel::Warning; + case QtCriticalMsg: + return MessageLevel::Error; + case QtFatalMsg: + return MessageLevel::Fatal; + default: + return MessageLevel::Unknown; + } +} + +/* Get message level from a line. Line is modified if it was successful. */ +MessageLevel MessageLevel::takeFromLine(QString& line) +{ + // Level prefix + int endmark = line.indexOf("]!"); + if (line.startsWith("!![") && endmark != -1) { + auto level = MessageLevel::fromName(line.left(endmark).mid(3)); + line = line.mid(endmark + 2); + return level; + } + return MessageLevel::Unknown; +} + +/* Get message level from a line from the launcher log. Line is modified if it was successful. */ +MessageLevel MessageLevel::takeFromLauncherLine(QString& line) +{ + // Level prefix + int startMark = 0; + while (startMark < line.size() && (line[startMark].isDigit() || line[startMark].isSpace() || line[startMark] == '.')) + ++startMark; + int endmark = line.indexOf(":"); + if (startMark < line.size() && endmark != -1) { + auto level = MessageLevel::fromName(line.left(endmark).mid(startMark)); + line = line.mid(endmark + 2); + return level; + } + return MessageLevel::Unknown; +} diff --git a/launcher/MessageLevel.h b/launcher/MessageLevel.h new file mode 100644 index 0000000..cff0986 --- /dev/null +++ b/launcher/MessageLevel.h @@ -0,0 +1,43 @@ +#pragma once + +#include +#include + +/** + * @brief the MessageLevel Enum + * defines what level a log message is + */ +struct MessageLevel { + enum class Enum { + Unknown, /**< No idea what this is or where it came from */ + StdOut, /**< Undetermined stderr messages */ + StdErr, /**< Undetermined stdout messages */ + Launcher, /**< Launcher Messages */ + Trace, /**< Trace Messages */ + Debug, /**< Debug Messages */ + Info, /**< Info Messages */ + Message, /**< Standard Messages */ + Warning, /**< Warnings */ + Error, /**< Errors */ + Fatal, /**< Fatal Errors */ + }; + using enum Enum; + constexpr MessageLevel(Enum e = Unknown) : m_type(e) {} + static MessageLevel fromName(const QString& type); + static MessageLevel fromQtMsgType(const QtMsgType& type); + static MessageLevel fromLine(const QString& line); + inline bool isValid() const { return m_type != Unknown; } + std::strong_ordering operator<=>(const MessageLevel& other) const = default; + std::strong_ordering operator<=>(const MessageLevel::Enum& other) const { return m_type <=> other; } + explicit operator int() const { return static_cast(m_type); } + explicit operator MessageLevel::Enum() { return m_type; } + + /* Get message level from a line. Line is modified if it was successful. */ + static MessageLevel takeFromLine(QString& line); + + /* Get message level from a line from the launcher log. Line is modified if it was successful. */ + static MessageLevel takeFromLauncherLine(QString& line); + + private: + Enum m_type; +}; diff --git a/launcher/NullInstance.h b/launcher/NullInstance.h new file mode 100644 index 0000000..1c2425e --- /dev/null +++ b/launcher/NullInstance.h @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once +#include "BaseInstance.h" +#include "launch/LaunchTask.h" + +class NullInstance : public BaseInstance { + Q_OBJECT + public: + NullInstance(SettingsObject* globalSettings, std::unique_ptr settings, const QString& rootDir) + : BaseInstance(globalSettings, std::move(settings), rootDir) + { + setVersionBroken(true); + } + virtual ~NullInstance() = default; + void saveNow() override {} + void loadSpecificSettings() override { setSpecificSettingsLoaded(true); } + QString getStatusbarDescription() override { return tr("Unknown instance type"); }; + QSet traits() const override { return {}; }; + QString instanceConfigFolder() const override { return instanceRoot(); }; + LaunchTask* createLaunchTask(AuthSessionPtr, MinecraftTarget::Ptr) override { return nullptr; } + QList createUpdateTask() override { return {}; } + QProcessEnvironment createEnvironment() override { return QProcessEnvironment(); } + QProcessEnvironment createLaunchEnvironment() override { return QProcessEnvironment(); } + QMap getVariables() override { return QMap(); } + QStringList getLogFileSearchPaths() override { return {}; } + QString typeName() const override { return "Null"; } + bool canExport() const override { return false; } + bool canEdit() const override { return false; } + bool canLaunch() const override { return false; } + void populateLaunchMenu(QMenu* menu) override {} + QStringList verboseDescription(AuthSessionPtr session, MinecraftTarget::Ptr targetToJoin) override + { + QStringList out; + out << "Null instance - placeholder."; + return out; + } + QString modsRoot() const override { return QString(); } + void updateRuntimeContext() override + { + // NOOP + } +}; diff --git a/launcher/PSaveFile.h b/launcher/PSaveFile.h new file mode 100644 index 0000000..533195e --- /dev/null +++ b/launcher/PSaveFile.h @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023-2024 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +#pragma once + +#include +#include +#include "Application.h" + +#if defined(LAUNCHER_APPLICATION) + +/* PSaveFile + * A class that mimics QSaveFile for Windows. + * + * When reading resources, we need to avoid accessing temporary files + * generated by QSaveFile. If we start reading such a file, we may + * inadvertently keep it open while QSaveFile is trying to remove it, + * or we might detect the file just before it is removed, leading to + * race conditions and errors. + * + * Unfortunately, QSaveFile doesn't provide a way to retrieve the + * temporary file name or to set a specific template for the temporary + * file name it uses. By default, QSaveFile appends a `.XXXXXX` suffix + * to the original file name, where the `XXXXXX` part is dynamically + * generated to ensure uniqueness. + * + * This class acts like a lock by adding and removing the target file + * name into/from a global string set, helping to manage access to + * files during critical operations. + * + * Note: Please do not use the `setFileName` function directly, as it + * is not virtual and cannot be overridden. + */ +class PSaveFile : public QSaveFile { + public: + PSaveFile(const QString& name) : QSaveFile(name) { addPath(name); } + PSaveFile(const QString& name, QObject* parent) : QSaveFile(name, parent) { addPath(name); } + virtual ~PSaveFile() + { + if (auto app = APPLICATION_DYN) { + app->removeQSavePath(m_absoluteFilePath); + } + } + + private: + void addPath(const QString& path) + { + m_absoluteFilePath = QFileInfo(path).absoluteFilePath() + "."; // add dot for tmp files only + if (auto app = APPLICATION_DYN) { + app->addQSavePath(m_absoluteFilePath); + } + } + QString m_absoluteFilePath; +}; +#else +using PSaveFile = QSaveFile; +#endif diff --git a/launcher/ProblemProvider.h b/launcher/ProblemProvider.h new file mode 100644 index 0000000..9d1b000 --- /dev/null +++ b/launcher/ProblemProvider.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include + +enum class ProblemSeverity { None, Warning, Error }; + +struct PatchProblem { + ProblemSeverity m_severity; + QString m_description; +}; + +class ProblemProvider { + public: + virtual ~ProblemProvider() {} + virtual const QList getProblems() const = 0; + virtual ProblemSeverity getProblemSeverity() const = 0; +}; + +class ProblemContainer : public ProblemProvider { + public: + const QList getProblems() const override { return m_problems; } + ProblemSeverity getProblemSeverity() const override { return m_problemSeverity; } + virtual void addProblem(ProblemSeverity severity, const QString& description) + { + if (severity > m_problemSeverity) { + m_problemSeverity = severity; + } + m_problems.append({ severity, description }); + } + + private: + QList m_problems; + ProblemSeverity m_problemSeverity = ProblemSeverity::None; +}; diff --git a/launcher/QObjectPtr.h b/launcher/QObjectPtr.h new file mode 100644 index 0000000..88c17c0 --- /dev/null +++ b/launcher/QObjectPtr.h @@ -0,0 +1,53 @@ +#pragma once + +#include +#include + +#include +#include + +/** + * A unique pointer class with unique pointer semantics intended for derivates of QObject + * Calls deleteLater() instead of destroying the contained object immediately + */ +template +using unique_qobject_ptr = QScopedPointer; + +/** + * A shared pointer class with shared pointer semantics intended for derivates of QObject + * Calls deleteLater() instead of destroying the contained object immediately + */ +template +class shared_qobject_ptr : public QSharedPointer { + public: + constexpr explicit shared_qobject_ptr() : QSharedPointer() {} + constexpr explicit shared_qobject_ptr(T* ptr) : QSharedPointer(ptr, &QObject::deleteLater) {} + constexpr shared_qobject_ptr(std::nullptr_t null_ptr) : QSharedPointer(null_ptr, &QObject::deleteLater) {} + + template + constexpr shared_qobject_ptr(const shared_qobject_ptr& other) : QSharedPointer(other) + {} + + template + constexpr shared_qobject_ptr(const QSharedPointer& other) : QSharedPointer(other) + {} + + void reset() { QSharedPointer::reset(); } + void reset(T* other) + { + shared_qobject_ptr t(other); + this->swap(t); + } + void reset(const shared_qobject_ptr& other) + { + shared_qobject_ptr t(other); + this->swap(t); + } +}; + +template +shared_qobject_ptr makeShared(Args... args) +{ + auto obj = new T(args...); + return shared_qobject_ptr(obj); +} diff --git a/launcher/QVariantUtils.h b/launcher/QVariantUtils.h new file mode 100644 index 0000000..23fe825 --- /dev/null +++ b/launcher/QVariantUtils.h @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2023 flowln + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +namespace QVariantUtils { + +template +inline QList toList(QVariant src) +{ + QVariantList variantList = src.toList(); + + QList list_t; + list_t.reserve(variantList.size()); + for (const QVariant& v : variantList) { + list_t.append(v.value()); + } + return list_t; +} + +template +inline QVariant fromList(QList val) +{ + QVariantList variantList; + variantList.reserve(val.size()); + for (const T& v : val) { + variantList.append(v); + } + + return variantList; +} + +} // namespace QVariantUtils diff --git a/launcher/RWStorage.h b/launcher/RWStorage.h new file mode 100644 index 0000000..5764d79 --- /dev/null +++ b/launcher/RWStorage.h @@ -0,0 +1,63 @@ +#pragma once +#include +#include +#include +#include + +template +class RWStorage { + public: + void add(K key, V value) + { + QWriteLocker l(&lock); + cache[key] = value; + stale_entries.remove(key); + } + V get(K key) + { + QReadLocker l(&lock); + if (cache.contains(key)) { + return cache[key]; + } else + return V(); + } + bool get(K key, V& value) + { + QReadLocker l(&lock); + if (cache.contains(key)) { + value = cache[key]; + return true; + } else + return false; + } + bool has(K key) + { + QReadLocker l(&lock); + return cache.contains(key); + } + bool stale(K key) + { + QReadLocker l(&lock); + if (!cache.contains(key)) + return true; + return stale_entries.contains(key); + } + void setStale(K key) + { + QWriteLocker l(&lock); + if (cache.contains(key)) { + stale_entries.insert(key); + } + } + void clear() + { + QWriteLocker l(&lock); + cache.clear(); + stale_entries.clear(); + } + + private: + QReadWriteLock lock; + QMap cache; + QSet stale_entries; +}; diff --git a/launcher/RecursiveFileSystemWatcher.cpp b/launcher/RecursiveFileSystemWatcher.cpp new file mode 100644 index 0000000..b0137fb --- /dev/null +++ b/launcher/RecursiveFileSystemWatcher.cpp @@ -0,0 +1,95 @@ +#include "RecursiveFileSystemWatcher.h" + +#include + +RecursiveFileSystemWatcher::RecursiveFileSystemWatcher(QObject* parent) : QObject(parent), m_watcher(new QFileSystemWatcher(this)) +{ + connect(m_watcher, &QFileSystemWatcher::fileChanged, this, &RecursiveFileSystemWatcher::fileChange); + connect(m_watcher, &QFileSystemWatcher::directoryChanged, this, &RecursiveFileSystemWatcher::directoryChange); +} + +void RecursiveFileSystemWatcher::setRootDir(const QDir& root) +{ + bool wasEnabled = m_isEnabled; + disable(); + m_root = root; + setFiles(scanRecursive(m_root)); + if (wasEnabled) { + enable(); + } +} +void RecursiveFileSystemWatcher::setWatchFiles(const bool watchFiles) +{ + bool wasEnabled = m_isEnabled; + disable(); + m_watchFiles = watchFiles; + if (wasEnabled) { + enable(); + } +} + +void RecursiveFileSystemWatcher::enable() +{ + if (m_isEnabled) { + return; + } + Q_ASSERT(m_root != QDir::root()); + addFilesToWatcherRecursive(m_root); + m_isEnabled = true; +} +void RecursiveFileSystemWatcher::disable() +{ + if (!m_isEnabled) { + return; + } + m_isEnabled = false; + m_watcher->removePaths(m_watcher->files()); + m_watcher->removePaths(m_watcher->directories()); +} + +void RecursiveFileSystemWatcher::setFiles(const QStringList& files) +{ + if (files != m_files) { + m_files = files; + emit filesChanged(); + } +} + +void RecursiveFileSystemWatcher::addFilesToWatcherRecursive(const QDir& dir) +{ + m_watcher->addPath(dir.absolutePath()); + for (const QString& directory : dir.entryList(QDir::Dirs | QDir::NoDotAndDotDot)) { + addFilesToWatcherRecursive(dir.absoluteFilePath(directory)); + } + if (m_watchFiles) { + for (const QFileInfo& info : dir.entryInfoList(QDir::Files)) { + m_watcher->addPath(info.absoluteFilePath()); + } + } +} +QStringList RecursiveFileSystemWatcher::scanRecursive(const QDir& directory) +{ + QStringList ret; + if (!m_matcher) { + return {}; + } + for (const QString& dir : directory.entryList(QDir::Dirs | QDir::NoDotAndDotDot | QDir::Hidden)) { + ret.append(scanRecursive(directory.absoluteFilePath(dir))); + } + for (const QString& file : directory.entryList(QDir::Files | QDir::Hidden)) { + auto relPath = m_root.relativeFilePath(directory.absoluteFilePath(file)); + if (m_matcher(relPath)) { + ret.append(relPath); + } + } + return ret; +} + +void RecursiveFileSystemWatcher::fileChange(const QString& path) +{ + emit fileChanged(path); +} +void RecursiveFileSystemWatcher::directoryChange([[maybe_unused]] const QString& path) +{ + setFiles(scanRecursive(m_root)); +} diff --git a/launcher/RecursiveFileSystemWatcher.h b/launcher/RecursiveFileSystemWatcher.h new file mode 100644 index 0000000..0a71e64 --- /dev/null +++ b/launcher/RecursiveFileSystemWatcher.h @@ -0,0 +1,48 @@ +#pragma once + +#include +#include +#include "Filter.h" + +class RecursiveFileSystemWatcher : public QObject { + Q_OBJECT + public: + RecursiveFileSystemWatcher(QObject* parent); + + void setRootDir(const QDir& root); + QDir rootDir() const { return m_root; } + + // WARNING: setting this to true may be bad for performance + void setWatchFiles(bool watchFiles); + bool watchFiles() const { return m_watchFiles; } + + void setMatcher(Filter matcher) { m_matcher = std::move(matcher); } + + QStringList files() const { return m_files; } + + signals: + void filesChanged(); + void fileChanged(const QString& path); + + public slots: + void enable(); + void disable(); + + private: + QDir m_root; + bool m_watchFiles = false; + bool m_isEnabled = false; + Filter m_matcher; + + QFileSystemWatcher* m_watcher; + + QStringList m_files; + void setFiles(const QStringList& files); + + void addFilesToWatcherRecursive(const QDir& dir); + QStringList scanRecursive(const QDir& dir); + + private slots: + void fileChange(const QString& path); + void directoryChange(const QString& path); +}; diff --git a/launcher/ResourceDownloadTask.cpp b/launcher/ResourceDownloadTask.cpp new file mode 100644 index 0000000..d50b3d5 --- /dev/null +++ b/launcher/ResourceDownloadTask.cpp @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022-2023 flowln + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "ResourceDownloadTask.h" + +#include "Application.h" + +#include "FileSystem.h" +#include "minecraft/mod/ResourceFolderModel.h" + +#include "minecraft/mod/ShaderPackFolderModel.h" +#include "modplatform/helpers/HashUtils.h" +#include "net/ApiDownload.h" +#include "net/ChecksumValidator.h" + +ResourceDownloadTask::ResourceDownloadTask(ModPlatform::IndexedPack::Ptr pack, + ModPlatform::IndexedVersion version, + ResourceFolderModel* packs, + bool is_indexed) + : m_pack(std::move(pack)), m_pack_version(std::move(version)), m_pack_model(packs) +{ + if (is_indexed) { + m_update_task.reset(new LocalResourceUpdateTask(m_pack_model->indexDir(), *m_pack, m_pack_version)); + connect(m_update_task.get(), &LocalResourceUpdateTask::hasOldResource, this, &ResourceDownloadTask::hasOldResource); + + addTask(m_update_task); + } + + m_filesNetJob.reset(new NetJob(tr("Resource download"), APPLICATION->network())); + m_filesNetJob->setStatus(tr("Downloading resource:\n%1").arg(m_pack_version.downloadUrl)); + + auto action = Net::ApiDownload::makeFile(m_pack_version.downloadUrl, m_pack_model->dir().absoluteFilePath(getFilename())); + if (!m_pack_version.hash_type.isEmpty() && !m_pack_version.hash.isEmpty()) { + switch (Hashing::algorithmFromString(m_pack_version.hash_type)) { + case Hashing::Algorithm::Md4: + action->addValidator(new Net::ChecksumValidator(QCryptographicHash::Algorithm::Md4, m_pack_version.hash)); + break; + case Hashing::Algorithm::Md5: + action->addValidator(new Net::ChecksumValidator(QCryptographicHash::Algorithm::Md5, m_pack_version.hash)); + break; + case Hashing::Algorithm::Sha1: + action->addValidator(new Net::ChecksumValidator(QCryptographicHash::Algorithm::Sha1, m_pack_version.hash)); + break; + case Hashing::Algorithm::Sha256: + action->addValidator(new Net::ChecksumValidator(QCryptographicHash::Algorithm::Sha256, m_pack_version.hash)); + break; + case Hashing::Algorithm::Sha512: + action->addValidator(new Net::ChecksumValidator(QCryptographicHash::Algorithm::Sha512, m_pack_version.hash)); + break; + default: + break; + } + } + m_filesNetJob->addNetAction(action); + connect(m_filesNetJob.get(), &NetJob::succeeded, this, &ResourceDownloadTask::downloadSucceeded); + connect(m_filesNetJob.get(), &NetJob::progress, this, &ResourceDownloadTask::downloadProgressChanged); + connect(m_filesNetJob.get(), &NetJob::stepProgress, this, &ResourceDownloadTask::propagateStepProgress); + connect(m_filesNetJob.get(), &NetJob::failed, this, &ResourceDownloadTask::downloadFailed); + + addTask(m_filesNetJob); +} + +void ResourceDownloadTask::downloadSucceeded() +{ + m_filesNetJob.reset(); + auto oldName = std::get<0>(to_delete); + auto oldFilename = std::get<1>(to_delete); + + if (oldName.isEmpty() || oldFilename == m_pack_version.fileName) + return; + + m_pack_model->uninstallResource(oldFilename, true); + + // also rename the shader config file + if (dynamic_cast(m_pack_model) != nullptr) { + QFileInfo oldConfig(m_pack_model->dir(), oldFilename + ".txt"); + QFileInfo newConfig(m_pack_model->dir(), getFilename() + ".txt"); + + if (oldConfig.exists() && !newConfig.exists()) { + bool success = FS::move(oldConfig.filePath(), newConfig.filePath()); + + if (!success) + emit logWarning(tr("Failed to rename shader config from '%1' to '%2'").arg(oldConfig.fileName(), newConfig.fileName())); + } + } +} + +void ResourceDownloadTask::downloadFailed(QString reason) +{ + m_filesNetJob.reset(); + emitFailed(reason); +} + +void ResourceDownloadTask::downloadProgressChanged(qint64 current, qint64 total) +{ + emit progress(current, total); +} + +// This indirection is done so that we don't delete a mod before being sure it was +// downloaded successfully! +void ResourceDownloadTask::hasOldResource(QString name, QString filename) +{ + to_delete = { name, filename }; +} diff --git a/launcher/ResourceDownloadTask.h b/launcher/ResourceDownloadTask.h new file mode 100644 index 0000000..7a04c6f --- /dev/null +++ b/launcher/ResourceDownloadTask.h @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022-2023 flowln + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include "net/NetJob.h" +#include "tasks/SequentialTask.h" + +#include "minecraft/mod/tasks/LocalResourceUpdateTask.h" +#include "modplatform/ModIndex.h" + +class ResourceFolderModel; + +class ResourceDownloadTask : public SequentialTask { + Q_OBJECT + public: + explicit ResourceDownloadTask(ModPlatform::IndexedPack::Ptr pack, + ModPlatform::IndexedVersion version, + ResourceFolderModel* packs, + bool is_indexed = true); + const QString& getFilename() const { return m_pack_version.fileName; } + const QVariant& getVersionID() const { return m_pack_version.fileId; } + const ModPlatform::IndexedVersion& getVersion() const { return m_pack_version; } + const ModPlatform::ResourceProvider& getProvider() const { return m_pack->provider; } + const QString& getName() const { return m_pack->name; } + ModPlatform::IndexedPack::Ptr getPack() { return m_pack; } + + private: + ModPlatform::IndexedPack::Ptr m_pack; + ModPlatform::IndexedVersion m_pack_version; + ResourceFolderModel* m_pack_model; + + NetJob::Ptr m_filesNetJob; + LocalResourceUpdateTask::Ptr m_update_task; + + void downloadProgressChanged(qint64 current, qint64 total); + void downloadFailed(QString reason); + void downloadSucceeded(); + + std::tuple to_delete{ "", "" }; + + private slots: + void hasOldResource(QString name, QString filename); +}; diff --git a/launcher/RuntimeContext.h b/launcher/RuntimeContext.h new file mode 100644 index 0000000..84a56a8 --- /dev/null +++ b/launcher/RuntimeContext.h @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include +#include "SysInfo.h" +#include "settings/SettingsObject.h" + +struct RuntimeContext { + QString javaArchitecture; + QString javaRealArchitecture; + QString system = SysInfo::currentSystem(); + + QString mappedJavaRealArchitecture() const + { + if (javaRealArchitecture == "amd64") + return "x86_64"; + if (javaRealArchitecture == "i386" || javaRealArchitecture == "i686") + return "x86"; + if (javaRealArchitecture == "aarch64") + return "arm64"; + if (javaRealArchitecture == "arm" || javaRealArchitecture == "armhf") + return "arm32"; + return javaRealArchitecture; + } + + void updateFromInstanceSettings(SettingsObject* instanceSettings) + { + javaArchitecture = instanceSettings->get("JavaArchitecture").toString(); + javaRealArchitecture = instanceSettings->get("JavaRealArchitecture").toString(); + } + + QString getClassifier() const { return system + "-" + mappedJavaRealArchitecture(); } + + // "Legacy" refers to the fact that Mojang assumed that these are the only two architectures + bool isLegacyArch() const + { + const QString mapped = mappedJavaRealArchitecture(); + return mapped == "x86_64" || mapped == "x86"; + } + + bool classifierMatches(QString target) const + { + // try to match precise classifier "[os]-[arch]" + bool x = target == getClassifier(); + // try to match imprecise classifier on legacy architectures "[os]" + if (!x && isLegacyArch()) + x = target == system; + + return x; + } +}; diff --git a/launcher/SeparatorPrefixTree.h b/launcher/SeparatorPrefixTree.h new file mode 100644 index 0000000..df9dfe5 --- /dev/null +++ b/launcher/SeparatorPrefixTree.h @@ -0,0 +1,230 @@ +#pragma once +#include +#include +#include + +template +class SeparatorPrefixTree { + public: + SeparatorPrefixTree(QStringList paths) { insert(paths); } + + SeparatorPrefixTree(bool contained = false) { m_contained = contained; } + + void insert(QStringList paths) + { + for (auto& path : paths) { + insert(path); + } + } + + /// insert an exact path into the tree + SeparatorPrefixTree& insert(QString path) + { + auto sepIndex = path.indexOf(Tseparator); + if (sepIndex == -1) { + children[path] = SeparatorPrefixTree(true); + return children[path]; + } else { + auto prefix = path.left(sepIndex); + if (!children.contains(prefix)) { + children[prefix] = SeparatorPrefixTree(false); + } + return children[prefix].insert(path.mid(sepIndex + 1)); + } + } + + /// is the path fully contained in the tree? + bool contains(QString path) const + { + auto node = find(path); + return node != nullptr; + } + + /// does the tree cover a path? That means the prefix of the path is contained in the tree + bool covers(QString path) const + { + // if we found some valid node, it's good enough. the tree covers the path + if (m_contained) { + return true; + } + auto sepIndex = path.indexOf(Tseparator); + if (sepIndex == -1) { + auto found = children.find(path); + if (found == children.end()) { + return false; + } + return (*found).covers(QString()); + } else { + auto prefix = path.left(sepIndex); + auto found = children.find(prefix); + if (found == children.end()) { + return false; + } + return (*found).covers(path.mid(sepIndex + 1)); + } + } + + /// return the contained path that covers the path specified + QString cover(QString path) const + { + // if we found some valid node, it's good enough. the tree covers the path + if (m_contained) { + return QString(""); + } + auto sepIndex = path.indexOf(Tseparator); + if (sepIndex == -1) { + auto found = children.find(path); + if (found == children.end()) { + return QString(); + } + auto nested = (*found).cover(QString()); + if (nested.isNull()) { + return nested; + } + if (nested.isEmpty()) + return path; + return path + Tseparator + nested; + } else { + auto prefix = path.left(sepIndex); + auto found = children.find(prefix); + if (found == children.end()) { + return QString(); + } + auto nested = (*found).cover(path.mid(sepIndex + 1)); + if (nested.isNull()) { + return nested; + } + if (nested.isEmpty()) + return prefix; + return prefix + Tseparator + nested; + } + } + + /// Does the path-specified node exist in the tree? It does not have to be contained. + bool exists(QString path) const + { + auto sepIndex = path.indexOf(Tseparator); + if (sepIndex == -1) { + auto found = children.find(path); + if (found == children.end()) { + return false; + } + return true; + } else { + auto prefix = path.left(sepIndex); + auto found = children.find(prefix); + if (found == children.end()) { + return false; + } + return (*found).exists(path.mid(sepIndex + 1)); + } + } + + /// find a node in the tree by name + const SeparatorPrefixTree* find(QString path) const + { + auto sepIndex = path.indexOf(Tseparator); + if (sepIndex == -1) { + auto found = children.find(path); + if (found == children.end()) { + return nullptr; + } + return &(*found); + } else { + auto prefix = path.left(sepIndex); + auto found = children.find(prefix); + if (found == children.end()) { + return nullptr; + } + return (*found).find(path.mid(sepIndex + 1)); + } + } + + /// is this a leaf node? + bool leaf() const { return children.isEmpty(); } + + /// is this node actually contained in the tree, or is it purely structural? + bool contained() const { return m_contained; } + + /// Remove a path from the tree + bool remove(QString path) { return removeInternal(path) != Failed; } + + /// Clear all children of this node tree node + void clear() { children.clear(); } + + QStringList toStringList() const + { + QStringList collected; + // collecting these is more expensive. + auto iter = children.begin(); + while (iter != children.end()) { + QStringList list = iter.value().toStringList(); + for (int i = 0; i < list.size(); i++) { + list[i] = iter.key() + Tseparator + list[i]; + } + collected.append(list); + if ((*iter).m_contained) { + collected.append(iter.key()); + } + iter++; + } + return collected; + } + + private: + enum Removal { Failed, Succeeded, HasChildren }; + Removal removeInternal(QString path = QString()) + { + if (path.isEmpty()) { + if (!m_contained) { + // remove all children - we are removing a prefix + clear(); + return Succeeded; + } + m_contained = false; + if (children.size()) { + return HasChildren; + } + return Succeeded; + } + Removal remStatus = Failed; + QString childToRemove; + auto sepIndex = path.indexOf(Tseparator); + if (sepIndex == -1) { + childToRemove = path; + auto found = children.find(childToRemove); + if (found == children.end()) { + return Failed; + } + remStatus = (*found).removeInternal(); + } else { + childToRemove = path.left(sepIndex); + auto found = children.find(childToRemove); + if (found == children.end()) { + return Failed; + } + remStatus = (*found).removeInternal(path.mid(sepIndex + 1)); + } + switch (remStatus) { + case Failed: + case HasChildren: { + return remStatus; + } + case Succeeded: { + children.remove(childToRemove); + if (m_contained) { + return HasChildren; + } + if (children.size()) { + return HasChildren; + } + return Succeeded; + } + } + return Failed; + } + + private: + QMap> children; + bool m_contained = false; +}; diff --git a/launcher/StringUtils.cpp b/launcher/StringUtils.cpp new file mode 100644 index 0000000..a79cfb5 --- /dev/null +++ b/launcher/StringUtils.cpp @@ -0,0 +1,234 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2023 flowln + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "StringUtils.h" + +#include +#include +#include + +/// If you're wondering where these came from exactly, then know you're not the only one =D + +/// TAKEN FROM Qt, because it doesn't expose it intelligently +static inline QChar getNextChar(const QString& s, int location) +{ + return (location < s.length()) ? s.at(location) : QChar(); +} + +/// TAKEN FROM Qt, because it doesn't expose it intelligently +int StringUtils::naturalCompare(const QString& s1, const QString& s2, Qt::CaseSensitivity cs) +{ + int l1 = 0, l2 = 0; + while (l1 <= s1.size() && l2 <= s2.size()) { + // skip spaces, tabs and 0's + QChar c1 = getNextChar(s1, l1); + while (c1.isSpace()) + c1 = getNextChar(s1, ++l1); + + QChar c2 = getNextChar(s2, l2); + while (c2.isSpace()) + c2 = getNextChar(s2, ++l2); + + if (c1.isDigit() && c2.isDigit()) { + while (c1.digitValue() == 0) + c1 = getNextChar(s1, ++l1); + while (c2.digitValue() == 0) + c2 = getNextChar(s2, ++l2); + + int lookAheadLocation1 = l1; + int lookAheadLocation2 = l2; + int currentReturnValue = 0; + // find the last digit, setting currentReturnValue as we go if it isn't equal + for (QChar lookAhead1 = c1, lookAhead2 = c2; (lookAheadLocation1 <= s1.length() && lookAheadLocation2 <= s2.length()); + lookAhead1 = getNextChar(s1, ++lookAheadLocation1), lookAhead2 = getNextChar(s2, ++lookAheadLocation2)) { + bool is1ADigit = !lookAhead1.isNull() && lookAhead1.isDigit(); + bool is2ADigit = !lookAhead2.isNull() && lookAhead2.isDigit(); + if (!is1ADigit && !is2ADigit) + break; + if (!is1ADigit) + return -1; + if (!is2ADigit) + return 1; + if (currentReturnValue == 0) { + if (lookAhead1 < lookAhead2) { + currentReturnValue = -1; + } else if (lookAhead1 > lookAhead2) { + currentReturnValue = 1; + } + } + } + if (currentReturnValue != 0) + return currentReturnValue; + } + + if (cs == Qt::CaseInsensitive) { + if (!c1.isLower()) + c1 = c1.toLower(); + if (!c2.isLower()) + c2 = c2.toLower(); + } + + int r = QString::localeAwareCompare(c1, c2); + if (r < 0) + return -1; + if (r > 0) + return 1; + + l1 += 1; + l2 += 1; + } + + // The two strings are the same (02 == 2) so fall back to the normal sort + return QString::compare(s1, s2, cs); +} + +QString StringUtils::truncateUrlHumanFriendly(QUrl& url, int max_len, bool hard_limit) +{ + auto display_options = QUrl::RemoveUserInfo | QUrl::RemoveFragment | QUrl::NormalizePathSegments; + auto str_url = url.toDisplayString(display_options); + + if (str_url.length() <= max_len) + return str_url; + + auto url_path_parts = url.path().split('/'); + QString last_path_segment = url_path_parts.takeLast(); + + if (url_path_parts.size() >= 1 && url_path_parts.first().isEmpty()) + url_path_parts.removeFirst(); // drop empty first segment (from leading / ) + + if (url_path_parts.size() >= 1) + url_path_parts.removeLast(); // drop the next to last path segment + + auto url_template = QStringLiteral("%1://%2/%3%4"); + + auto url_compact = url_path_parts.isEmpty() + ? url_template.arg(url.scheme(), url.host(), QStringList({ "...", last_path_segment }).join('/'), url.query()) + : url_template.arg(url.scheme(), url.host(), + QStringList({ url_path_parts.join('/'), "...", last_path_segment }).join('/'), url.query()); + + // remove url parts one by one if it's still too long + while (url_compact.length() > max_len && url_path_parts.size() >= 1) { + url_path_parts.removeLast(); // drop the next to last path segment + url_compact = url_path_parts.isEmpty() + ? url_template.arg(url.scheme(), url.host(), QStringList({ "...", last_path_segment }).join('/'), url.query()) + : url_template.arg(url.scheme(), url.host(), + QStringList({ url_path_parts.join('/'), "...", last_path_segment }).join('/'), url.query()); + } + + if ((url_compact.length() >= max_len) && hard_limit) { + // still too long, truncate normally + url_compact = QString(str_url); + auto to_remove = url_compact.length() - max_len + 3; + url_compact.remove(url_compact.length() - to_remove - 1, to_remove); + url_compact.append("..."); + } + + return url_compact; +} + +static const QStringList s_units_si{ "KB", "MB", "GB", "TB" }; +static const QStringList s_units_kibi{ "KiB", "MiB", "GiB", "TiB" }; + +QString StringUtils::humanReadableFileSize(double bytes, bool use_si, int decimal_points) +{ + const QStringList units = use_si ? s_units_si : s_units_kibi; + const int scale = use_si ? 1000 : 1024; + + int u = -1; + double r = pow(10, decimal_points); + + do { + bytes /= scale; + u++; + } while (round(abs(bytes) * r) / r >= scale && u < units.length() - 1); + + return QString::number(bytes, 'f', 2) + " " + units[u]; +} + +QString StringUtils::getRandomAlphaNumeric() +{ + return QUuid::createUuid().toString(QUuid::Id128); +} + +QPair StringUtils::splitFirst(const QString& s, const QString& sep, Qt::CaseSensitivity cs) +{ + QString left, right; + auto index = s.indexOf(sep, 0, cs); + left = s.mid(0, index); + right = s.mid(index + sep.length()); + return qMakePair(left, right); +} + +QPair StringUtils::splitFirst(const QString& s, QChar sep, Qt::CaseSensitivity cs) +{ + QString left, right; + auto index = s.indexOf(sep, 0, cs); + left = s.mid(0, index); + right = s.mid(left.length() + 1); + return qMakePair(left, right); +} + +QPair StringUtils::splitFirst(const QString& s, const QRegularExpression& re) +{ + QString left, right; + QRegularExpressionMatch match; + auto index = s.indexOf(re, 0, &match); + left = s.mid(0, index); + auto end = match.hasMatch() ? left.length() + match.capturedLength() : left.length() + 1; + right = s.mid(end); + return qMakePair(left, right); +} + +QString StringUtils::htmlListPatch(QString htmlStr) +{ + static const QRegularExpression s_ulMatcher("<\\s*/\\s*ul\\s*>"); + int pos = htmlStr.indexOf(s_ulMatcher); + int imgPos; + while (pos != -1) { + pos = htmlStr.indexOf(">", pos) + 1; // Get the size of the tag. Add one for zeroeth index + imgPos = htmlStr.indexOf(""); + + pos = htmlStr.indexOf(s_ulMatcher, pos); + } + return htmlStr; +} diff --git a/launcher/StringUtils.h b/launcher/StringUtils.h new file mode 100644 index 0000000..624ee41 --- /dev/null +++ b/launcher/StringUtils.h @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2023 flowln + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include + +namespace StringUtils { + +#if defined Q_OS_WIN32 +using string = std::wstring; + +inline string toStdString(QString s) +{ + return s.toStdWString(); +} +inline QString fromStdString(string s) +{ + return QString::fromStdWString(s); +} +#else +using string = std::string; + +inline string toStdString(QString s) +{ + return s.toStdString(); +} +inline QString fromStdString(string s) +{ + return QString::fromStdString(s); +} +#endif + +int naturalCompare(const QString& s1, const QString& s2, Qt::CaseSensitivity cs); + +/** + * @brief Truncate a url while keeping its readability py placing the `...` in the middle of the path + * @param url Url to truncate + * @param max_len max length of url in characters + * @param hard_limit if truncating the path can't get the url short enough, truncate it normally. + */ +QString truncateUrlHumanFriendly(QUrl& url, int max_len, bool hard_limit = false); + +QString humanReadableFileSize(double bytes, bool use_si = false, int decimal_points = 1); + +QString getRandomAlphaNumeric(); + +QPair splitFirst(const QString& s, const QString& sep, Qt::CaseSensitivity cs = Qt::CaseSensitive); +QPair splitFirst(const QString& s, QChar sep, Qt::CaseSensitivity cs = Qt::CaseSensitive); +QPair splitFirst(const QString& s, const QRegularExpression& re); + +QString htmlListPatch(QString htmlStr); + +} // namespace StringUtils diff --git a/launcher/SysInfo.cpp b/launcher/SysInfo.cpp new file mode 100644 index 0000000..d02b1d8 --- /dev/null +++ b/launcher/SysInfo.cpp @@ -0,0 +1,128 @@ + +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 r58Playz + * Copyright (C) 2024 timoreo + * Copyright (C) 2024 Trial97 + * Copyright (C) 2025 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include + +#include "HardwareInfo.h" + +#ifdef Q_OS_MACOS +#include + +bool rosettaDetect() +{ + int ret = 0; + size_t size = sizeof(ret); + if (sysctlbyname("sysctl.proc_translated", &ret, &size, nullptr, 0) == -1) { + return false; + } + return ret == 1; +} +#endif + +namespace SysInfo { +QString currentSystem() +{ +#if defined(Q_OS_LINUX) + return "linux"; +#elif defined(Q_OS_MACOS) + return "osx"; +#elif defined(Q_OS_WINDOWS) + return "windows"; +#elif defined(Q_OS_FREEBSD) + return "freebsd"; +#elif defined(Q_OS_OPENBSD) + return "openbsd"; +#else + return "unknown"; +#endif +} + +QString useQTForArch() +{ +#if defined(Q_OS_MACOS) && !defined(Q_PROCESSOR_ARM) + if (rosettaDetect()) { + return "arm64"; + } else { + return "x86_64"; + } +#endif + return QSysInfo::currentCpuArchitecture(); +} + +int defaultMaxJvmMem() +{ + // If totalRAM < 6GB, use (totalRAM / 1.5), else 4GB + if (const uint64_t totalRAM = HardwareInfo::totalRamMiB(); totalRAM < (4096 * 1.5)) + return totalRAM / 1.5; + else + return 4096; +} + +QString getSupportedJavaArchitecture() +{ + auto sys = currentSystem(); + auto arch = useQTForArch(); + if (sys == "windows") { + if (arch == "x86_64") + return "windows-x64"; + if (arch == "i386") + return "windows-x86"; + // Unknown, maybe arm, appending arch + return "windows-" + arch; + } + if (sys == "osx") { + if (arch == "arm64") + return "mac-os-arm64"; + if (arch.contains("64")) + return "mac-os-x64"; + if (arch.contains("86")) + return "mac-os-x86"; + // Unknown, maybe something new, appending arch + return "mac-os-" + arch; + } else if (sys == "linux") { + if (arch == "x86_64") + return "linux-x64"; + if (arch == "i386") + return "linux-x86"; + // will work for arm32 arm(64) + return "linux-" + arch; + } + return {}; +} +} // namespace SysInfo diff --git a/launcher/SysInfo.h b/launcher/SysInfo.h new file mode 100644 index 0000000..da23c5b --- /dev/null +++ b/launcher/SysInfo.h @@ -0,0 +1,12 @@ +#pragma once + +#include + +#include + +namespace SysInfo { +QString currentSystem(); +QString useQTForArch(); +QString getSupportedJavaArchitecture(); +int defaultMaxJvmMem(); +} // namespace SysInfo diff --git a/launcher/Usable.h b/launcher/Usable.h new file mode 100644 index 0000000..8cef298 --- /dev/null +++ b/launcher/Usable.h @@ -0,0 +1,48 @@ +#pragma once + +#include +#include + +#include "QObjectPtr.h" + +class Usable; + +/** + * Base class for things that can be used by multiple other things and we want to track the use count. + * + * @see UseLock + */ +class Usable { + friend class UseLock; + + public: + virtual ~Usable() {} + + std::size_t useCount() const { return m_useCount; } + bool isInUse() const { return m_useCount > 0; } + + protected: + virtual void decrementUses() { m_useCount--; } + virtual void incrementUses() { m_useCount++; } + + private: + std::size_t m_useCount = 0; +}; + +/** + * Lock class to use for keeping track of uses of other things derived from Usable + * + * @see Usable + */ +class UseLock { + public: + UseLock(Usable* usable) : m_usable(usable) + { + // this doesn't use shared pointer use count, because that wouldn't be correct. this count is separate. + m_usable->incrementUses(); + } + ~UseLock() { m_usable->decrementUses(); } + + private: + Usable* m_usable; +}; diff --git a/launcher/Version.cpp b/launcher/Version.cpp new file mode 100644 index 0000000..d5496ce --- /dev/null +++ b/launcher/Version.cpp @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 flowln + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (c) 2026 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +#include "Version.h" + +#include +#include +#include +#include + +/// qDebug print support for the Version class +QDebug operator<<(QDebug debug, const Version& v) +{ + const QDebugStateSaver saver(debug); + + debug.nospace() << "Version{ string: " << v.toString() << ", sections: [ "; + + bool first = true; + for (const auto& s : v.m_sections) { + if (!first) { + debug.nospace() << ", "; + } + debug.nospace() << s.value; + first = false; + } + + debug.nospace() << " ]" << " }"; + + return debug; +} + +std::strong_ordering Version::Section::operator<=>(const Section& other) const +{ + // If both components are numeric, compare numerically (codepoint-wise) + if (this->t == Type::Numeric && other.t == Type::Numeric) { + auto aLen = this->value.size(); + if (aLen != other.value.size()) { + // Lengths differ; compare by length + return aLen <=> other.value.size(); + } + // Compare by digits + auto cmp = QString::compare(this->value, other.value); + if (cmp < 0) { + return std::strong_ordering::less; + } + if (cmp > 0) { + return std::strong_ordering::greater; + } + return std::strong_ordering::equal; + } + // One or both are null + if (this->t == Type::Null) { + if (other.t == Type::PreRelease) { + return std::strong_ordering::greater; + } + return std::strong_ordering::less; + } + if (other.t == Type::Null) { + if (this->t == Type::PreRelease) { + return std::strong_ordering::less; + } + return std::strong_ordering::greater; + } + // Textual comparison (differing type, or both textual/pre-release) + auto minLen = qMin(this->value.size(), other.value.size()); + for (int i = 0; i < minLen; i++) { + auto a = this->value.at(i); + auto b = other.value.at(i); + if (a != b) { + // Compare by rune + return a.unicode() <=> b.unicode(); + } + } + // Compare by length + return this->value.size() <=> other.value.size(); +} + +namespace { +void removeLeadingZeros(QString& s) +{ + s.remove(0, std::distance(s.begin(), std::ranges::find_if_not(s, [](QChar c) { return c == '0'; }))); +} +} // namespace + +void Version::parse() +{ + auto len = m_string.size(); + for (int i = 0; i < len;) { + Section cur(Section::Type::Textual); + auto c = m_string.at(i); + if (c == '+') { + break; // Ignore appendices + } + // custom: the space is special to handle the strings like "1.20 Pre-Release 1" + // this is needed to support Modrinth versions + if (c == '-' || c == ' ') { + // Add dash to component + cur.value += c; + i++; + // If the next rune is non-digit, mark as pre-release (requires >= 1 non-digit after dash so the component has length > 1) + if (i < len && !m_string.at(i).isDigit()) { + cur.t = Section::Type::PreRelease; + } + } else if (c.isDigit()) { + // Mark as numeric + cur.t = Section::Type::Numeric; + } + for (; i < len; i++) { + auto r = m_string.at(i); + if ((r.isDigit() != (cur.t == Section::Type::Numeric)) // starts a new section + || (r == ' ' && cur.t == Section::Type::Numeric) // custom: numeric section then a space is a pre-release + || (r == '-' && cur.t != Section::Type::PreRelease) // "---" is a valid pre-release component + || r == '+') { + // Run completed (do not consume this rune) + break; + } + // Add rune to current run + cur.value += r; + } + if (!cur.value.isEmpty()) { + if (cur.t == Section::Type::Numeric) { + removeLeadingZeros(cur.value); + } + m_sections.append(cur); + } + } +} + +std::strong_ordering Version::operator<=>(const Version& other) const +{ + const auto size = qMax(m_sections.size(), other.m_sections.size()); + for (int i = 0; i < size; ++i) { + auto sec1 = (i >= m_sections.size()) ? Section() : m_sections.at(i); + auto sec2 = (i >= other.m_sections.size()) ? Section() : other.m_sections.at(i); + + if (auto cmp = sec1 <=> sec2; cmp != std::strong_ordering::equal) { + return cmp; + } + } + return std::strong_ordering::equal; +} diff --git a/launcher/Version.h b/launcher/Version.h new file mode 100644 index 0000000..c0f70f4 --- /dev/null +++ b/launcher/Version.h @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 flowln + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (c) 2026 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include +#include +#include + +// this implements the FlexVer +// https://git.sleeping.town/exa/FlexVer +class Version { + public: + Version(QString str) : m_string(std::move(str)) { parse(); } // NOLINT(hicpp-explicit-conversions) + Version() = default; + + private: + struct Section { + enum class Type : std::uint8_t { Null, Textual, Numeric, PreRelease }; + explicit Section(Type t = Type::Null, QString value = "") : t(t), value(std::move(value)) {} + Type t; + QString value; + bool operator==(const Section& other) const = default; + std::strong_ordering operator<=>(const Section& other) const; + }; + + private: + void parse(); + + public: + QString toString() const { return m_string; } + bool isEmpty() const { return m_string.isEmpty(); } + + friend QDebug operator<<(QDebug debug, const Version& v); + + bool operator==(const Version& other) const { return (*this <=> other) == std::strong_ordering::equal; } + std::strong_ordering operator<=>(const Version& other) const; + + private: + QString m_string; + QList
m_sections; +}; \ No newline at end of file diff --git a/launcher/VersionProxyModel.cpp b/launcher/VersionProxyModel.cpp new file mode 100644 index 0000000..aaab7e8 --- /dev/null +++ b/launcher/VersionProxyModel.cpp @@ -0,0 +1,441 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "VersionProxyModel.h" +#include +#include +#include +#include +#include + +class VersionFilterModel : public QSortFilterProxyModel { + Q_OBJECT + public: + VersionFilterModel(VersionProxyModel* parent) : QSortFilterProxyModel(parent) + { + m_parent = parent; + setSortRole(BaseVersionList::SortRole); + sort(0, Qt::DescendingOrder); + } + + bool filterAcceptsRow(int source_row, const QModelIndex& source_parent) const + { + const auto& filters = m_parent->filters(); + const QString& search = m_parent->search(); + const QModelIndex idx = sourceModel()->index(source_row, 0, source_parent); + + if (!search.isEmpty() && !sourceModel()->data(idx, BaseVersionList::VersionRole).toString().contains(search, Qt::CaseInsensitive)) + return false; + + for (auto it = filters.begin(); it != filters.end(); ++it) { + auto data = sourceModel()->data(idx, it.key()); + auto match = data.toString(); + if (!it.value()(match)) { + return false; + } + } + return true; + } + + void filterChanged() { invalidateFilter(); } + + private: + VersionProxyModel* m_parent; +}; + +VersionProxyModel::VersionProxyModel(QObject* parent) : QAbstractProxyModel(parent) +{ + filterModel = new VersionFilterModel(this); + connect(filterModel, &QAbstractItemModel::dataChanged, this, &VersionProxyModel::sourceDataChanged); + connect(filterModel, &QAbstractItemModel::rowsAboutToBeInserted, this, &VersionProxyModel::sourceRowsAboutToBeInserted); + connect(filterModel, &QAbstractItemModel::rowsInserted, this, &VersionProxyModel::sourceRowsInserted); + connect(filterModel, &QAbstractItemModel::rowsAboutToBeRemoved, this, &VersionProxyModel::sourceRowsAboutToBeRemoved); + connect(filterModel, &QAbstractItemModel::rowsRemoved, this, &VersionProxyModel::sourceRowsRemoved); + // FIXME: implement when needed + /* + connect(replacing, &QAbstractItemModel::rowsAboutToBeMoved, this, &VersionProxyModel::sourceRowsAboutToBeMoved); + connect(replacing, &QAbstractItemModel::rowsMoved, this, &VersionProxyModel::sourceRowsMoved); + connect(replacing, &QAbstractItemModel::layoutAboutToBeChanged, this, &VersionProxyModel::sourceLayoutAboutToBeChanged); + connect(replacing, &QAbstractItemModel::layoutChanged, this, &VersionProxyModel::sourceLayoutChanged); + */ + connect(filterModel, &QAbstractItemModel::modelAboutToBeReset, this, &VersionProxyModel::sourceAboutToBeReset); + connect(filterModel, &QAbstractItemModel::modelReset, this, &VersionProxyModel::sourceReset); + + QAbstractProxyModel::setSourceModel(filterModel); +} + +QVariant VersionProxyModel::headerData(int section, Qt::Orientation orientation, int role) const +{ + if (section < 0 || section >= m_columns.size()) + return QVariant(); + if (orientation != Qt::Horizontal) + return QVariant(); + auto column = m_columns[section]; + if (role == Qt::DisplayRole) { + switch (column) { + case Name: + return tr("Version"); + case ParentVersion: + return tr("Minecraft"); // FIXME: this should come from metadata + case Branch: + return tr("Branch"); + case Type: + return tr("Type"); + case CPUArchitecture: + return tr("Architecture"); + case Path: + return tr("Path"); + case JavaName: + return tr("Java Name"); + case JavaMajor: + return tr("Major Version"); + case Time: + return tr("Released"); + } + } else if (role == Qt::ToolTipRole) { + switch (column) { + case Name: + return tr("The name of the version."); + case ParentVersion: + return tr("Minecraft version"); // FIXME: this should come from metadata + case Branch: + return tr("The version's branch"); + case Type: + return tr("The version's type"); + case CPUArchitecture: + return tr("CPU Architecture"); + case Path: + return tr("Filesystem path to this version"); + case JavaName: + return tr("The alternative name of the Java version"); + case JavaMajor: + return tr("The Java major version"); + case Time: + return tr("Release date of this version"); + } + } + return QVariant(); +} + +QVariant VersionProxyModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid()) { + return QVariant(); + } + auto column = m_columns[index.column()]; + auto parentIndex = mapToSource(index); + switch (role) { + case Qt::DisplayRole: { + switch (column) { + case Name: { + QString version = sourceModel()->data(parentIndex, BaseVersionList::VersionRole).toString(); + if (version == m_currentVersion) { + return tr("%1 (installed)").arg(version); + } + return version; + } + case ParentVersion: + return sourceModel()->data(parentIndex, BaseVersionList::ParentVersionRole); + case Branch: + return sourceModel()->data(parentIndex, BaseVersionList::BranchRole); + case Type: + return sourceModel()->data(parentIndex, BaseVersionList::TypeRole); + case CPUArchitecture: + return sourceModel()->data(parentIndex, BaseVersionList::CPUArchitectureRole); + case Path: + return sourceModel()->data(parentIndex, BaseVersionList::PathRole); + case JavaName: + return sourceModel()->data(parentIndex, BaseVersionList::JavaNameRole); + case JavaMajor: + return sourceModel()->data(parentIndex, BaseVersionList::JavaMajorRole); + case Time: + return sourceModel()->data(parentIndex, Meta::VersionList::TimeRole).toDate(); + default: + return QVariant(); + } + } + case Qt::ToolTipRole: { + if (column == Name && hasRecommended) { + auto value = sourceModel()->data(parentIndex, BaseVersionList::RecommendedRole); + if (value.toBool()) { + return tr("Recommended"); + } else if (hasLatest) { + auto latest = sourceModel()->data(parentIndex, BaseVersionList::LatestRole); + if (latest.toBool()) { + return tr("Latest"); + } + } + } + return sourceModel()->data(parentIndex, BaseVersionList::VersionIdRole); + } + case Qt::DecorationRole: { + if (column == Name && hasRecommended) { + auto recommenced = sourceModel()->data(parentIndex, BaseVersionList::RecommendedRole); + if (recommenced.toBool()) { + return QIcon::fromTheme("star"); + } else if (hasLatest) { + auto latest = sourceModel()->data(parentIndex, BaseVersionList::LatestRole); + if (latest.toBool()) { + return QIcon::fromTheme("bug"); + } + } + QPixmap pixmap; + QPixmapCache::find("placeholder", &pixmap); + if (!pixmap) { + QPixmap px(16, 16); + px.fill(Qt::transparent); + QPixmapCache::insert("placeholder", px); + return px; + } + return pixmap; + } + return QVariant(); + } + default: { + if (roles.contains((BaseVersionList::ModelRoles)role)) { + return sourceModel()->data(parentIndex, role); + } + return QVariant(); + } + } +} + +QModelIndex VersionProxyModel::parent([[maybe_unused]] const QModelIndex& child) const +{ + return QModelIndex(); +} + +QModelIndex VersionProxyModel::mapFromSource(const QModelIndex& sourceIndex) const +{ + if (sourceIndex.isValid()) { + return index(sourceIndex.row(), 0); + } + return QModelIndex(); +} + +QModelIndex VersionProxyModel::mapToSource(const QModelIndex& proxyIndex) const +{ + if (proxyIndex.isValid()) { + return sourceModel()->index(proxyIndex.row(), 0); + } + return QModelIndex(); +} + +QModelIndex VersionProxyModel::index(int row, int column, const QModelIndex& parent) const +{ + // no trees here... shoo + if (parent.isValid()) { + return QModelIndex(); + } + if (row < 0 || row >= sourceModel()->rowCount()) + return QModelIndex(); + if (column < 0 || column >= columnCount()) + return QModelIndex(); + return QAbstractItemModel::createIndex(row, column); +} + +int VersionProxyModel::columnCount(const QModelIndex& parent) const +{ + return parent.isValid() ? 0 : m_columns.size(); +} + +int VersionProxyModel::rowCount(const QModelIndex& parent) const +{ + if (sourceModel()) { + return sourceModel()->rowCount(parent); + } + return 0; +} + +void VersionProxyModel::sourceDataChanged(const QModelIndex& source_top_left, const QModelIndex& source_bottom_right) +{ + if (source_top_left.parent() != source_bottom_right.parent()) + return; + + // whole row is getting changed + auto topLeft = createIndex(source_top_left.row(), 0); + auto bottomRight = createIndex(source_bottom_right.row(), columnCount() - 1); + emit dataChanged(topLeft, bottomRight); +} + +void VersionProxyModel::setSourceModel(QAbstractItemModel* replacingRaw) +{ + auto replacing = dynamic_cast(replacingRaw); + + m_columns.clear(); + if (!replacing) { + roles.clear(); + filterModel->setSourceModel(replacing); + return; + } + + roles = replacing->providesRoles(); + if (roles.contains(BaseVersionList::VersionRole)) { + m_columns.push_back(Name); + } + /* + if(roles.contains(BaseVersionList::ParentVersionRole)) + { + m_columns.push_back(ParentVersion); + } + */ + if (roles.contains(BaseVersionList::CPUArchitectureRole)) { + m_columns.push_back(CPUArchitecture); + } + if (roles.contains(BaseVersionList::PathRole)) { + m_columns.push_back(Path); + } + if (roles.contains(BaseVersionList::JavaNameRole)) { + m_columns.push_back(JavaName); + } + if (roles.contains(BaseVersionList::JavaMajorRole)) { + m_columns.push_back(JavaMajor); + } + if (roles.contains(Meta::VersionList::TimeRole)) { + m_columns.push_back(Time); + } + if (roles.contains(BaseVersionList::BranchRole)) { + m_columns.push_back(Branch); + } + if (roles.contains(BaseVersionList::TypeRole)) { + m_columns.push_back(Type); + } + if (roles.contains(BaseVersionList::RecommendedRole)) { + hasRecommended = true; + } + if (roles.contains(BaseVersionList::LatestRole)) { + hasLatest = true; + } + filterModel->setSourceModel(replacing); +} + +QModelIndex VersionProxyModel::getRecommended() const +{ + if (!roles.contains(BaseVersionList::RecommendedRole)) { + return index(0, 0); + } + int recommended = 0; + for (int i = 0; i < rowCount(); i++) { + auto value = sourceModel()->data(mapToSource(index(i, 0)), BaseVersionList::RecommendedRole); + if (value.toBool()) { + recommended = i; + } + } + return index(recommended, 0); +} + +QModelIndex VersionProxyModel::getVersion(const QString& version) const +{ + int found = -1; + for (int i = 0; i < rowCount(); i++) { + auto value = sourceModel()->data(mapToSource(index(i, 0)), BaseVersionList::VersionRole); + if (value.toString() == version) { + found = i; + } + } + if (found == -1) { + return QModelIndex(); + } + return index(found, 0); +} + +void VersionProxyModel::clearFilters() +{ + m_filters.clear(); + m_search.clear(); + filterModel->filterChanged(); +} + +void VersionProxyModel::setFilter(const BaseVersionList::ModelRoles column, Filter f) +{ + m_filters[column] = std::move(f); + filterModel->filterChanged(); +} + +void VersionProxyModel::setSearch(const QString& search) +{ + m_search = search; + filterModel->filterChanged(); +} + +const VersionProxyModel::FilterMap& VersionProxyModel::filters() const +{ + return m_filters; +} + +const QString& VersionProxyModel::search() const +{ + return m_search; +} + +void VersionProxyModel::sourceAboutToBeReset() +{ + beginResetModel(); +} + +void VersionProxyModel::sourceReset() +{ + endResetModel(); +} + +void VersionProxyModel::sourceRowsAboutToBeInserted(const QModelIndex& parent, int first, int last) +{ + beginInsertRows(parent, first, last); +} + +void VersionProxyModel::sourceRowsInserted([[maybe_unused]] const QModelIndex& parent, + [[maybe_unused]] int first, + [[maybe_unused]] int last) +{ + endInsertRows(); +} + +void VersionProxyModel::sourceRowsAboutToBeRemoved(const QModelIndex& parent, int first, int last) +{ + beginRemoveRows(parent, first, last); +} + +void VersionProxyModel::sourceRowsRemoved([[maybe_unused]] const QModelIndex& parent, [[maybe_unused]] int first, [[maybe_unused]] int last) +{ + endRemoveRows(); +} + +void VersionProxyModel::setCurrentVersion(const QString& version) +{ + m_currentVersion = version; +} + +#include "VersionProxyModel.moc" diff --git a/launcher/VersionProxyModel.h b/launcher/VersionProxyModel.h new file mode 100644 index 0000000..ddd5d24 --- /dev/null +++ b/launcher/VersionProxyModel.h @@ -0,0 +1,59 @@ +#pragma once +#include +#include "BaseVersionList.h" + +#include + +class VersionFilterModel; + +class VersionProxyModel : public QAbstractProxyModel { + Q_OBJECT + public: + enum Column { Name, ParentVersion, Branch, Type, CPUArchitecture, Path, Time, JavaName, JavaMajor }; + using FilterMap = QHash; + + public: + VersionProxyModel(QObject* parent = 0); + virtual ~VersionProxyModel() {}; + + virtual int columnCount(const QModelIndex& parent = QModelIndex()) const override; + virtual int rowCount(const QModelIndex& parent = QModelIndex()) const override; + virtual QModelIndex mapFromSource(const QModelIndex& sourceIndex) const override; + virtual QModelIndex mapToSource(const QModelIndex& proxyIndex) const override; + virtual QModelIndex index(int row, int column, const QModelIndex& parent = QModelIndex()) const override; + virtual QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + virtual QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; + virtual QModelIndex parent(const QModelIndex& child) const override; + virtual void setSourceModel(QAbstractItemModel* sourceModel) override; + + const FilterMap& filters() const; + const QString& search() const; + void setFilter(BaseVersionList::ModelRoles column, Filter filter); + void setSearch(const QString& search); + void clearFilters(); + QModelIndex getRecommended() const; + QModelIndex getVersion(const QString& version) const; + void setCurrentVersion(const QString& version); + private slots: + + void sourceDataChanged(const QModelIndex& source_top_left, const QModelIndex& source_bottom_right); + + void sourceAboutToBeReset(); + void sourceReset(); + + void sourceRowsAboutToBeInserted(const QModelIndex& parent, int first, int last); + void sourceRowsInserted(const QModelIndex& parent, int first, int last); + + void sourceRowsAboutToBeRemoved(const QModelIndex& parent, int first, int last); + void sourceRowsRemoved(const QModelIndex& parent, int first, int last); + + private: + QList m_columns; + FilterMap m_filters; + QString m_search; + BaseVersionList::RoleList roles; + VersionFilterModel* filterModel; + bool hasRecommended = false; + bool hasLatest = false; + QString m_currentVersion; +}; diff --git a/launcher/WatchLock.h b/launcher/WatchLock.h new file mode 100644 index 0000000..ad74f25 --- /dev/null +++ b/launcher/WatchLock.h @@ -0,0 +1,15 @@ + +#pragma once + +#include +#include + +struct WatchLock { + WatchLock(QFileSystemWatcher* watcher, const QString& directory) : m_watcher(watcher), m_directory(directory) + { + m_watcher->removePath(m_directory); + } + ~WatchLock() { m_watcher->addPath(m_directory); } + QFileSystemWatcher* m_watcher; + QString m_directory; +}; diff --git a/launcher/archive/ArchiveReader.cpp b/launcher/archive/ArchiveReader.cpp new file mode 100644 index 0000000..764063d --- /dev/null +++ b/launcher/archive/ArchiveReader.cpp @@ -0,0 +1,297 @@ +// SPDX-License-Identifier: GPL-3.0-only AND LicenseRef-PublicDomain +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2025 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * Additional note: Portions of this file are released into the public domain + * under LicenseRef-PublicDomain. + */ +#include "ArchiveReader.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace MMCZip { +QStringList ArchiveReader::getFiles() +{ + return m_fileNames; +} + +bool ArchiveReader::collectFiles(bool onlyFiles) +{ + return parse([this, onlyFiles](File* f) { + if (!onlyFiles || f->isFile()) { + m_fileNames << f->filename(); + } + return f->skip(); + }); +} + +using getPathFunc = std::function; +static QString decodeLibArchivePath(archive_entry* entry, const getPathFunc& getUtf8Path, const getPathFunc& getPath) +{ + auto fileName = QString::fromUtf8(getUtf8Path(entry)); + if (fileName.isEmpty()) { + fileName = QString::fromLocal8Bit(getPath(entry)); + } + return fileName; +} + +QString ArchiveReader::File::filename() +{ + return decodeLibArchivePath(m_entry, archive_entry_pathname_utf8, archive_entry_pathname); +} + +QByteArray ArchiveReader::File::readAll(int* outStatus) +{ + QByteArray data; + const void* buff = nullptr; + size_t size = 0; + la_int64_t offset = 0; + + int status = 0; + while ((status = archive_read_data_block(m_archive.get(), &buff, &size, &offset)) == ARCHIVE_OK) { + data.append(static_cast(buff), static_cast(size)); + } + if (status != ARCHIVE_EOF && status != ARCHIVE_OK) { + qWarning() << "libarchive read error:" << archive_error_string(m_archive.get()); + } + if (outStatus) { + *outStatus = status; + } + return data; +} + +QDateTime ArchiveReader::File::dateTime() +{ + auto mtime = archive_entry_mtime(m_entry); + auto mtime_nsec = archive_entry_mtime_nsec(m_entry); + auto dt = QDateTime::fromSecsSinceEpoch(mtime); + return dt.addMSecs(mtime_nsec / 1e6); +} + +int ArchiveReader::File::readNextHeader() +{ + return archive_read_next_header(m_archive.get(), &m_entry); +} + +auto ArchiveReader::goToFile(const QString& filename) -> std::unique_ptr +{ + auto f = std::make_unique(); + auto* a = f->m_archive.get(); + archive_read_support_format_all(a); + archive_read_support_filter_all(a); + auto fileName = m_archivePath.toStdWString(); + if (archive_read_open_filename_w(a, fileName.data(), m_blockSize) != ARCHIVE_OK) { + qCritical() << "Failed to open archive file:" << m_archivePath << "-" << archive_error_string(a); + return nullptr; + } + + while (f->readNextHeader() == ARCHIVE_OK) { + if (f->filename() == filename) { + return f; + } + f->skip(); + } + + archive_read_close(a); + return nullptr; +} + +static int copy_data(struct archive* ar, struct archive* aw, bool notBlock = false) +{ + int r = 0; + const void* buff = nullptr; + size_t size = 0; + la_int64_t offset = 0; + + for (;;) { + r = archive_read_data_block(ar, &buff, &size, &offset); + if (r == ARCHIVE_EOF) { + return ARCHIVE_OK; + } + if (r < ARCHIVE_OK) { + qCritical() << "Failed reading data block:" << archive_error_string(ar); + return (r); + } + if (notBlock) { + r = archive_write_data(aw, buff, size); + } else { + r = archive_write_data_block(aw, buff, size, offset); + } + if (r < ARCHIVE_OK) { + qCritical() << "Failed writing data block:" << archive_error_string(aw); + return (r); + } + } +} + +static bool willEscapeRoot(const QDir& root, archive_entry* entry) +{ + auto entryPath = decodeLibArchivePath(entry, archive_entry_pathname_utf8, archive_entry_pathname); + auto linkTarget = decodeLibArchivePath(entry, archive_entry_symlink_utf8, archive_entry_symlink); + auto hardLink = decodeLibArchivePath(entry, archive_entry_hardlink_utf8, archive_entry_hardlink); + + if (entryPath.isEmpty() || (linkTarget.isEmpty() && hardLink.isEmpty())) { + return false; + } + + bool isHardLink = false; + if (isHardLink = linkTarget.isEmpty(); isHardLink) { + linkTarget = hardLink; + } + + QString linkFullPath = root.filePath(entryPath); + auto rootDir = QUrl::fromLocalFile(root.absolutePath()); + + if (!rootDir.isParentOf(QUrl::fromLocalFile(linkFullPath))) { + return true; + } + + QDir linkDir = QFileInfo(linkFullPath).dir(); + if (!QDir::isAbsolutePath(linkTarget)) { + linkTarget = (!isHardLink ? linkDir : root).filePath(linkTarget); + } + return !rootDir.isParentOf(QUrl::fromLocalFile(QDir::cleanPath(linkTarget))); +} + +bool ArchiveReader::File::writeFile(archive* out, const QString& targetFileName, bool notBlock) +{ + return writeFile(out, targetFileName, {}, notBlock); +}; + +bool ArchiveReader::File::writeFile(archive* out, const QString& targetFileName, std::optional root, bool notBlock) +{ + auto* entry = m_entry; + std::unique_ptr entryClone(nullptr, &archive_entry_free); + if (!targetFileName.isEmpty()) { + entryClone.reset(archive_entry_clone(m_entry)); + entry = entryClone.get(); + auto nameUtf8 = targetFileName.toUtf8(); + archive_entry_set_pathname_utf8(entry, nameUtf8.constData()); + } + if (root.has_value() && willEscapeRoot(root.value(), entry)) { + qCritical() << "Failed to write header to entry:" << filename() << "-" << "file outside root"; + return false; + } + if (archive_write_header(out, entry) < ARCHIVE_OK) { + qCritical() << "Failed to write header to entry:" << filename() << "-" << archive_error_string(out) << targetFileName; + return false; + } + if (archive_entry_size(m_entry) > 0) { + auto r = copy_data(m_archive.get(), out, notBlock); + if (r < ARCHIVE_OK) { + qCritical() << "Failed reading data block:" << archive_error_string(out); + } + if (r < ARCHIVE_WARN) { + return false; + } + } + auto r = archive_write_finish_entry(out); + if (r < ARCHIVE_OK) { + qCritical() << "Failed to finish writing entry:" << archive_error_string(out); + } + return (r >= ARCHIVE_WARN); +} + +bool ArchiveReader::parse(const std::function& doStuff) +{ + auto f = std::make_unique(); + auto* a = f->m_archive.get(); + archive_read_support_format_all(a); + archive_read_support_filter_all(a); + auto fileName = m_archivePath.toStdWString(); + if (archive_read_open_filename_w(a, fileName.data(), m_blockSize) != ARCHIVE_OK) { + qCritical() << "Failed to open archive file:" << m_archivePath << "-" << f->error(); + return false; + } + + bool breakControl = false; + while (f->readNextHeader() == ARCHIVE_OK) { + if (f && !doStuff(f.get(), breakControl)) { + qCritical() << "Failed to parse file:" << f->filename() << "-" << f->error(); + return false; + } + if (breakControl) { + break; + } + } + + archive_read_close(a); + return true; +} + +bool ArchiveReader::parse(const std::function& doStuff) +{ + return parse([doStuff](File* f, bool&) { return doStuff(f); }); +} + +bool ArchiveReader::File::isFile() +{ + return (archive_entry_filetype(m_entry) & AE_IFMT) == AE_IFREG; +} +bool ArchiveReader::File::skip() +{ + return archive_read_data_skip(m_archive.get()) == ARCHIVE_OK; +} +const char* ArchiveReader::File::error() +{ + return archive_error_string(m_archive.get()); +} +QString ArchiveReader::getZipName() +{ + return m_archivePath; +} + +bool ArchiveReader::exists(const QString& filePath) const +{ + if (filePath == QLatin1String("/") || filePath.isEmpty()) { + return true; + } + // Normalize input path (remove trailing slash, if any) + QString normalizedPath = QDir::cleanPath(filePath); + if (normalizedPath.startsWith('/')) { + normalizedPath.remove(0, 1); + } + if (normalizedPath == QLatin1String(".")) { + return true; + } + if (normalizedPath == QLatin1String("..")) { + return false; // root only + } + + // Check for exact file match + if (m_fileNames.contains(normalizedPath, Qt::CaseInsensitive)) { + return true; + } + + // Check for directory existence by seeing if any file starts with that path + QString dirPath = normalizedPath + QLatin1Char('/'); + for (const QString& f : m_fileNames) { + if (f.startsWith(dirPath, Qt::CaseInsensitive)) { + return true; + } + } + + return false; +} + +ArchiveReader::File::File() : m_archive(ArchivePtr(archive_read_new(), archive_read_free)) {} +} // namespace MMCZip diff --git a/launcher/archive/ArchiveReader.h b/launcher/archive/ArchiveReader.h new file mode 100644 index 0000000..4f11d2e --- /dev/null +++ b/launcher/archive/ArchiveReader.h @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2025 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +struct archive; +struct archive_entry; +namespace MMCZip { +class ArchiveReader { + public: + using ArchivePtr = std::unique_ptr; + explicit ArchiveReader(QString fileName) : m_archivePath(std::move(fileName)) {} + virtual ~ArchiveReader() = default; + + QStringList getFiles(); + QString getZipName(); + bool collectFiles(bool onlyFiles = true); + bool exists(const QString& filePath) const; + + class File { + public: + File(); + virtual ~File() = default; + + QString filename(); + bool isFile(); + QDateTime dateTime(); + const char* error(); + + QByteArray readAll(int* outStatus = nullptr); + bool skip(); + bool writeFile(archive* out, const QString& targetFileName = "", bool notBlock = false); + bool writeFile(archive* out, const QString& targetFileName, std::optional root, bool notBlock = false); + + private: + int readNextHeader(); + + private: + friend ArchiveReader; + ArchivePtr m_archive; + archive_entry* m_entry; + }; + + std::unique_ptr goToFile(const QString& filename); + bool parse(const std::function&); + bool parse(const std::function&); + + private: + QString m_archivePath; + size_t m_blockSize = 10240; + + QStringList m_fileNames; +}; +} // namespace MMCZip diff --git a/launcher/archive/ArchiveWriter.cpp b/launcher/archive/ArchiveWriter.cpp new file mode 100644 index 0000000..43dbe4d --- /dev/null +++ b/launcher/archive/ArchiveWriter.cpp @@ -0,0 +1,253 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2025 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +#include "ArchiveWriter.h" +#include +#include +#include + +#include +#include + +#include + +#if defined Q_OS_WIN32 +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +// clang-format off +#include +#include +// clang-format on +#endif + +namespace MMCZip { + +ArchiveWriter::ArchiveWriter(const QString& archiveName) : m_filename(archiveName) {} + +ArchiveWriter::~ArchiveWriter() +{ + close(); +} + +bool ArchiveWriter::open() +{ + if (m_filename.isEmpty()) { + qCritical() << "Archive m_filename not set."; + return false; + } + + m_archive = archive_write_new(); + if (!m_archive) { + qCritical() << "Archive not initialized."; + return false; + } + + auto format = m_format.toUtf8(); + archive_write_set_format_by_name(m_archive, format.constData()); + + if (archive_write_set_options(m_archive, "hdrcharset=UTF-8") != ARCHIVE_OK) { + qCritical() << "Failed to open archive file:" << m_filename << "-" << archive_error_string(m_archive); + return false; + } + + auto archiveNameW = m_filename.toStdWString(); + if (archive_write_open_filename_w(m_archive, archiveNameW.data()) != ARCHIVE_OK) { + qCritical() << "Failed to open archive file:" << m_filename << "-" << archive_error_string(m_archive); + return false; + } + + return true; +} + +bool ArchiveWriter::close() +{ + bool success = true; + if (m_archive) { + if (archive_write_close(m_archive) != ARCHIVE_OK) { + qCritical() << "Failed to close archive" << m_filename << "-" << archive_error_string(m_archive); + success = false; + } + if (archive_write_free(m_archive) != ARCHIVE_OK) { + qCritical() << "Failed to free archive" << m_filename << "-" << archive_error_string(m_archive); + success = false; + } + m_archive = nullptr; + } + return success; +} + +bool ArchiveWriter::addFile(const QString& fileName, const QString& fileDest) +{ + QFileInfo fileInfo(fileName); + if (!fileInfo.exists()) { + qCritical() << "File does not exist:" << fileInfo.filePath(); + return false; + } + + std::unique_ptr entry_ptr(archive_entry_new(), archive_entry_free); + auto entry = entry_ptr.get(); + if (!entry) { + qCritical() << "Failed to create archive entry"; + return false; + } + + auto fileDestUtf8 = fileDest.toUtf8(); + archive_entry_set_pathname_utf8(entry, fileDestUtf8.constData()); + +#if defined Q_OS_WIN32 + { + // Windows needs to use this method, thanks I hate it. + + auto widePath = fileInfo.absoluteFilePath().toStdWString(); + HANDLE file_handle = CreateFileW(widePath.data(), GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); + if (file_handle == INVALID_HANDLE_VALUE) { + qCritical() << "Failed to stat file:" << fileInfo.filePath(); + return false; + } + + BY_HANDLE_FILE_INFORMATION file_info; + if (!GetFileInformationByHandle(file_handle, &file_info)) { + qCritical() << "Failed to stat file:" << fileInfo.filePath(); + CloseHandle(file_handle); + return false; + } + + archive_entry_copy_bhfi(entry, &file_info); + CloseHandle(file_handle); + } +#else + { + // this only works for multibyte encoded filenames if the local is properly set, + // a wide character version doesn't seem to exist: here's hoping... + + QByteArray utf8 = fileInfo.absoluteFilePath().toUtf8(); + const char* cpath = utf8.constData(); + struct stat st; + if (stat(cpath, &st) != 0) { + qCritical() << "Failed to stat file:" << fileInfo.filePath(); + return false; + } + + // This should handle the copying of most attributes + archive_entry_copy_stat(entry, &st); + } +#endif + + // However: + // "The [filetype] constants used by stat(2) may have different numeric values from the corresponding [libarchive constants]." + // - `archive_entry_stat(3)` + if (fileInfo.isSymLink()) { + archive_entry_set_filetype(entry, AE_IFLNK); + + // We also need to manually copy some attributes from the link itself, as `stat` above operates on its target + auto target = fileInfo.symLinkTarget().toUtf8(); + archive_entry_set_symlink_utf8(entry, target.constData()); + archive_entry_set_size(entry, 0); + archive_entry_set_perm(entry, fileInfo.permissions()); + } else if (fileInfo.isFile()) { + archive_entry_set_filetype(entry, AE_IFREG); + } else { + qCritical() << "Unsupported file type:" << fileInfo.filePath(); + return false; + } + + if (archive_write_header(m_archive, entry) != ARCHIVE_OK) { + qCritical() << "Failed to write header for:" << fileDest << "-" << archive_error_string(m_archive); + return false; + } + + if (fileInfo.isFile() && !fileInfo.isSymLink()) { + QFile file(fileInfo.absoluteFilePath()); + if (!file.open(QIODevice::ReadOnly)) { + qCritical() << "Failed to open file:" << fileInfo.filePath() << "error:" << file.errorString(); + return false; + } + + constexpr qint64 chunkSize = 8192; + QByteArray buffer; + buffer.resize(chunkSize); + + while (!file.atEnd()) { + auto bytesRead = file.read(buffer.data(), chunkSize); + if (bytesRead < 0) { + qCritical() << "Read error in file:" << fileInfo.filePath(); + return false; + } + + if (archive_write_data(m_archive, buffer.constData(), bytesRead) < 0) { + qCritical() << "Write error in archive for:" << fileDest; + return false; + } + } + } + + return true; +} + +bool ArchiveWriter::addFile(const QString& fileDest, const QByteArray& data) +{ + std::unique_ptr entry_ptr(archive_entry_new(), archive_entry_free); + auto entry = entry_ptr.get(); + if (!entry) { + qCritical() << "Failed to create archive entry"; + return false; + } + + auto fileDestUtf8 = fileDest.toUtf8(); + archive_entry_set_pathname_utf8(entry, fileDestUtf8.constData()); + archive_entry_set_perm(entry, 0644); + + archive_entry_set_filetype(entry, AE_IFREG); + archive_entry_set_size(entry, data.size()); + + if (archive_write_header(m_archive, entry) != ARCHIVE_OK) { + qCritical() << "Failed to write header for:" << fileDest << "-" << archive_error_string(m_archive); + return false; + } + + if (archive_write_data(m_archive, data.constData(), data.size()) < 0) { + qCritical() << "Write error in archive for:" << fileDest << "-" << archive_error_string(m_archive); + return false; + } + return true; +} + +bool ArchiveWriter::addFile(ArchiveReader::File* f) +{ + return f->writeFile(m_archive, "", true); +} + +std::unique_ptr ArchiveWriter::createDiskWriter() +{ + int flags = ARCHIVE_EXTRACT_TIME | ARCHIVE_EXTRACT_PERM | ARCHIVE_EXTRACT_ACL | ARCHIVE_EXTRACT_FFLAGS | + ARCHIVE_EXTRACT_SECURE_NODOTDOT | ARCHIVE_EXTRACT_SECURE_SYMLINKS; + + std::unique_ptr extPtr(archive_write_disk_new(), [](archive* a) { + if (a) { + archive_write_close(a); + archive_write_free(a); + } + }); + + archive* ext = extPtr.get(); + archive_write_disk_set_options(ext, flags); + archive_write_disk_set_standard_lookup(ext); + + return extPtr; +} +} // namespace MMCZip diff --git a/launcher/archive/ArchiveWriter.h b/launcher/archive/ArchiveWriter.h new file mode 100644 index 0000000..50858b5 --- /dev/null +++ b/launcher/archive/ArchiveWriter.h @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2025 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +#pragma once + +#include +#include +#include "archive/ArchiveReader.h" + +struct archive; +namespace MMCZip { + +class ArchiveWriter { + public: + ArchiveWriter(const QString& archiveName); + virtual ~ArchiveWriter(); + + bool open(); + bool close(); + + bool addFile(const QString& fileName, const QString& fileDest); + bool addFile(const QString& fileDest, const QByteArray& data); + bool addFile(ArchiveReader::File* f); + + static std::unique_ptr createDiskWriter(); + + private: + struct archive* m_archive = nullptr; + QString m_filename; + QString m_format = "zip"; +}; +} // namespace MMCZip diff --git a/launcher/archive/ExportToZipTask.cpp b/launcher/archive/ExportToZipTask.cpp new file mode 100644 index 0000000..bd3bc90 --- /dev/null +++ b/launcher/archive/ExportToZipTask.cpp @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2025 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +#include "ExportToZipTask.h" + +#include + +#include "FileSystem.h" + +namespace MMCZip { +void ExportToZipTask::executeTask() +{ + setStatus("Adding files..."); + setProgress(0, m_files.length()); + m_buildZipFuture = QtConcurrent::run(QThreadPool::globalInstance(), [this]() { return exportZip(); }); + connect(&m_buildZipWatcher, &QFutureWatcher::finished, this, &ExportToZipTask::finish); + m_buildZipWatcher.setFuture(m_buildZipFuture); +} + +auto ExportToZipTask::exportZip() -> ZipResult +{ + if (!m_dir.exists()) { + return ZipResult(tr("Folder doesn't exist")); + } + if (!m_output.open()) { + return ZipResult(tr("Could not create file")); + } + + for (auto fileName : m_extraFiles.keys()) { + if (m_buildZipFuture.isCanceled()) + return ZipResult(); + if (!m_output.addFile(fileName, m_extraFiles[fileName])) { + return ZipResult(tr("Could not add:") + fileName); + } + } + + for (const QFileInfo& file : m_files) { + if (m_buildZipFuture.isCanceled()) + return ZipResult(); + + auto absolute = file.absoluteFilePath(); + auto relative = m_dir.relativeFilePath(absolute); + setStatus("Compressing: " + relative); + setProgress(m_progress + 1, m_progressTotal); + if (m_followSymlinks) { + if (file.isSymLink()) + absolute = file.symLinkTarget(); + else + absolute = file.canonicalFilePath(); + } + + if (!m_excludeFiles.contains(relative) && !m_output.addFile(absolute, m_destinationPrefix + relative)) { + return ZipResult(tr("Could not read and compress %1").arg(relative)); + } + } + + if (!m_output.close()) { + return ZipResult(tr("A zip error occurred")); + } + return ZipResult(); +} + +void ExportToZipTask::finish() +{ + if (m_buildZipFuture.isCanceled()) { + FS::deletePath(m_outputPath); + emitAborted(); + } else if (auto result = m_buildZipFuture.result(); result.has_value()) { + FS::deletePath(m_outputPath); + emitFailed(result.value()); + } else { + emitSucceeded(); + } +} + +bool ExportToZipTask::abort() +{ + if (m_buildZipFuture.isRunning()) { + m_buildZipFuture.cancel(); + // NOTE: Here we don't do `emitAborted()` because it will be done when `m_build_zip_future` actually cancels, which may not occur + // immediately. + return true; + } + return false; +} +} // namespace MMCZip diff --git a/launcher/archive/ExportToZipTask.h b/launcher/archive/ExportToZipTask.h new file mode 100644 index 0000000..0c8329c --- /dev/null +++ b/launcher/archive/ExportToZipTask.h @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2025 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +#pragma once + +#include +#include +#include +#include + +#include "archive/ArchiveWriter.h" +#include "tasks/Task.h" + +namespace MMCZip { +class ExportToZipTask : public Task { + Q_OBJECT + public: + ExportToZipTask(QString outputPath, QDir dir, QFileInfoList files, QString destinationPrefix = "", bool followSymlinks = false) + : m_outputPath(outputPath) + , m_output(outputPath) + , m_dir(dir) + , m_files(files) + , m_destinationPrefix(destinationPrefix) + , m_followSymlinks(followSymlinks) + { + setAbortable(true); + }; + ExportToZipTask(QString outputPath, QString dir, QFileInfoList files, QString destinationPrefix = "", bool followSymlinks = false) + : ExportToZipTask(outputPath, QDir(dir), files, destinationPrefix, followSymlinks) {}; + + virtual ~ExportToZipTask() = default; + + void setExcludeFiles(QStringList excludeFiles) { m_excludeFiles = excludeFiles; } + void addExtraFile(QString fileName, QByteArray data) { m_extraFiles.insert(fileName, data); } + + using ZipResult = std::optional; + + protected: + virtual void executeTask() override; + bool abort() override; + + ZipResult exportZip(); + void finish(); + + private: + QString m_outputPath; + ArchiveWriter m_output; + QDir m_dir; + QFileInfoList m_files; + QString m_destinationPrefix; + bool m_followSymlinks; + QStringList m_excludeFiles; + QHash m_extraFiles; + + QFuture m_buildZipFuture; + QFutureWatcher m_buildZipWatcher; +}; +} // namespace MMCZip diff --git a/launcher/archive/ExtractZipTask.cpp b/launcher/archive/ExtractZipTask.cpp new file mode 100644 index 0000000..35dc39d --- /dev/null +++ b/launcher/archive/ExtractZipTask.cpp @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2025 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +#include "ExtractZipTask.h" +#include +#include "FileSystem.h" +#include "archive/ArchiveReader.h" +#include "archive/ArchiveWriter.h" + +namespace MMCZip { + +void ExtractZipTask::executeTask() +{ + m_zipFuture = QtConcurrent::run(QThreadPool::globalInstance(), [this]() { return extractZip(); }); + connect(&m_zipWatcher, &QFutureWatcher::finished, this, &ExtractZipTask::finish); + m_zipWatcher.setFuture(m_zipFuture); +} + +auto ExtractZipTask::extractZip() -> ZipResult +{ + auto target = m_outputDir.absolutePath(); + auto target_top_dir = QUrl::fromLocalFile(target); + + QStringList extracted; + + qDebug() << "Extracting subdir" << m_subdirectory << "from" << m_input.getZipName() << "to" << target; + if (!m_input.collectFiles()) { + return ZipResult(tr("Failed to enumerate files in archive")); + } + if (m_input.getFiles().isEmpty()) { + logWarning(tr("Extracting empty archives seems odd...")); + return ZipResult(); + } + + auto extPtr = ArchiveWriter::createDiskWriter(); + auto ext = extPtr.get(); + + setStatus("Extracting files..."); + setProgress(0, m_input.getFiles().count()); + ZipResult result; + auto fileName = m_input.getZipName(); + if (!m_input.parse([this, &result, &target, &target_top_dir, ext, &extracted](ArchiveReader::File* f) { + if (m_zipFuture.isCanceled()) + return false; + setProgress(m_progress + 1, m_progressTotal); + QString file_name = f->filename(); + if (!file_name.startsWith(m_subdirectory)) { + f->skip(); + return true; + } + + auto relative_file_name = QDir::fromNativeSeparators(file_name.mid(m_subdirectory.size())); + auto original_name = relative_file_name; + setStatus("Unpacking: " + relative_file_name); + + // Fix subdirs/files ending with a / getting transformed into absolute paths + if (relative_file_name.startsWith('/')) + relative_file_name = relative_file_name.mid(1); + + // Fix weird "folders with a single file get squashed" thing + QString sub_path; + if (relative_file_name.contains('/') && !relative_file_name.endsWith('/')) { + sub_path = relative_file_name.section('/', 0, -2) + '/'; + FS::ensureFolderPathExists(FS::PathCombine(target, sub_path)); + + relative_file_name = relative_file_name.split('/').last(); + } + + QString target_file_path; + if (relative_file_name.isEmpty()) { + target_file_path = target + '/'; + } else { + target_file_path = FS::PathCombine(target_top_dir.toLocalFile(), sub_path, relative_file_name); + if (relative_file_name.endsWith('/') && !target_file_path.endsWith('/')) + target_file_path += '/'; + } + + if (!target_top_dir.isParentOf(QUrl::fromLocalFile(target_file_path))) { + result = ZipResult(tr("Extracting %1 was cancelled, because it was effectively outside of the target path %2") + .arg(relative_file_name, target)); + return false; + } + + if (!f->writeFile(ext, target_file_path, target)) { + result = ZipResult(tr("Failed to extract file %1 to %2").arg(original_name, target_file_path)); + return false; + } + extracted.append(target_file_path); + + qDebug() << "Extracted file" << relative_file_name << "to" << target_file_path; + return true; + })) { + FS::removeFiles(extracted); + return result.has_value() ? result : ZipResult(tr("Failed to parse file %1").arg(fileName)); + } + return ZipResult(); +} + +void ExtractZipTask::finish() +{ + if (m_zipFuture.isCanceled()) { + emitAborted(); + } else if (auto result = m_zipFuture.result(); result.has_value()) { + emitFailed(result.value()); + } else { + emitSucceeded(); + } +} + +bool ExtractZipTask::abort() +{ + if (m_zipFuture.isRunning()) { + m_zipFuture.cancel(); + // NOTE: Here we don't do `emitAborted()` because it will be done when `m_build_zip_future` actually cancels, which may not occur + // immediately. + return true; + } + return false; +} + +} // namespace MMCZip diff --git a/launcher/archive/ExtractZipTask.h b/launcher/archive/ExtractZipTask.h new file mode 100644 index 0000000..03c391a --- /dev/null +++ b/launcher/archive/ExtractZipTask.h @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2025 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +#pragma once + +#include +#include +#include +#include "archive/ArchiveReader.h" +#include "tasks/Task.h" + +namespace MMCZip { + +class ExtractZipTask : public Task { + Q_OBJECT + public: + ExtractZipTask(QString input, QDir outputDir, QString subdirectory = "") + : m_input(input), m_outputDir(outputDir), m_subdirectory(subdirectory) + {} + virtual ~ExtractZipTask() = default; + + using ZipResult = std::optional; + + protected: + virtual void executeTask() override; + bool abort() override; + + ZipResult extractZip(); + void finish(); + + private: + ArchiveReader m_input; + QDir m_outputDir; + QString m_subdirectory; + + QFuture m_zipFuture; + QFutureWatcher m_zipWatcher; +}; +} // namespace MMCZip diff --git a/launcher/console/Console.h b/launcher/console/Console.h new file mode 100644 index 0000000..7aaf83d --- /dev/null +++ b/launcher/console/Console.h @@ -0,0 +1,33 @@ +#pragma once + +#include + +#include +#if defined Q_OS_WIN32 +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#else +#include +#include +#endif + +namespace console { + +inline bool isConsole() +{ +#if defined Q_OS_WIN32 + DWORD procIDs[2]; + DWORD maxCount = 2; + DWORD result = GetConsoleProcessList((LPDWORD)procIDs, maxCount); + return result > 1; +#else + if (isatty(fileno(stdout))) { + return true; + } + return false; +#endif +} + +} // namespace console diff --git a/launcher/console/WindowsConsole.cpp b/launcher/console/WindowsConsole.cpp new file mode 100644 index 0000000..e121836 --- /dev/null +++ b/launcher/console/WindowsConsole.cpp @@ -0,0 +1,191 @@ +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "WindowsConsole.h" +#include + +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace console { + +void RedirectHandle(DWORD handle, FILE* stream, const char* mode) +{ + HANDLE stdHandle = GetStdHandle(handle); + if (stdHandle != INVALID_HANDLE_VALUE) { + int fileDescriptor = _open_osfhandle((intptr_t)stdHandle, _O_TEXT); + if (fileDescriptor != -1) { + FILE* file = _fdopen(fileDescriptor, mode); + if (file != NULL) { + int dup2Result = _dup2(_fileno(file), _fileno(stream)); + if (dup2Result == 0) { + setvbuf(stream, NULL, _IONBF, 0); + } + } + } + } +} + +// taken from https://stackoverflow.com/a/25927081 +// getting a proper output to console with redirection support on windows is apparently hell +void BindCrtHandlesToStdHandles(bool bindStdIn, bool bindStdOut, bool bindStdErr) +{ + // Re-initialize the C runtime "FILE" handles with clean handles bound to "nul". We do this because it has been + // observed that the file number of our standard handle file objects can be assigned internally to a value of -2 + // when not bound to a valid target, which represents some kind of unknown internal invalid state. In this state our + // call to "_dup2" fails, as it specifically tests to ensure that the target file number isn't equal to this value + // before allowing the operation to continue. We can resolve this issue by first "re-opening" the target files to + // use the "nul" device, which will place them into a valid state, after which we can redirect them to our target + // using the "_dup2" function. + if (bindStdIn) { + FILE* dummyFile; + freopen_s(&dummyFile, "nul", "r", stdin); + } + if (bindStdOut) { + FILE* dummyFile; + freopen_s(&dummyFile, "nul", "w", stdout); + } + if (bindStdErr) { + FILE* dummyFile; + freopen_s(&dummyFile, "nul", "w", stderr); + } + + // Redirect unbuffered stdin from the current standard input handle + if (bindStdIn) { + RedirectHandle(STD_INPUT_HANDLE, stdin, "r"); + } + + // Redirect unbuffered stdout to the current standard output handle + if (bindStdOut) { + RedirectHandle(STD_OUTPUT_HANDLE, stdout, "w"); + } + + // Redirect unbuffered stderr to the current standard error handle + if (bindStdErr) { + RedirectHandle(STD_ERROR_HANDLE, stderr, "w"); + } + + // Clear the error state for each of the C++ standard stream objects. We need to do this, as attempts to access the + // standard streams before they refer to a valid target will cause the iostream objects to enter an error state. In + // versions of Visual Studio after 2005, this seems to always occur during startup regardless of whether anything + // has been read from or written to the targets or not. + if (bindStdIn) { + std::wcin.clear(); + std::cin.clear(); + } + if (bindStdOut) { + std::wcout.clear(); + std::cout.clear(); + } + if (bindStdErr) { + std::wcerr.clear(); + std::cerr.clear(); + } +} + +bool AttachWindowsConsole() +{ + auto stdinType = GetFileType(GetStdHandle(STD_INPUT_HANDLE)); + auto stdoutType = GetFileType(GetStdHandle(STD_OUTPUT_HANDLE)); + auto stderrType = GetFileType(GetStdHandle(STD_ERROR_HANDLE)); + + bool bindStdIn = false; + bool bindStdOut = false; + bool bindStdErr = false; + + if (stdinType == FILE_TYPE_CHAR || stdinType == FILE_TYPE_UNKNOWN) { + bindStdIn = true; + } + if (stdoutType == FILE_TYPE_CHAR || stdoutType == FILE_TYPE_UNKNOWN) { + bindStdOut = true; + } + if (stderrType == FILE_TYPE_CHAR || stderrType == FILE_TYPE_UNKNOWN) { + bindStdErr = true; + } + + if (AttachConsole(ATTACH_PARENT_PROCESS)) { + BindCrtHandlesToStdHandles(bindStdIn, bindStdOut, bindStdErr); + return true; + } + + return false; +} + +std::error_code EnableAnsiSupport() +{ + // ref: https://docs.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilew + // Using `CreateFileW("CONOUT$", ...)` to retrieve the console handle works correctly even if STDOUT and/or STDERR are redirected + HANDLE console_handle = CreateFileW(L"CONOUT$", FILE_GENERIC_READ | FILE_GENERIC_WRITE, FILE_SHARE_WRITE, NULL, OPEN_EXISTING, 0, 0); + if (console_handle == INVALID_HANDLE_VALUE) { + return std::error_code(GetLastError(), std::system_category()); + } + + // ref: https://docs.microsoft.com/en-us/windows/console/getconsolemode + DWORD console_mode; + if (0 == GetConsoleMode(console_handle, &console_mode)) { + return std::error_code(GetLastError(), std::system_category()); + } + + // VT processing not already enabled? + if ((console_mode & ENABLE_VIRTUAL_TERMINAL_PROCESSING) == 0) { + // https://docs.microsoft.com/en-us/windows/console/setconsolemode + if (0 == SetConsoleMode(console_handle, console_mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING)) { + return std::error_code(GetLastError(), std::system_category()); + } + } + + return {}; +} + +void FreeWindowsConsole() +{ + fclose(stdout); + fclose(stdin); + fclose(stderr); + FreeConsole(); +} + +WindowsConsoleGuard::WindowsConsoleGuard() : m_consoleAttached(false) +{ + if (console::AttachWindowsConsole()) { + m_consoleAttached = true; + if (auto err = console::EnableAnsiSupport(); err) { + std::cout << "Error setting up ansi console" << err.message() << std::endl; + } + } +} + +WindowsConsoleGuard::~WindowsConsoleGuard() +{ + // Detach from Windows console + if (m_consoleAttached) { + console::FreeWindowsConsole(); + } +} + +} // namespace console diff --git a/launcher/console/WindowsConsole.h b/launcher/console/WindowsConsole.h new file mode 100644 index 0000000..5210221 --- /dev/null +++ b/launcher/console/WindowsConsole.h @@ -0,0 +1,44 @@ +// +// SPDX-License-Identifier: GPL-3.0-only + +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#pragma once + +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif + +#include +namespace console { +void BindCrtHandlesToStdHandles(bool bindStdIn, bool bindStdOut, bool bindStdErr); +bool AttachWindowsConsole(); +std::error_code EnableAnsiSupport(); +void FreeWindowsConsole(); + +class WindowsConsoleGuard { + public: + WindowsConsoleGuard(); + ~WindowsConsoleGuard(); + + private: + bool m_consoleAttached; +}; + +} // namespace console diff --git a/launcher/filelink/FileLink.cpp b/launcher/filelink/FileLink.cpp new file mode 100644 index 0000000..d87a107 --- /dev/null +++ b/launcher/filelink/FileLink.cpp @@ -0,0 +1,224 @@ +// SPDX-FileCopyrightText: 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> +// +// SPDX-License-Identifier: GPL-3.0-only + +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "FileLink.h" +#include "BuildConfig.h" + +#include "StringUtils.h" + +#include + +#include +#include + +#include + +#include + +#include +namespace fs = std::filesystem; + +FileLinkApp::FileLinkApp(int& argc, char** argv) : QCoreApplication(argc, argv), socket(new QLocalSocket(this)) +{ + setOrganizationName(BuildConfig.LAUNCHER_NAME); + setOrganizationDomain(BuildConfig.LAUNCHER_DOMAIN); + setApplicationName(BuildConfig.LAUNCHER_NAME + "FileLink"); + setApplicationVersion(BuildConfig.printableVersionString() + "\n" + BuildConfig.GIT_COMMIT); + + // Commandline parsing + QCommandLineParser parser; + parser.setApplicationDescription(QObject::tr("a batch MKLINK program for windows to be used with prismlauncher")); + + parser.addOptions({ { { "s", "server" }, "Join the specified server on launch", "pipe name" }, + { { "H", "hard" }, "use hard links instead of symbolic", "true/false" } }); + parser.addHelpOption(); + parser.addVersionOption(); + + parser.process(arguments()); + + QString serverToJoin = parser.value("server"); + m_useHardLinks = QVariant(parser.value("hard")).toBool(); + + qDebug() << "link program launched"; + + if (!serverToJoin.isEmpty()) { + qDebug() << "joining server" << serverToJoin; + joinServer(serverToJoin); + } else { + qDebug() << "no server to join"; + m_status = Failed; + exit(); + } +} + +void FileLinkApp::joinServer(QString server) +{ + blockSize = 0; + + in.setDevice(&socket); + + connect(&socket, &QLocalSocket::connected, this, []() { qDebug() << "connected to server"; }); + + connect(&socket, &QLocalSocket::readyRead, this, &FileLinkApp::readPathPairs); + + connect(&socket, &QLocalSocket::errorOccurred, this, [this](QLocalSocket::LocalSocketError socketError) { + m_status = Failed; + switch (socketError) { + case QLocalSocket::ServerNotFoundError: + qDebug() + << ("The host was not found. Please make sure " + "that the server is running and that the " + "server name is correct."); + break; + case QLocalSocket::ConnectionRefusedError: + qDebug() + << ("The connection was refused by the peer. " + "Make sure the server is running, " + "and check that the server name " + "is correct."); + break; + case QLocalSocket::PeerClosedError: + qDebug() << ("The connection was closed by the peer. "); + break; + default: + qDebug() << "The following error occurred:" << socket.errorString(); + } + }); + + connect(&socket, &QLocalSocket::disconnected, this, [this]() { + qDebug() << "disconnected from server, should exit"; + m_status = Succeeded; + exit(); + }); + + socket.connectToServer(server); +} + +void FileLinkApp::runLink() +{ + std::error_code os_err; + + qDebug() << "creating links"; + + for (auto link : m_links_to_make) { + QString src_path = link.src; + QString dst_path = link.dst; + + FS::ensureFilePathExists(dst_path); + if (m_useHardLinks) { + qDebug() << "making hard link:" << src_path << "to" << dst_path; + fs::create_hard_link(StringUtils::toStdString(src_path), StringUtils::toStdString(dst_path), os_err); + } else if (fs::is_directory(StringUtils::toStdString(src_path))) { + qDebug() << "making directory_symlink:" << src_path << "to" << dst_path; + fs::create_directory_symlink(StringUtils::toStdString(src_path), StringUtils::toStdString(dst_path), os_err); + } else { + qDebug() << "making symlink:" << src_path << "to" << dst_path; + fs::create_symlink(StringUtils::toStdString(src_path), StringUtils::toStdString(dst_path), os_err); + } + + if (os_err) { + qWarning() << "Failed to link files:" << QString::fromStdString(os_err.message()); + qDebug() << "Source file:" << src_path; + qDebug() << "Destination file:" << dst_path; + qDebug() << "Error category:" << os_err.category().name(); + qDebug() << "Error code:" << os_err.value(); + + FS::LinkResult result = { src_path, dst_path, QString::fromStdString(os_err.message()), os_err.value() }; + m_path_results.append(result); + } else { + FS::LinkResult result = { src_path, dst_path, "", 0 }; + m_path_results.append(result); + } + } + + sendResults(); + qDebug() << "done, should exit soon"; +} + +void FileLinkApp::sendResults() +{ + // construct block of data to send + QByteArray block; + QDataStream out(&block, QIODevice::WriteOnly); + + qint32 blocksize = quint32(sizeof(quint32)); + for (auto result : m_path_results) { + blocksize += quint32(result.src.size()); + blocksize += quint32(result.dst.size()); + blocksize += quint32(result.err_msg.size()); + blocksize += quint32(sizeof(quint32)); + } + qDebug() << "About to write block of size:" << blocksize; + out << blocksize; + + out << quint32(m_path_results.length()); + for (auto result : m_path_results) { + out << result.src; + out << result.dst; + out << result.err_msg; + out << quint32(result.err_value); + } + + qint64 byteswritten = socket.write(block); + bool bytesflushed = socket.flush(); + qDebug() << "block flushed" << byteswritten << bytesflushed; +} + +void FileLinkApp::readPathPairs() +{ + m_links_to_make.clear(); + qDebug() << "Reading path pairs from server"; + qDebug() << "bytes available" << socket.bytesAvailable(); + if (blockSize == 0) { + // Relies on the fact that QDataStream serializes a quint32 into + // sizeof(quint32) bytes + if (socket.bytesAvailable() < (int)sizeof(quint32)) + return; + qDebug() << "reading block size"; + in >> blockSize; + } + qDebug() << "blocksize is" << blockSize; + qDebug() << "bytes available" << socket.bytesAvailable(); + if (socket.bytesAvailable() < blockSize || in.atEnd()) + return; + + quint32 numLinks; + in >> numLinks; + qDebug() << "numLinks" << numLinks; + + for (quint32 i = 0; i < numLinks; i++) { + FS::LinkPair pair; + in >> pair.src; + in >> pair.dst; + qDebug() << "link" << pair.src << "to" << pair.dst; + m_links_to_make.append(pair); + } + + runLink(); +} + +FileLinkApp::~FileLinkApp() +{ + qDebug() << "link program shutting down"; + // Shut down logger by setting the logger function to nothing + qInstallMessageHandler(nullptr); +} diff --git a/launcher/filelink/FileLink.h b/launcher/filelink/FileLink.h new file mode 100644 index 0000000..25fdb71 --- /dev/null +++ b/launcher/filelink/FileLink.h @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> +// +// SPDX-License-Identifier: GPL-3.0-only + +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define PRISM_EXTERNAL_EXE +#include "FileSystem.h" + +class FileLinkApp : public QCoreApplication { + Q_OBJECT + public: + enum Status { Starting, Failed, Succeeded, Initialized }; + FileLinkApp(int& argc, char** argv); + virtual ~FileLinkApp(); + Status status() const { return m_status; } + + private: + void joinServer(QString server); + void readPathPairs(); + void runLink(); + void sendResults(); + + Status m_status = Status::Starting; + + bool m_useHardLinks = false; + + QDateTime m_startTime; + QLocalSocket socket; + QDataStream in; + quint32 blockSize; + + QList m_links_to_make; + QList m_path_results; + +}; diff --git a/launcher/filelink/filelink.exe.manifest b/launcher/filelink/filelink.exe.manifest new file mode 100644 index 0000000..239aa97 --- /dev/null +++ b/launcher/filelink/filelink.exe.manifest @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/filelink/filelink_main.cpp b/launcher/filelink/filelink_main.cpp new file mode 100644 index 0000000..d348443 --- /dev/null +++ b/launcher/filelink/filelink_main.cpp @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> +// +// SPDX-License-Identifier: GPL-3.0-only + +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "FileLink.h" + +#if defined Q_OS_WIN32 +#include "console/WindowsConsole.h" +#endif + +int main(int argc, char* argv[]) +{ +#if defined Q_OS_WIN32 + // attach the parent console + console::WindowsConsoleGuard _consoleGuard; +#endif + + FileLinkApp ldh(argc, argv); + + switch (ldh.status()) { + case FileLinkApp::Starting: + case FileLinkApp::Initialized: { + return ldh.exec(); + } + case FileLinkApp::Failed: + return 1; + case FileLinkApp::Succeeded: + return 0; + default: + return -1; + } +} diff --git a/launcher/icons/IconList.cpp b/launcher/icons/IconList.cpp new file mode 100644 index 0000000..ad48665 --- /dev/null +++ b/launcher/icons/IconList.cpp @@ -0,0 +1,490 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "IconList.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include "icons/IconUtils.h" + +#define MAX_SIZE 1024 + +IconList::IconList(const QStringList& builtinPaths, const QString& path, QObject* parent) : QAbstractListModel(parent) +{ + QSet builtinNames; + + // add builtin icons + for (const auto& builtinPath : builtinPaths) { + QDir instanceIcons(builtinPath); + auto fileInfoList = instanceIcons.entryInfoList(QDir::Files, QDir::Name); + for (const auto& fileInfo : fileInfoList) { + builtinNames.insert(fileInfo.completeBaseName()); + } + } + for (const auto& builtinName : builtinNames) { + addThemeIcon(builtinName); + } + + m_watcher.reset(new QFileSystemWatcher()); + m_isWatching = false; + connect(m_watcher.get(), &QFileSystemWatcher::directoryChanged, this, &IconList::directoryChanged); + connect(m_watcher.get(), &QFileSystemWatcher::fileChanged, this, &IconList::fileChanged); + + directoryChanged(path); + + // Forces the UI to update, so that lengthy icon names are shown properly from the start + emit iconUpdated({}); +} + +void IconList::sortIconList() +{ + qDebug() << "Sorting icon list..."; + std::sort(m_icons.begin(), m_icons.end(), [](const MMCIcon& a, const MMCIcon& b) { + bool aIsSubdir = a.m_key.contains(QDir::separator()); + bool bIsSubdir = b.m_key.contains(QDir::separator()); + if (aIsSubdir != bIsSubdir) { + return !aIsSubdir; // root-level icons come first + } + return a.m_key.localeAwareCompare(b.m_key) < 0; + }); + reindex(); +} + +// Helper function to add directories recursively +bool IconList::addPathRecursively(const QString& path) +{ + QDir dir(path); + if (!dir.exists()) + return false; + + // Add the directory itself + bool watching = m_watcher->addPath(path); + + // Add all subdirectories + QFileInfoList entries = dir.entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot); + for (const QFileInfo& entry : entries) { + if (addPathRecursively(entry.absoluteFilePath())) { + watching = true; + } + } + return watching; +} + +QStringList IconList::getIconFilePaths() const +{ + QStringList iconFiles{}; + QStringList directories{ m_dir.absolutePath() }; + while (!directories.isEmpty()) { + QString first = directories.takeFirst(); + QDir dir(first); + for (QFileInfo& fileInfo : dir.entryInfoList(QDir::AllDirs | QDir::Files | QDir::NoDotAndDotDot, QDir::Name)) { + if (fileInfo.isDir()) + directories.push_back(fileInfo.absoluteFilePath()); + else + iconFiles.push_back(fileInfo.absoluteFilePath()); + } + } + return iconFiles; +} + +QString formatName(const QDir& iconsDir, const QFileInfo& iconFile) +{ + if (iconFile.dir() == iconsDir) + return iconFile.completeBaseName(); + + constexpr auto delimiter = " » "; + QString relativePathWithoutExtension = + iconsDir.relativeFilePath(iconFile.dir().path()) + QDir::separator() + iconFile.completeBaseName(); + return relativePathWithoutExtension.replace(QDir::separator(), delimiter); +} + +/// Split into a separate function because the preprocessing impedes readability +QSet toStringSet(const QList& list) +{ + QSet set(list.begin(), list.end()); + return set; +} + +void IconList::directoryChanged(const QString& path) +{ + QDir newDir(path); + if (m_dir.absolutePath() != newDir.absolutePath()) { + m_dir.setPath(path); + m_dir.refresh(); + if (m_isWatching) + stopWatching(); + startWatching(); + } + if (!m_dir.exists() && !FS::ensureFolderPathExists(m_dir.absolutePath())) + return; + m_dir.refresh(); + const QStringList newFileNamesList = getIconFilePaths(); + const QSet newSet = toStringSet(newFileNamesList); + QSet currentSet; + for (const MMCIcon& it : m_icons) { + if (!it.has(IconType::FileBased)) + continue; + QFileInfo icon(it.getFilePath()); + currentSet.insert(icon.absoluteFilePath()); + } + QSet toRemove = currentSet - newSet; + QSet toAdd = newSet - currentSet; + + for (const QString& removedPath : toRemove) { + qDebug() << "Removing icon" << removedPath; + QFileInfo removedFile(removedPath); + QString relativePath = m_dir.relativeFilePath(removedFile.absoluteFilePath()); + QString key = QFileInfo(relativePath).completeBaseName(); + + int idx = getIconIndex(key); + if (idx == -1) + continue; + m_icons[idx].remove(FileBased); + if (m_icons[idx].type() == ToBeDeleted) { + beginRemoveRows(QModelIndex(), idx, idx); + m_icons.remove(idx); + reindex(); + endRemoveRows(); + } else { + dataChanged(index(idx), index(idx)); + } + m_watcher->removePath(removedPath); + emit iconUpdated(key); + } + + for (const QString& addedPath : toAdd) { + qDebug() << "Adding icon" << addedPath; + + QFileInfo addfile(addedPath); + QString relativePath = m_dir.relativeFilePath(addfile.absoluteFilePath()); + QString key = QFileInfo(relativePath).completeBaseName(); + QString name = formatName(m_dir, addfile); + + if (addIcon(key, name, addfile.filePath(), IconType::FileBased)) { + m_watcher->addPath(addedPath); + emit iconUpdated(key); + } + } + + sortIconList(); +} + +void IconList::fileChanged(const QString& path) +{ + qDebug() << "Checking icon" << path; + QFileInfo checkfile(path); + if (!checkfile.exists()) + return; + QString key = m_dir.relativeFilePath(checkfile.absoluteFilePath()); + int idx = getIconIndex(key); + if (idx == -1) + return; + QIcon icon; + // special handling for jpg and jpeg to go through pixmap to keep the size constant + if (path.endsWith(".jpg") || path.endsWith(".jpeg")) { + icon.addPixmap(QPixmap(path)); + } else { + icon.addFile(path); + } + if (icon.availableSizes().empty()) + return; + + m_icons[idx].m_images[IconType::FileBased].icon = icon; + dataChanged(index(idx), index(idx)); + emit iconUpdated(key); +} + +void IconList::SettingChanged(const Setting& setting, const QVariant& value) +{ + if (setting.id() != "IconsDir") + return; + + directoryChanged(value.toString()); +} + +void IconList::startWatching() +{ + auto abs_path = m_dir.absolutePath(); + FS::ensureFolderPathExists(abs_path); + m_isWatching = addPathRecursively(abs_path); + if (m_isWatching) { + qDebug() << "Started watching" << abs_path; + } else { + qDebug() << "Failed to start watching" << abs_path; + } +} + +void IconList::stopWatching() +{ + m_watcher->removePaths(m_watcher->files()); + m_watcher->removePaths(m_watcher->directories()); + m_isWatching = false; +} + +QStringList IconList::mimeTypes() const +{ + QStringList types; + types << "text/uri-list"; + return types; +} +Qt::DropActions IconList::supportedDropActions() const +{ + return Qt::CopyAction; +} + +bool IconList::dropMimeData(const QMimeData* data, + Qt::DropAction action, + [[maybe_unused]] int row, + [[maybe_unused]] int column, + [[maybe_unused]] const QModelIndex& parent) +{ + if (action == Qt::IgnoreAction) + return true; + // check if the action is supported + if (!data || !(action & supportedDropActions())) + return false; + + // files dropped from outside? + if (data->hasUrls()) { + auto urls = data->urls(); + QStringList iconFiles; + for (const auto& url : urls) { + // only local files may be dropped... + if (!url.isLocalFile()) + continue; + iconFiles += url.toLocalFile(); + } + installIcons(iconFiles); + return true; + } + return false; +} + +Qt::ItemFlags IconList::flags(const QModelIndex& index) const +{ + Qt::ItemFlags defaultFlags = QAbstractListModel::flags(index); + return Qt::ItemIsDropEnabled | defaultFlags; +} + +QVariant IconList::data(const QModelIndex& index, int role) const +{ + if (!index.isValid()) + return {}; + + int row = index.row(); + + if (row < 0 || row >= m_icons.size()) + return {}; + + switch (role) { + case Qt::DecorationRole: + return m_icons[row].icon(); + case Qt::DisplayRole: + return m_icons[row].name(); + case Qt::UserRole: + return m_icons[row].m_key; + default: + return {}; + } +} + +int IconList::rowCount(const QModelIndex& parent) const +{ + return parent.isValid() ? 0 : m_icons.size(); +} + +void IconList::installIcons(const QStringList& iconFiles) +{ + for (const QString& file : iconFiles) + installIcon(file, {}); +} + +void IconList::installIcon(const QString& file, const QString& name) +{ + QFileInfo fileinfo(file); + if (!fileinfo.isReadable() || !fileinfo.isFile()) + return; + + if (!IconUtils::isIconSuffix(fileinfo.suffix())) + return; + + QString target = FS::PathCombine(getDirectory(), name.isEmpty() ? fileinfo.fileName() : name); + QFile::copy(file, target); +} + +bool IconList::iconFileExists(const QString& key) const +{ + auto iconEntry = icon(key); + return iconEntry && iconEntry->has(IconType::FileBased); +} + +/// Returns the icon with the given key or nullptr if it doesn't exist. +const MMCIcon* IconList::icon(const QString& key) const +{ + int iconIdx = getIconIndex(key); + if (iconIdx == -1) + return nullptr; + return &m_icons[iconIdx]; +} + +bool IconList::deleteIcon(const QString& key) +{ + return iconFileExists(key) && FS::deletePath(icon(key)->getFilePath()); +} + +bool IconList::trashIcon(const QString& key) +{ + return iconFileExists(key) && FS::trash(icon(key)->getFilePath(), nullptr); +} + +bool IconList::addThemeIcon(const QString& key) +{ + auto iter = m_nameIndex.find(key); + if (iter != m_nameIndex.end()) { + auto& oldOne = m_icons[*iter]; + oldOne.replace(Builtin, key); + dataChanged(index(*iter), index(*iter)); + return true; + } + // add a new icon + beginInsertRows(QModelIndex(), m_icons.size(), m_icons.size()); + { + MMCIcon mmc_icon; + mmc_icon.m_name = key; + mmc_icon.m_key = key; + mmc_icon.replace(Builtin, key); + m_icons.push_back(mmc_icon); + m_nameIndex[key] = m_icons.size() - 1; + } + endInsertRows(); + return true; +} + +bool IconList::addIcon(const QString& key, const QString& name, const QString& path, const IconType type) +{ + // replace the icon even? is the input valid? + QIcon icon; + // special handling for jpg and jpeg to go through pixmap to keep the size constant + if (path.endsWith(".jpg") || path.endsWith(".jpeg")) { + icon.addPixmap(QPixmap(path)); + } else { + icon.addFile(path); + } + + if (icon.isNull()) + return false; + auto iter = m_nameIndex.find(key); + if (iter != m_nameIndex.end()) { + auto& oldOne = m_icons[*iter]; + oldOne.replace(type, icon, path); + dataChanged(index(*iter), index(*iter)); + return true; + } + // add a new icon + beginInsertRows(QModelIndex(), m_icons.size(), m_icons.size()); + { + MMCIcon mmc_icon; + mmc_icon.m_name = name; + mmc_icon.m_key = key; + mmc_icon.replace(type, icon, path); + m_icons.push_back(mmc_icon); + m_nameIndex[key] = m_icons.size() - 1; + } + endInsertRows(); + return true; +} + +void IconList::saveIcon(const QString& key, const QString& path, const char* format) const +{ + auto icon = getIcon(key); + auto pixmap = icon.pixmap(128, 128); + pixmap.save(path, format); +} + +void IconList::reindex() +{ + m_nameIndex.clear(); + for (int i = 0; i < m_icons.size(); i++) { + m_nameIndex[m_icons[i].m_key] = i; + emit iconUpdated(m_icons[i].m_key); // prevents incorrect indices with proxy model + } +} + +QIcon IconList::getIcon(const QString& key) const +{ + int iconIndex = getIconIndex(key); + + if (iconIndex != -1) + return m_icons[iconIndex].icon(); + + // Fallback for icons that don't exist.b + iconIndex = getIconIndex("grass"); + + if (iconIndex != -1) + return m_icons[iconIndex].icon(); + return {}; +} + +int IconList::getIconIndex(const QString& key) const +{ + auto iter = m_nameIndex.find(key == "default" ? "grass" : key); + if (iter != m_nameIndex.end()) + return *iter; + + return -1; +} + +QString IconList::getDirectory() const +{ + return m_dir.absolutePath(); +} + +/// Returns the directory of the icon with the given key or the default directory if it's a builtin icon. +QString IconList::iconDirectory(const QString& key) const +{ + for (const auto& mmcIcon : m_icons) { + if (mmcIcon.m_key == key && mmcIcon.has(IconType::FileBased)) { + QFileInfo iconFile(mmcIcon.getFilePath()); + return iconFile.dir().path(); + } + } + return getDirectory(); +} diff --git a/launcher/icons/IconList.h b/launcher/icons/IconList.h new file mode 100644 index 0000000..d2f9044 --- /dev/null +++ b/launcher/icons/IconList.h @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "MMCIcon.h" +#include "settings/Setting.h" + +#include "QObjectPtr.h" + +class QFileSystemWatcher; + +class IconList : public QAbstractListModel { + Q_OBJECT + public: + explicit IconList(const QStringList& builtinPaths, const QString& path, QObject* parent = 0); + virtual ~IconList() {}; + + QIcon getIcon(const QString& key) const; + int getIconIndex(const QString& key) const; + QString getDirectory() const; + + virtual QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + virtual int rowCount(const QModelIndex& parent = QModelIndex()) const override; + + virtual QStringList mimeTypes() const override; + virtual Qt::DropActions supportedDropActions() const override; + virtual bool dropMimeData(const QMimeData* data, Qt::DropAction action, int row, int column, const QModelIndex& parent) override; + virtual Qt::ItemFlags flags(const QModelIndex& index) const override; + + bool addThemeIcon(const QString& key); + bool addIcon(const QString& key, const QString& name, const QString& path, IconType type); + void saveIcon(const QString& key, const QString& path, const char* format) const; + bool deleteIcon(const QString& key); + bool trashIcon(const QString& key); + bool iconFileExists(const QString& key) const; + QString iconDirectory(const QString& key) const; + + void installIcons(const QStringList& iconFiles); + void installIcon(const QString& file, const QString& name); + + const MMCIcon* icon(const QString& key) const; + + void startWatching(); + void stopWatching(); + + signals: + void iconUpdated(QString key); + + private: + // hide copy constructor + IconList(const IconList&) = delete; + // hide assign op + IconList& operator=(const IconList&) = delete; + void reindex(); + void sortIconList(); + bool addPathRecursively(const QString& path); + QStringList getIconFilePaths() const; + + public slots: + void directoryChanged(const QString& path); + + protected slots: + void fileChanged(const QString& path); + void SettingChanged(const Setting& setting, const QVariant& value); + + private: + shared_qobject_ptr m_watcher; + bool m_isWatching; + QMap m_nameIndex; + QList m_icons; + QDir m_dir; +}; diff --git a/launcher/icons/IconUtils.cpp b/launcher/icons/IconUtils.cpp new file mode 100644 index 0000000..87e9487 --- /dev/null +++ b/launcher/icons/IconUtils.cpp @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "IconUtils.h" + +#include +#include "FileSystem.h" + +namespace { +static const QStringList validIconExtensions = { { "svg", "png", "ico", "gif", "jpg", "jpeg", "webp" } }; +} + +namespace IconUtils { + +QString findBestIconIn(const QString& folder, const QString& iconKey) +{ + QString best_filename; + + QDirIterator it(folder, QDir::NoDotAndDotDot | QDir::Files, QDirIterator::NoIteratorFlags); + while (it.hasNext()) { + it.next(); + auto fileInfo = it.fileInfo(); + if ((fileInfo.completeBaseName() == iconKey || fileInfo.fileName() == iconKey) && isIconSuffix(fileInfo.suffix())) + return fileInfo.absoluteFilePath(); + } + return {}; +} + +QString getIconFilter() +{ + return "(*." + validIconExtensions.join(" *.") + ")"; +} + +bool isIconSuffix(QString suffix) +{ + return validIconExtensions.contains(suffix); +} + +} // namespace IconUtils diff --git a/launcher/icons/IconUtils.h b/launcher/icons/IconUtils.h new file mode 100644 index 0000000..90cdfe5 --- /dev/null +++ b/launcher/icons/IconUtils.h @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +namespace IconUtils { + +// Given a folder and an icon key, find 'best' of the icons with the given key in there and return its path +QString findBestIconIn(const QString& folder, const QString& iconKey); + +// Get icon file type filter for file browser dialogs +QString getIconFilter(); + +bool isIconSuffix(QString suffix); +} // namespace IconUtils diff --git a/launcher/icons/MMCIcon.cpp b/launcher/icons/MMCIcon.cpp new file mode 100644 index 0000000..991b470 --- /dev/null +++ b/launcher/icons/MMCIcon.cpp @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "MMCIcon.h" +#include +#include + +IconType operator--(IconType& t, int) +{ + IconType temp = t; + switch (t) { + case IconType::Builtin: + t = IconType::ToBeDeleted; + break; + case IconType::Transient: + t = IconType::Builtin; + break; + case IconType::FileBased: + t = IconType::Transient; + break; + default: + break; + } + return temp; +} + +IconType MMCIcon::type() const +{ + return m_current_type; +} + +QString MMCIcon::name() const +{ + if (m_name.size()) + return m_name; + return m_key; +} + +bool MMCIcon::has(IconType _type) const +{ + return m_images[_type].present(); +} + +QIcon MMCIcon::icon() const +{ + if (m_current_type == IconType::ToBeDeleted) + return QIcon(); + auto& icon = m_images[m_current_type].icon; + if (!icon.isNull()) + return icon; + // FIXME: inject this. + return QIcon::fromTheme(m_images[m_current_type].key); +} + +void MMCIcon::remove(IconType rm_type) +{ + m_images[rm_type].filename = QString(); + m_images[rm_type].icon = QIcon(); + for (auto iter = rm_type; iter != IconType::ToBeDeleted; iter--) { + if (m_images[iter].present()) { + m_current_type = iter; + return; + } + } + m_current_type = IconType::ToBeDeleted; +} + +void MMCIcon::replace(IconType new_type, QIcon icon, QString path) +{ + if (new_type > m_current_type || m_current_type == IconType::ToBeDeleted) { + m_current_type = new_type; + } + m_images[new_type].icon = icon; + m_images[new_type].filename = path; + m_images[new_type].key = QString(); +} + +void MMCIcon::replace(IconType new_type, const QString& key) +{ + if (new_type > m_current_type || m_current_type == IconType::ToBeDeleted) { + m_current_type = new_type; + } + m_images[new_type].icon = QIcon(); + m_images[new_type].filename = QString(); + m_images[new_type].key = key; +} + +QString MMCIcon::getFilePath() const +{ + if (m_current_type == IconType::ToBeDeleted) { + return QString(); + } + return m_images[m_current_type].filename; +} + +bool MMCIcon::isBuiltIn() const +{ + return m_current_type == IconType::Builtin; +} diff --git a/launcher/icons/MMCIcon.h b/launcher/icons/MMCIcon.h new file mode 100644 index 0000000..a6e3056 --- /dev/null +++ b/launcher/icons/MMCIcon.h @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +#pragma once +#include +#include +#include + +enum IconType : unsigned { Builtin, Transient, FileBased, ICONS_TOTAL, ToBeDeleted }; + +struct MMCImage { + QIcon icon; + QString key; + QString filename; + bool present() const { return !icon.isNull() || !key.isEmpty(); } +}; + +struct MMCIcon { + QString m_key; + QString m_name; + MMCImage m_images[ICONS_TOTAL]; + IconType m_current_type = ToBeDeleted; + + IconType type() const; + QString name() const; + bool has(IconType _type) const; + QIcon icon() const; + void remove(IconType rm_type); + void replace(IconType new_type, QIcon icon, QString path = QString()); + void replace(IconType new_type, const QString& key); + bool isBuiltIn() const; + QString getFilePath() const; +}; diff --git a/launcher/include/base.pch.hpp b/launcher/include/base.pch.hpp new file mode 100644 index 0000000..ecaf41f --- /dev/null +++ b/launcher/include/base.pch.hpp @@ -0,0 +1,17 @@ +#pragma once +#ifndef PRISM_PRECOMPILED_BASE_HEADERS_H +#define PRISM_PRECOMPILED_BASE_HEADERS_H + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#endif // PRISM_PRECOMPILED_BASE_HEADERS_H diff --git a/launcher/include/qtcore.pch.hpp b/launcher/include/qtcore.pch.hpp new file mode 100644 index 0000000..b883661 --- /dev/null +++ b/launcher/include/qtcore.pch.hpp @@ -0,0 +1,65 @@ +#pragma once +#ifndef PRISM_PRECOMPILED_QTCORE_HEADERS_H +#define PRISM_PRECOMPILED_QTCORE_HEADERS_H + +#include +#include +#include + +#include +#include + +#include +#include + +#include +#include + +#include + +// collections +#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 + +#include + +#include +#include +#include + +#endif // PRISM_PRECOMPILED_QTCORE_HEADERS_H diff --git a/launcher/include/qtgui.pch.hpp b/launcher/include/qtgui.pch.hpp new file mode 100644 index 0000000..fb57cb3 --- /dev/null +++ b/launcher/include/qtgui.pch.hpp @@ -0,0 +1,47 @@ +#pragma once +#ifndef PRISM_PRECOMPILED_QTGUI_HEADERS_H +#define PRISM_PRECOMPILED_QTGUI_HEADERS_H + +#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 +#include + +#endif // PRISM_PRECOMPILED_GUI_HEADERS_H diff --git a/launcher/java/JavaChecker.cpp b/launcher/java/JavaChecker.cpp new file mode 100644 index 0000000..5c52c65 --- /dev/null +++ b/launcher/java/JavaChecker.cpp @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "JavaChecker.h" + +#include +#include +#include +#include + +#include "Commandline.h" +#include "FileSystem.h" +#include "java/JavaUtils.h" + +JavaChecker::JavaChecker(QString path, QString args, int minMem, int maxMem, int permGen, int id) + : Task(), m_path(path), m_args(args), m_minMem(minMem), m_maxMem(maxMem), m_permGen(permGen), m_id(id) +{} + +void JavaChecker::executeTask() +{ + QString checkerJar = JavaUtils::getJavaCheckPath(); + + if (checkerJar.isEmpty()) { + qDebug() << "Java checker library could not be found. Please check your installation."; + return; + } +#ifdef Q_OS_WIN + checkerJar = FS::getPathNameInLocal8bit(checkerJar); +#endif + + QStringList args; + + process.reset(new QProcess()); + if (m_args.size()) { + auto extraArgs = Commandline::splitArgs(m_args); + args.append(extraArgs); + } + if (m_minMem != 0) { + args << QString("-Xms%1m").arg(m_minMem); + } + if (m_maxMem != 0) { + args << QString("-Xmx%1m").arg(m_maxMem); + } + if (m_permGen != 64 && m_permGen != 0) { + args << QString("-XX:PermSize=%1m").arg(m_permGen); + } + + args.append({ "-jar", checkerJar }); + process->setArguments(args); + process->setProgram(m_path); + process->setProcessChannelMode(QProcess::SeparateChannels); + process->setProcessEnvironment(CleanEnviroment()); + qDebug() << "Running java checker:" << m_path << args.join(" "); + + connect(process.get(), &QProcess::finished, this, &JavaChecker::finished); + connect(process.get(), &QProcess::errorOccurred, this, &JavaChecker::error); + connect(process.get(), &QProcess::readyReadStandardOutput, this, &JavaChecker::stdoutReady); + connect(process.get(), &QProcess::readyReadStandardError, this, &JavaChecker::stderrReady); + connect(&killTimer, &QTimer::timeout, this, &JavaChecker::timeout); + killTimer.setSingleShot(true); + killTimer.start(15000); + process->start(); +} + +void JavaChecker::stdoutReady() +{ + QByteArray data = process->readAllStandardOutput(); + QString added = QString::fromLocal8Bit(data); + added.remove('\r'); + m_stdout += added; +} + +void JavaChecker::stderrReady() +{ + QByteArray data = process->readAllStandardError(); + QString added = QString::fromLocal8Bit(data); + added.remove('\r'); + m_stderr += added; +} + +void JavaChecker::finished(int exitcode, QProcess::ExitStatus status) +{ + killTimer.stop(); + QProcessPtr _process = process; + process.reset(); + + Result result = { + m_path, + m_id, + }; + result.errorLog = m_stderr; + result.outLog = m_stdout; + qDebug() << "STDOUT" << m_stdout; + qWarning() << "STDERR" << m_stderr; + qDebug() << "Java checker finished with status" << status << "exit code" << exitcode; + + if (status == QProcess::CrashExit || exitcode == 1) { + result.validity = Result::Validity::Errored; + emit checkFinished(result); + emitSucceeded(); + return; + } + + bool success = true; + + QMap results; + + QStringList lines = m_stdout.split("\n", Qt::SkipEmptyParts); + for (QString line : lines) { + line = line.trimmed(); + // NOTE: workaround for GH-4125, where garbage is getting printed into stdout on bedrock linux + if (line.contains("/bedrock/strata")) { + continue; + } + + auto parts = line.split('=', Qt::SkipEmptyParts); + if (parts.size() != 2 || parts[0].isEmpty() || parts[1].isEmpty()) { + continue; + } else { + results.insert(parts[0], parts[1]); + } + } + + if (!results.contains("os.arch") || !results.contains("java.version") || !results.contains("java.vendor") || !success) { + result.validity = Result::Validity::ReturnedInvalidData; + emit checkFinished(result); + emitSucceeded(); + return; + } + + auto os_arch = results["os.arch"]; + auto java_version = results["java.version"]; + auto java_vendor = results["java.vendor"]; + bool is_64 = os_arch == "x86_64" || os_arch == "amd64" || os_arch == "aarch64" || os_arch == "arm64" || os_arch == "riscv64" || os_arch == "ppc64le" || os_arch == "ppc64"; + + result.validity = Result::Validity::Valid; + result.is_64bit = is_64; + result.mojangPlatform = is_64 ? "64" : "32"; + result.realPlatform = os_arch; + result.javaVersion = java_version; + result.javaVendor = java_vendor; + qDebug() << "Java checker succeeded."; + emit checkFinished(result); + emitSucceeded(); +} + +void JavaChecker::error(QProcess::ProcessError err) +{ + if (err == QProcess::FailedToStart) { + qDebug() << "Java checker has failed to start."; + qDebug() << "Process environment:"; + qDebug() << process->environment(); + qDebug() << "Native environment:"; + qDebug() << QProcessEnvironment::systemEnvironment().toStringList(); + killTimer.stop(); + emit checkFinished({ m_path, m_id }); + } + emitSucceeded(); +} + +void JavaChecker::timeout() +{ + // NO MERCY. NO ABUSE. + if (process) { + qDebug() << "Java checker has been killed by timeout."; + process->kill(); + } +} diff --git a/launcher/java/JavaChecker.h b/launcher/java/JavaChecker.h new file mode 100644 index 0000000..a04b681 --- /dev/null +++ b/launcher/java/JavaChecker.h @@ -0,0 +1,55 @@ +#pragma once +#include +#include + +#include "JavaVersion.h" +#include "QObjectPtr.h" +#include "tasks/Task.h" + +class JavaChecker : public Task { + Q_OBJECT + public: + using QProcessPtr = shared_qobject_ptr; + using Ptr = shared_qobject_ptr; + + struct Result { + QString path; + int id; + QString mojangPlatform; + QString realPlatform; + JavaVersion javaVersion; + QString javaVendor; + QString outLog; + QString errorLog; + bool is_64bit = false; + enum class Validity { Errored, ReturnedInvalidData, Valid } validity = Validity::Errored; + }; + + explicit JavaChecker(QString path, QString args, int minMem = 0, int maxMem = 0, int permGen = 0, int id = 0); + + signals: + void checkFinished(const Result& result); + + protected: + virtual void executeTask() override; + + private: + QProcessPtr process; + QTimer killTimer; + QString m_stdout; + QString m_stderr; + + QString m_path; + QString m_args; + int m_minMem = 0; + int m_maxMem = 0; + int m_permGen = 64; + int m_id = 0; + + private slots: + void timeout(); + void finished(int exitcode, QProcess::ExitStatus); + void error(QProcess::ProcessError); + void stdoutReady(); + void stderrReady(); +}; diff --git a/launcher/java/JavaInstall.cpp b/launcher/java/JavaInstall.cpp new file mode 100644 index 0000000..98aac5c --- /dev/null +++ b/launcher/java/JavaInstall.cpp @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023-2024 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "JavaInstall.h" + +#include "BaseVersion.h" +#include "StringUtils.h" + +bool JavaInstall::operator<(const JavaInstall& rhs) const +{ + auto archCompare = StringUtils::naturalCompare(arch, rhs.arch, Qt::CaseInsensitive); + if (archCompare != 0) + return archCompare < 0; + if (id < rhs.id) { + return true; + } + if (id > rhs.id) { + return false; + } + return StringUtils::naturalCompare(path, rhs.path, Qt::CaseInsensitive) < 0; +} + +bool JavaInstall::operator==(const JavaInstall& rhs) const +{ + return arch == rhs.arch && id == rhs.id && path == rhs.path; +} + +bool JavaInstall::operator>(const JavaInstall& rhs) const +{ + return (!operator<(rhs)) && (!operator==(rhs)); +} + +bool JavaInstall::operator<(BaseVersion& a) const +{ + try { + return operator<(dynamic_cast(a)); + } catch (const std::bad_cast&) { + return BaseVersion::operator<(a); + } +} + +bool JavaInstall::operator>(BaseVersion& a) const +{ + try { + return operator>(dynamic_cast(a)); + } catch (const std::bad_cast&) { + return BaseVersion::operator>(a); + } +} diff --git a/launcher/java/JavaInstall.h b/launcher/java/JavaInstall.h new file mode 100644 index 0000000..5899964 --- /dev/null +++ b/launcher/java/JavaInstall.h @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023-2024 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include "BaseVersion.h" +#include "JavaVersion.h" + +struct JavaInstall : public BaseVersion { + JavaInstall() {} + JavaInstall(QString id, QString arch, QString path) : id(id), arch(arch), path(path) {} + virtual QString descriptor() const override { return id.toString(); } + + virtual QString name() const override { return id.toString(); } + + virtual QString typeString() const override { return arch; } + + virtual bool operator<(BaseVersion& a) const override; + virtual bool operator>(BaseVersion& a) const override; + bool operator<(const JavaInstall& rhs) const; + bool operator==(const JavaInstall& rhs) const; + bool operator>(const JavaInstall& rhs) const; + + JavaVersion id; + QString arch; + QString path; + bool is_64bit = false; +}; + +using JavaInstallPtr = std::shared_ptr; diff --git a/launcher/java/JavaInstallList.cpp b/launcher/java/JavaInstallList.cpp new file mode 100644 index 0000000..254d5f4 --- /dev/null +++ b/launcher/java/JavaInstallList.cpp @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include + +#include +#include + +#include "Application.h" +#include "settings/SettingsObject.h" +#include "java/JavaChecker.h" +#include "java/JavaInstallList.h" +#include "java/JavaUtils.h" +#include "tasks/ConcurrentTask.h" + +JavaInstallList::JavaInstallList(QObject* parent, bool onlyManagedVersions) + : BaseVersionList(parent), m_only_managed_versions(onlyManagedVersions) +{} + +Task::Ptr JavaInstallList::getLoadTask() +{ + load(); + return getCurrentTask(); +} + +Task::Ptr JavaInstallList::getCurrentTask() +{ + if (m_status == Status::InProgress) { + return m_load_task; + } + return nullptr; +} + +void JavaInstallList::load() +{ + if (m_status != Status::InProgress) { + m_status = Status::InProgress; + m_load_task.reset(new JavaListLoadTask(this, m_only_managed_versions)); + m_load_task->start(); + } +} + +const BaseVersion::Ptr JavaInstallList::at(int i) const +{ + return m_vlist.at(i); +} + +bool JavaInstallList::isLoaded() +{ + return m_status == JavaInstallList::Status::Done; +} + +int JavaInstallList::count() const +{ + return m_vlist.count(); +} + +QVariant JavaInstallList::data(const QModelIndex& index, int role) const +{ + if (!index.isValid()) + return QVariant(); + + if (index.row() > count()) + return QVariant(); + + auto version = std::dynamic_pointer_cast(m_vlist[index.row()]); + switch (role) { + case SortRole: + return -index.row(); + case VersionPointerRole: + return QVariant::fromValue(m_vlist[index.row()]); + case VersionIdRole: + return version->descriptor(); + case VersionRole: + return version->id.toString(); + case RecommendedRole: + return false; + case PathRole: + return version->path; + case CPUArchitectureRole: + return version->arch; + default: + return QVariant(); + } +} + +BaseVersionList::RoleList JavaInstallList::providesRoles() const +{ + return { VersionPointerRole, VersionIdRole, VersionRole, RecommendedRole, PathRole, CPUArchitectureRole }; +} + +void JavaInstallList::updateListData(QList versions) +{ + beginResetModel(); + m_vlist = versions; + sortVersions(); + endResetModel(); + m_status = Status::Done; + m_load_task.reset(); +} + +bool sortJavas(BaseVersion::Ptr left, BaseVersion::Ptr right) +{ + auto rleft = std::dynamic_pointer_cast(right); + auto rright = std::dynamic_pointer_cast(left); + return (*rleft) > (*rright); +} + +void JavaInstallList::sortVersions() +{ + beginResetModel(); + std::sort(m_vlist.begin(), m_vlist.end(), sortJavas); + endResetModel(); +} + +JavaListLoadTask::JavaListLoadTask(JavaInstallList* vlist, bool onlyManagedVersions) : Task(), m_only_managed_versions(onlyManagedVersions) +{ + m_list = vlist; + m_current_recommended = NULL; +} + +void JavaListLoadTask::executeTask() +{ + setStatus(tr("Detecting Java installations...")); + + JavaUtils ju; + QList candidate_paths = m_only_managed_versions ? getPrismJavaBundle() : ju.FindJavaPaths(); + + ConcurrentTask::Ptr job(new ConcurrentTask("Java detection", APPLICATION->settings()->get("NumberOfConcurrentTasks").toInt())); + m_job.reset(job); + connect(m_job.get(), &Task::finished, this, &JavaListLoadTask::javaCheckerFinished); + connect(m_job.get(), &Task::progress, this, &Task::setProgress); + + qDebug() << "Probing the following Java paths: "; + int id = 0; + for (QString candidate : candidate_paths) { + auto checker = new JavaChecker(candidate, "", 0, 0, 0, id); + connect(checker, &JavaChecker::checkFinished, [this](const JavaChecker::Result& result) { m_results << result; }); + job->addTask(Task::Ptr(checker)); + id++; + } + + m_job->start(); +} + +void JavaListLoadTask::javaCheckerFinished() +{ + QList candidates; + std::sort(m_results.begin(), m_results.end(), [](const JavaChecker::Result& a, const JavaChecker::Result& b) { return a.id < b.id; }); + + qDebug() << "Found the following valid Java installations:"; + for (auto result : m_results) { + if (result.validity == JavaChecker::Result::Validity::Valid) { + JavaInstallPtr javaVersion(new JavaInstall()); + + javaVersion->id = result.javaVersion; + javaVersion->arch = result.realPlatform; + javaVersion->path = result.path; + javaVersion->is_64bit = result.is_64bit; + candidates.append(javaVersion); + + qDebug() << " " << javaVersion->id.toString() << javaVersion->arch << javaVersion->path; + } + } + + QList javas_bvp; + for (auto java : candidates) { + // qDebug() << java->id << java->arch << " at " << java->path; + BaseVersion::Ptr bp_java = std::dynamic_pointer_cast(java); + + if (bp_java) { + javas_bvp.append(java); + } + } + + m_list->updateListData(javas_bvp); + emitSucceeded(); +} diff --git a/launcher/java/JavaInstallList.h b/launcher/java/JavaInstallList.h new file mode 100644 index 0000000..c68c2a3 --- /dev/null +++ b/launcher/java/JavaInstallList.h @@ -0,0 +1,79 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +#include "BaseVersionList.h" +#include "java/JavaChecker.h" +#include "tasks/Task.h" + +#include "JavaInstall.h" + +#include "QObjectPtr.h" + +class JavaListLoadTask; + +class JavaInstallList : public BaseVersionList { + Q_OBJECT + enum class Status { NotDone, InProgress, Done }; + + public: + explicit JavaInstallList(QObject* parent = 0, bool onlyManagedVersions = false); + + Task::Ptr getLoadTask() override; + bool isLoaded() override; + const BaseVersion::Ptr at(int i) const override; + int count() const override; + void sortVersions() override; + + QVariant data(const QModelIndex& index, int role) const override; + RoleList providesRoles() const override; + + public slots: + void updateListData(QList versions) override; + + protected: + void load(); + Task::Ptr getCurrentTask(); + + protected: + Status m_status = Status::NotDone; + shared_qobject_ptr m_load_task; + QList m_vlist; + bool m_only_managed_versions; +}; + +class JavaListLoadTask : public Task { + Q_OBJECT + + public: + explicit JavaListLoadTask(JavaInstallList* vlist, bool onlyManagedVersions = false); + virtual ~JavaListLoadTask() = default; + + protected: + void executeTask() override; + public slots: + void javaCheckerFinished(); + + protected: + Task::Ptr m_job; + JavaInstallList* m_list; + JavaInstall* m_current_recommended; + QList m_results; + bool m_only_managed_versions; +}; diff --git a/launcher/java/JavaMetadata.cpp b/launcher/java/JavaMetadata.cpp new file mode 100644 index 0000000..3647c96 --- /dev/null +++ b/launcher/java/JavaMetadata.cpp @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023-2024 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "java/JavaMetadata.h" + +#include + +#include "Json.h" +#include "StringUtils.h" +#include "java/JavaVersion.h" +#include "minecraft/ParseUtils.h" + +namespace Java { + +DownloadType parseDownloadType(QString javaDownload) +{ + if (javaDownload == "manifest") + return DownloadType::Manifest; + else if (javaDownload == "archive") + return DownloadType::Archive; + else + return DownloadType::Unknown; +} +QString downloadTypeToString(DownloadType javaDownload) +{ + switch (javaDownload) { + case DownloadType::Manifest: + return "manifest"; + case DownloadType::Archive: + return "archive"; + case DownloadType::Unknown: + break; + } + return "unknown"; +} +MetadataPtr parseJavaMeta(const QJsonObject& in) +{ + auto meta = std::make_shared(); + + meta->m_name = in["name"].toString(""); + meta->vendor = in["vendor"].toString(""); + meta->url = in["url"].toString(""); + meta->releaseTime = timeFromS3Time(in["releaseTime"].toString("")); + meta->downloadType = parseDownloadType(in["downloadType"].toString("")); + meta->packageType = in["packageType"].toString(""); + meta->runtimeOS = in["runtimeOS"].toString("unknown"); + + if (in.contains("checksum")) { + auto obj = Json::requireObject(in, "checksum"); + meta->checksumHash = obj["hash"].toString(""); + meta->checksumType = obj["type"].toString(""); + } + + if (in.contains("version")) { + auto obj = Json::requireObject(in, "version"); + auto name = obj["name"].toString(""); + auto major = obj["major"].toInteger(); + auto minor = obj["minor"].toInteger(); + auto security = obj["security"].toInteger(); + auto build = obj["build"].toInteger(); + meta->version = JavaVersion(major, minor, security, build, name); + } + return meta; +} + +bool Metadata::operator<(const Metadata& rhs) const +{ + auto id = version; + if (id < rhs.version) { + return true; + } + if (id > rhs.version) { + return false; + } + auto date = releaseTime; + if (date < rhs.releaseTime) { + return true; + } + if (date > rhs.releaseTime) { + return false; + } + return StringUtils::naturalCompare(m_name, rhs.m_name, Qt::CaseInsensitive) < 0; +} + +bool Metadata::operator==(const Metadata& rhs) const +{ + return version == rhs.version && m_name == rhs.m_name; +} + +bool Metadata::operator>(const Metadata& rhs) const +{ + return (!operator<(rhs)) && (!operator==(rhs)); +} + +bool Metadata::operator<(BaseVersion& a) const +{ + try { + return operator<(dynamic_cast(a)); + } catch (const std::bad_cast&) { + return BaseVersion::operator<(a); + } +} + +bool Metadata::operator>(BaseVersion& a) const +{ + try { + return operator>(dynamic_cast(a)); + } catch (const std::bad_cast&) { + return BaseVersion::operator>(a); + } +} + +} // namespace Java diff --git a/launcher/java/JavaMetadata.h b/launcher/java/JavaMetadata.h new file mode 100644 index 0000000..0757a69 --- /dev/null +++ b/launcher/java/JavaMetadata.h @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023-2024 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +#pragma once + +#include +#include +#include + +#include + +#include "BaseVersion.h" +#include "java/JavaVersion.h" + +namespace Java { + +enum class DownloadType { Manifest, Archive, Unknown }; + +class Metadata : public BaseVersion { + public: + virtual QString descriptor() const override { return version.toString(); } + + virtual QString name() const override { return m_name; } + + virtual QString typeString() const override { return vendor; } + + virtual bool operator<(BaseVersion& a) const override; + virtual bool operator>(BaseVersion& a) const override; + bool operator<(const Metadata& rhs) const; + bool operator==(const Metadata& rhs) const; + bool operator>(const Metadata& rhs) const; + + QString m_name; + QString vendor; + QString url; + QDateTime releaseTime; + QString checksumType; + QString checksumHash; + DownloadType downloadType; + QString packageType; + JavaVersion version; + QString runtimeOS; +}; +using MetadataPtr = std::shared_ptr; + +DownloadType parseDownloadType(QString javaDownload); +QString downloadTypeToString(DownloadType javaDownload); +MetadataPtr parseJavaMeta(const QJsonObject& libObj); + +} // namespace Java diff --git a/launcher/java/JavaUtils.cpp b/launcher/java/JavaUtils.cpp new file mode 100644 index 0000000..c58fe56 --- /dev/null +++ b/launcher/java/JavaUtils.cpp @@ -0,0 +1,588 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include +#include +#include + +#include + +#include +#include "Application.h" +#include "BuildConfig.h" +#include "FileSystem.h" +#include "java/JavaInstallList.h" +#include "java/JavaUtils.h" + +#define IBUS "@im=ibus" + +JavaUtils::JavaUtils() {} + +QString stripVariableEntries(QString name, QString target, QString remove) +{ + char delimiter = ':'; +#ifdef Q_OS_WIN32 + delimiter = ';'; +#endif + + auto targetItems = target.split(delimiter); + auto toRemove = remove.split(delimiter); + + for (QString item : toRemove) { + bool removed = targetItems.removeOne(item); + if (!removed) + qWarning() << "Entry" << item << "could not be stripped from variable" << name; + } + return targetItems.join(delimiter); +} + +QProcessEnvironment CleanEnviroment() +{ + // prepare the process environment + QProcessEnvironment rawenv = QProcessEnvironment::systemEnvironment(); + QProcessEnvironment env; + + QStringList ignored = { "JAVA_ARGS", "CLASSPATH", "CONFIGPATH", "JAVA_HOME", + "JRE_HOME", "_JAVA_OPTIONS", "JAVA_OPTIONS", "JAVA_TOOL_OPTIONS" }; + + QStringList stripped = { +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) || defined(Q_OS_OPENBSD) + "LD_LIBRARY_PATH", "LD_PRELOAD", +#endif + "QT_PLUGIN_PATH", "QT_FONTPATH" + }; + for (auto key : rawenv.keys()) { + auto value = rawenv.value(key); + // filter out dangerous java crap + if (ignored.contains(key)) { + qDebug() << "Env: ignoring" << key << value; + continue; + } + + // These are used to strip the original variables + // If there is "LD_LIBRARY_PATH" and "LAUNCHER_LD_LIBRARY_PATH", we want to + // remove all values in "LAUNCHER_LD_LIBRARY_PATH" from "LD_LIBRARY_PATH" + if (key.startsWith("LAUNCHER_")) { + qDebug() << "Env: ignoring" << key << value; + continue; + } + if (stripped.contains(key)) { + QString newValue = stripVariableEntries(key, value, rawenv.value("LAUNCHER_" + key)); + + qDebug() << "Env: stripped" << key << value << "to" << newValue; + + value = newValue; + } +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) || defined(Q_OS_OPENBSD) + // Strip IBus + // IBus is a Linux IME framework. For some reason, it breaks MC? + if (key == "XMODIFIERS" && value.contains(IBUS)) { + QString save = value; + value.replace(IBUS, ""); + qDebug() << "Env: stripped" << IBUS << "from" << save << ":" << value; + } +#endif + // qDebug() << "Env: " << key << value; + env.insert(key, value); + } +#ifdef Q_OS_LINUX + // HACK: Workaround for QTBUG-42500 + if (!env.contains("LD_LIBRARY_PATH")) { + env.insert("LD_LIBRARY_PATH", ""); + } +#endif + + return env; +} + +JavaInstallPtr JavaUtils::MakeJavaPtr(QString path, QString id, QString arch) +{ + JavaInstallPtr javaVersion(new JavaInstall()); + + javaVersion->id = id; + javaVersion->arch = arch; + javaVersion->path = path; + + return javaVersion; +} + +JavaInstallPtr JavaUtils::GetDefaultJava() +{ + JavaInstallPtr javaVersion(new JavaInstall()); + + javaVersion->id = "java"; + javaVersion->arch = "unknown"; +#if defined(Q_OS_WIN32) + javaVersion->path = "javaw"; +#else + javaVersion->path = "java"; +#endif + + return javaVersion; +} + +QStringList addJavasFromEnv(QList javas) +{ + auto env = QProcessEnvironment::systemEnvironment().value(QStringLiteral("%1_JAVA_PATHS").arg(BuildConfig.LAUNCHER_ENVNAME)); +#if defined(Q_OS_WIN32) + QList javaPaths = env.replace("\\", "/").split(QLatin1String(";")); + + auto envPath = qEnvironmentVariable("PATH"); + QList javaPathsfromPath = envPath.replace("\\", "/").split(QLatin1String(";")); + for (QString string : javaPathsfromPath) { + javaPaths.append(string + "/javaw.exe"); + } +#else + QList javaPaths = env.split(QLatin1String(":")); +#endif + for (QString i : javaPaths) { + javas.append(i); + }; + return javas; +} + +#if defined(Q_OS_WIN32) +QList JavaUtils::FindJavaFromRegistryKey(DWORD keyType, QString keyName, QString keyJavaDir, QString subkeySuffix) +{ + QList javas; + + QString archType = "unknown"; + if (keyType == KEY_WOW64_64KEY) + archType = "64"; + else if (keyType == KEY_WOW64_32KEY) + archType = "32"; + + for (HKEY baseRegistry : { HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE }) { + HKEY jreKey; + if (RegOpenKeyExW(baseRegistry, keyName.toStdWString().c_str(), 0, KEY_READ | keyType | KEY_ENUMERATE_SUB_KEYS, &jreKey) == + ERROR_SUCCESS) { + // Read the current type version from the registry. + // This will be used to find any key that contains the JavaHome value. + + WCHAR subKeyName[255]; + DWORD subKeyNameSize, numSubKeys, retCode; + + // Get the number of subkeys + RegQueryInfoKeyW(jreKey, NULL, NULL, NULL, &numSubKeys, NULL, NULL, NULL, NULL, NULL, NULL, NULL); + + // Iterate until RegEnumKeyEx fails + if (numSubKeys > 0) { + for (DWORD i = 0; i < numSubKeys; i++) { + subKeyNameSize = 255; + retCode = RegEnumKeyExW(jreKey, i, subKeyName, &subKeyNameSize, NULL, NULL, NULL, NULL); + QString newSubkeyName = QString::fromWCharArray(subKeyName); + if (retCode == ERROR_SUCCESS) { + // Now open the registry key for the version that we just got. + QString newKeyName = keyName + "\\" + newSubkeyName + subkeySuffix; + + HKEY newKey; + if (RegOpenKeyExW(baseRegistry, newKeyName.toStdWString().c_str(), 0, KEY_READ | keyType, &newKey) == + ERROR_SUCCESS) { + // Read the JavaHome value to find where Java is installed. + DWORD valueSz = 0; + if (RegQueryValueExW(newKey, keyJavaDir.toStdWString().c_str(), NULL, NULL, NULL, &valueSz) == ERROR_SUCCESS) { + WCHAR* value = new WCHAR[valueSz]; + RegQueryValueExW(newKey, keyJavaDir.toStdWString().c_str(), NULL, NULL, (BYTE*)value, &valueSz); + + QString newValue = QString::fromWCharArray(value); + delete[] value; + + // Now, we construct the version object and add it to the list. + JavaInstallPtr javaVersion(new JavaInstall()); + + javaVersion->id = newSubkeyName; + javaVersion->arch = archType; + javaVersion->path = QDir(FS::PathCombine(newValue, "bin")).absoluteFilePath("javaw.exe"); + javas.append(javaVersion); + } + + RegCloseKey(newKey); + } + } + } + } + + RegCloseKey(jreKey); + } + } + + return javas; +} + +QList JavaUtils::FindJavaPaths() +{ + QList java_candidates; + + // Oracle + QList JRE64s = + this->FindJavaFromRegistryKey(KEY_WOW64_64KEY, "SOFTWARE\\JavaSoft\\Java Runtime Environment", "JavaHome"); + QList JDK64s = this->FindJavaFromRegistryKey(KEY_WOW64_64KEY, "SOFTWARE\\JavaSoft\\Java Development Kit", "JavaHome"); + QList JRE32s = + this->FindJavaFromRegistryKey(KEY_WOW64_32KEY, "SOFTWARE\\JavaSoft\\Java Runtime Environment", "JavaHome"); + QList JDK32s = this->FindJavaFromRegistryKey(KEY_WOW64_32KEY, "SOFTWARE\\JavaSoft\\Java Development Kit", "JavaHome"); + + // Oracle for Java 9 and newer + QList NEWJRE64s = this->FindJavaFromRegistryKey(KEY_WOW64_64KEY, "SOFTWARE\\JavaSoft\\JRE", "JavaHome"); + QList NEWJDK64s = this->FindJavaFromRegistryKey(KEY_WOW64_64KEY, "SOFTWARE\\JavaSoft\\JDK", "JavaHome"); + QList NEWJRE32s = this->FindJavaFromRegistryKey(KEY_WOW64_32KEY, "SOFTWARE\\JavaSoft\\JRE", "JavaHome"); + QList NEWJDK32s = this->FindJavaFromRegistryKey(KEY_WOW64_32KEY, "SOFTWARE\\JavaSoft\\JDK", "JavaHome"); + + // AdoptOpenJDK + QList ADOPTOPENJRE32s = + this->FindJavaFromRegistryKey(KEY_WOW64_32KEY, "SOFTWARE\\AdoptOpenJDK\\JRE", "Path", "\\hotspot\\MSI"); + QList ADOPTOPENJRE64s = + this->FindJavaFromRegistryKey(KEY_WOW64_64KEY, "SOFTWARE\\AdoptOpenJDK\\JRE", "Path", "\\hotspot\\MSI"); + QList ADOPTOPENJDK32s = + this->FindJavaFromRegistryKey(KEY_WOW64_32KEY, "SOFTWARE\\AdoptOpenJDK\\JDK", "Path", "\\hotspot\\MSI"); + QList ADOPTOPENJDK64s = + this->FindJavaFromRegistryKey(KEY_WOW64_64KEY, "SOFTWARE\\AdoptOpenJDK\\JDK", "Path", "\\hotspot\\MSI"); + + // Eclipse Foundation + QList FOUNDATIONJDK32s = + this->FindJavaFromRegistryKey(KEY_WOW64_32KEY, "SOFTWARE\\Eclipse Foundation\\JDK", "Path", "\\hotspot\\MSI"); + QList FOUNDATIONJDK64s = + this->FindJavaFromRegistryKey(KEY_WOW64_64KEY, "SOFTWARE\\Eclipse Foundation\\JDK", "Path", "\\hotspot\\MSI"); + + // Eclipse Adoptium + QList ADOPTIUMJRE32s = + this->FindJavaFromRegistryKey(KEY_WOW64_32KEY, "SOFTWARE\\Eclipse Adoptium\\JRE", "Path", "\\hotspot\\MSI"); + QList ADOPTIUMJRE64s = + this->FindJavaFromRegistryKey(KEY_WOW64_64KEY, "SOFTWARE\\Eclipse Adoptium\\JRE", "Path", "\\hotspot\\MSI"); + QList ADOPTIUMJDK32s = + this->FindJavaFromRegistryKey(KEY_WOW64_32KEY, "SOFTWARE\\Eclipse Adoptium\\JDK", "Path", "\\hotspot\\MSI"); + QList ADOPTIUMJDK64s = + this->FindJavaFromRegistryKey(KEY_WOW64_64KEY, "SOFTWARE\\Eclipse Adoptium\\JDK", "Path", "\\hotspot\\MSI"); + + // IBM Semeru + QList SEMERUJRE32s = this->FindJavaFromRegistryKey(KEY_WOW64_32KEY, "SOFTWARE\\Semeru\\JRE", "Path", "\\openj9\\MSI"); + QList SEMERUJRE64s = this->FindJavaFromRegistryKey(KEY_WOW64_64KEY, "SOFTWARE\\Semeru\\JRE", "Path", "\\openj9\\MSI"); + QList SEMERUJDK32s = this->FindJavaFromRegistryKey(KEY_WOW64_32KEY, "SOFTWARE\\Semeru\\JDK", "Path", "\\openj9\\MSI"); + QList SEMERUJDK64s = this->FindJavaFromRegistryKey(KEY_WOW64_64KEY, "SOFTWARE\\Semeru\\JDK", "Path", "\\openj9\\MSI"); + + // Microsoft + QList MICROSOFTJDK64s = + this->FindJavaFromRegistryKey(KEY_WOW64_64KEY, "SOFTWARE\\Microsoft\\JDK", "Path", "\\hotspot\\MSI"); + + // Azul Zulu + QList ZULU64s = this->FindJavaFromRegistryKey(KEY_WOW64_64KEY, "SOFTWARE\\Azul Systems\\Zulu", "InstallationPath"); + QList ZULU32s = this->FindJavaFromRegistryKey(KEY_WOW64_32KEY, "SOFTWARE\\Azul Systems\\Zulu", "InstallationPath"); + + // BellSoft Liberica + QList LIBERICA64s = this->FindJavaFromRegistryKey(KEY_WOW64_64KEY, "SOFTWARE\\BellSoft\\Liberica", "InstallationPath"); + QList LIBERICA32s = this->FindJavaFromRegistryKey(KEY_WOW64_32KEY, "SOFTWARE\\BellSoft\\Liberica", "InstallationPath"); + + // List x64 before x86 + java_candidates.append(JRE64s); + java_candidates.append(NEWJRE64s); + java_candidates.append(ADOPTOPENJRE64s); + java_candidates.append(ADOPTIUMJRE64s); + java_candidates.append(SEMERUJRE64s); + java_candidates.append(MakeJavaPtr("C:/Program Files/Java/jre8/bin/javaw.exe")); + java_candidates.append(MakeJavaPtr("C:/Program Files/Java/jre7/bin/javaw.exe")); + java_candidates.append(MakeJavaPtr("C:/Program Files/Java/jre6/bin/javaw.exe")); + java_candidates.append(JDK64s); + java_candidates.append(NEWJDK64s); + java_candidates.append(ADOPTOPENJDK64s); + java_candidates.append(FOUNDATIONJDK64s); + java_candidates.append(ADOPTIUMJDK64s); + java_candidates.append(SEMERUJDK64s); + java_candidates.append(MICROSOFTJDK64s); + java_candidates.append(ZULU64s); + java_candidates.append(LIBERICA64s); + + java_candidates.append(JRE32s); + java_candidates.append(NEWJRE32s); + java_candidates.append(ADOPTOPENJRE32s); + java_candidates.append(ADOPTIUMJRE32s); + java_candidates.append(SEMERUJRE32s); + java_candidates.append(MakeJavaPtr("C:/Program Files (x86)/Java/jre8/bin/javaw.exe")); + java_candidates.append(MakeJavaPtr("C:/Program Files (x86)/Java/jre7/bin/javaw.exe")); + java_candidates.append(MakeJavaPtr("C:/Program Files (x86)/Java/jre6/bin/javaw.exe")); + java_candidates.append(JDK32s); + java_candidates.append(NEWJDK32s); + java_candidates.append(ADOPTOPENJDK32s); + java_candidates.append(FOUNDATIONJDK32s); + java_candidates.append(ADOPTIUMJDK32s); + java_candidates.append(SEMERUJDK32s); + java_candidates.append(ZULU32s); + java_candidates.append(LIBERICA32s); + + java_candidates.append(MakeJavaPtr(this->GetDefaultJava()->path)); + + QList candidates; + for (JavaInstallPtr java_candidate : java_candidates) { + if (!candidates.contains(java_candidate->path)) { + candidates.append(java_candidate->path); + } + } + + candidates.append(getMinecraftJavaBundle()); + candidates.append(getPrismJavaBundle()); + candidates = addJavasFromEnv(candidates); + candidates.removeDuplicates(); + return candidates; +} + +#elif defined(Q_OS_MAC) +QList JavaUtils::FindJavaPaths() +{ + QList javas; + javas.append(this->GetDefaultJava()->path); + javas.append("/Applications/Xcode.app/Contents/Applications/Application Loader.app/Contents/MacOS/itms/java/bin/java"); + javas.append("/Library/Internet Plug-Ins/JavaAppletPlugin.plugin/Contents/Home/bin/java"); + javas.append("/System/Library/Frameworks/JavaVM.framework/Versions/Current/Commands/java"); + QDir libraryJVMDir("/Library/Java/JavaVirtualMachines/"); + QStringList libraryJVMJavas = libraryJVMDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot); + for (const QString& java : libraryJVMJavas) { + javas.append(libraryJVMDir.absolutePath() + "/" + java + "/Contents/Home/bin/java"); + javas.append(libraryJVMDir.absolutePath() + "/" + java + "/Contents/Home/jre/bin/java"); + } + QDir systemLibraryJVMDir("/System/Library/Java/JavaVirtualMachines/"); + QStringList systemLibraryJVMJavas = systemLibraryJVMDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot); + for (const QString& java : systemLibraryJVMJavas) { + javas.append(systemLibraryJVMDir.absolutePath() + "/" + java + "/Contents/Home/bin/java"); + javas.append(systemLibraryJVMDir.absolutePath() + "/" + java + "/Contents/Commands/java"); + } + + auto home = qEnvironmentVariable("HOME"); + + // javas downloaded by sdkman + QString sdkmanDir = qEnvironmentVariable("SDKMAN_DIR", FS::PathCombine(home, ".sdkman")); + QDir sdkmanJavaDir(FS::PathCombine(sdkmanDir, "candidates/java")); + QStringList sdkmanJavas = sdkmanJavaDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot); + for (const QString& java : sdkmanJavas) { + javas.append(sdkmanJavaDir.absolutePath() + "/" + java + "/bin/java"); + } + + // javas downloaded by asdf + QString asdfDataDir = qEnvironmentVariable("ASDF_DATA_DIR", FS::PathCombine(home, ".asdf")); + QDir asdfJavaDir(FS::PathCombine(asdfDataDir, "installs/java")); + QStringList asdfJavas = asdfJavaDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot); + for (const QString& java : asdfJavas) { + javas.append(asdfJavaDir.absolutePath() + "/" + java + "/bin/java"); + } + + // java in user library folder (like from intellij downloads) + QDir userLibraryJVMDir(FS::PathCombine(home, "Library/Java/JavaVirtualMachines/")); + QStringList userLibraryJVMJavas = userLibraryJVMDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot); + for (const QString& java : userLibraryJVMJavas) { + javas.append(userLibraryJVMDir.absolutePath() + "/" + java + "/Contents/Home/bin/java"); + javas.append(userLibraryJVMDir.absolutePath() + "/" + java + "/Contents/Commands/java"); + } + + javas.append(getMinecraftJavaBundle()); + javas.append(getPrismJavaBundle()); + javas = addJavasFromEnv(javas); + javas.removeDuplicates(); + return javas; +} + +#elif defined(Q_OS_LINUX) || defined(Q_OS_OPENBSD) || defined(Q_OS_FREEBSD) +QList JavaUtils::FindJavaPaths() +{ + QList javas; + javas.append(this->GetDefaultJava()->path); + auto scanJavaDir = [&javas]( + const QString& dirPath, + const std::function& filter = [](const QFileInfo&) { return true; }) { + QDir dir(dirPath); + if (!dir.exists()) + return; + auto entries = dir.entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot); + for (auto& entry : entries) { + if (!filter(entry)) + continue; + + QString prefix; + prefix = entry.canonicalFilePath(); + javas.append(FS::PathCombine(prefix, "jre/bin/java")); + javas.append(FS::PathCombine(prefix, "bin/java")); + } + }; + // java installed in a snap is installed in the standard directory, but underneath $SNAP + auto snap = qEnvironmentVariable("SNAP"); + auto scanJavaDirs = [scanJavaDir, snap](const QString& dirPath) { + scanJavaDir(dirPath); + if (!snap.isNull()) { + scanJavaDir(snap + dirPath); + } + }; +#if defined(Q_OS_LINUX) + // oracle RPMs + scanJavaDirs("/usr/java"); + // general locations used by distro packaging + scanJavaDirs("/usr/lib/jvm"); + scanJavaDirs("/usr/lib64/jvm"); + scanJavaDirs("/usr/lib32/jvm"); + // Gentoo's locations for openjdk and openjdk-bin respectively + auto gentooFilter = [](const QFileInfo& info) { + QString fileName = info.fileName(); + return fileName.startsWith("openjdk-") || fileName.startsWith("openj9-"); + }; + // AOSC OS's locations for openjdk + auto aoscFilter = [](const QFileInfo& info) { + QString fileName = info.fileName(); + return fileName == "java" || fileName.startsWith("java-"); + }; + scanJavaDir("/usr/lib64", gentooFilter); + scanJavaDir("/usr/lib", gentooFilter); + scanJavaDir("/opt", gentooFilter); + scanJavaDir("/usr/lib", aoscFilter); + // javas stored in Prism Launcher's folder + scanJavaDirs("java"); + // manually installed JDKs in /opt + scanJavaDirs("/opt/jdk"); + scanJavaDirs("/opt/jdks"); + scanJavaDirs("/opt/ibm"); // IBM Semeru Certified Edition + // flatpak + scanJavaDirs("/app/jdk"); +#elif defined(Q_OS_OPENBSD) || defined(Q_OS_FREEBSD) + // ports install to /usr/local on OpenBSD & FreeBSD + scanJavaDirs("/usr/local"); +#endif + auto home = qEnvironmentVariable("HOME"); + + // javas downloaded by IntelliJ + scanJavaDirs(FS::PathCombine(home, ".jdks")); + // javas downloaded by sdkman + QString sdkmanDir = qEnvironmentVariable("SDKMAN_DIR", FS::PathCombine(home, ".sdkman")); + scanJavaDirs(FS::PathCombine(sdkmanDir, "candidates/java")); + // javas downloaded by asdf + QString asdfDataDir = qEnvironmentVariable("ASDF_DATA_DIR", FS::PathCombine(home, ".asdf")); + scanJavaDirs(FS::PathCombine(asdfDataDir, "installs/java")); + // javas downloaded by gradle (toolchains) + QString gradleUserHome = qEnvironmentVariable("GRADLE_USER_HOME", FS::PathCombine(home, ".gradle")); + scanJavaDirs(FS::PathCombine(gradleUserHome, "jdks")); + + javas.append(getMinecraftJavaBundle()); + javas.append(getPrismJavaBundle()); + javas = addJavasFromEnv(javas); + javas.removeDuplicates(); + return javas; +} +#else +QList JavaUtils::FindJavaPaths() +{ + qDebug() << "Unknown operating system build - defaulting to \"java\""; + + QList javas; + javas.append(this->GetDefaultJava()->path); + + javas.append(getMinecraftJavaBundle()); + javas.append(getPrismJavaBundle()); + javas.removeDuplicates(); + return addJavasFromEnv(javas); +} +#endif + +QString JavaUtils::getJavaCheckPath() +{ + return APPLICATION->getJarPath("JavaCheck.jar"); +} + +QStringList getMinecraftJavaBundle() +{ + QStringList processpaths; +#if defined(Q_OS_MACOS) + processpaths << FS::PathCombine(QDir::homePath(), FS::PathCombine("Library", "Application Support", "minecraft", "runtime")); +#elif defined(Q_OS_WIN32) + + auto appDataPath = QProcessEnvironment::systemEnvironment().value("APPDATA", ""); + processpaths << FS::PathCombine(QFileInfo(appDataPath).absoluteFilePath(), ".minecraft", "runtime"); + + // add the microsoft store version of the launcher to the search. the current path is: + // C:\Users\USERNAME\AppData\Local\Packages\Microsoft.4297127D64EC6_8wekyb3d8bbwe\LocalCache\Local\runtime + auto localAppDataPath = QProcessEnvironment::systemEnvironment().value("LOCALAPPDATA", ""); + auto minecraftMSStorePath = + FS::PathCombine(QFileInfo(localAppDataPath).absoluteFilePath(), "Packages", "Microsoft.4297127D64EC6_8wekyb3d8bbwe"); + processpaths << FS::PathCombine(minecraftMSStorePath, "LocalCache", "Local", "runtime"); +#else + processpaths << FS::PathCombine(QDir::homePath(), ".minecraft", "runtime"); +#endif + + QStringList javas; + while (!processpaths.isEmpty()) { + auto dirPath = processpaths.takeFirst(); + QDir dir(dirPath); + if (!dir.exists()) + continue; + auto entries = dir.entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot); + auto binFound = false; + for (auto& entry : entries) { + if (entry.baseName() == "bin") { + javas.append(FS::PathCombine(entry.canonicalFilePath(), JavaUtils::javaExecutable)); + binFound = true; + break; + } + } + if (!binFound) { + for (auto& entry : entries) { + processpaths << entry.canonicalFilePath(); + } + } + } + return javas; +} + +#if defined(Q_OS_WIN32) +const QString JavaUtils::javaExecutable = "javaw.exe"; +#else +const QString JavaUtils::javaExecutable = "java"; +#endif + +QStringList getPrismJavaBundle() +{ + QList javas; + + auto scanDir = [&javas](QString prefix) { + javas.append(FS::PathCombine(prefix, "jre", "bin", JavaUtils::javaExecutable)); + javas.append(FS::PathCombine(prefix, "bin", JavaUtils::javaExecutable)); + javas.append(FS::PathCombine(prefix, JavaUtils::javaExecutable)); + }; + auto scanJavaDir = [scanDir](const QString& dirPath) { + QDir dir(dirPath); + if (!dir.exists()) + return; + auto entries = dir.entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot); + for (auto& entry : entries) { + scanDir(entry.canonicalFilePath()); + } + }; + + scanJavaDir(APPLICATION->javaPath()); + + return javas; +} diff --git a/launcher/java/JavaUtils.h b/launcher/java/JavaUtils.h new file mode 100644 index 0000000..eb3a173 --- /dev/null +++ b/launcher/java/JavaUtils.h @@ -0,0 +1,46 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include "java/JavaInstall.h" + +#ifdef Q_OS_WIN +#include +#endif + +QString stripVariableEntries(QString name, QString target, QString remove); +QProcessEnvironment CleanEnviroment(); +QStringList getMinecraftJavaBundle(); +QStringList getPrismJavaBundle(); + +class JavaUtils : public QObject { + Q_OBJECT + public: + JavaUtils(); + + JavaInstallPtr MakeJavaPtr(QString path, QString id = "unknown", QString arch = "unknown"); + QList FindJavaPaths(); + JavaInstallPtr GetDefaultJava(); + +#ifdef Q_OS_WIN + QList FindJavaFromRegistryKey(DWORD keyType, QString keyName, QString keyJavaDir, QString subkeySuffix = ""); +#endif + + static QString getJavaCheckPath(); + static const QString javaExecutable; +}; diff --git a/launcher/java/JavaVersion.cpp b/launcher/java/JavaVersion.cpp new file mode 100644 index 0000000..fef573c --- /dev/null +++ b/launcher/java/JavaVersion.cpp @@ -0,0 +1,136 @@ +#include "JavaVersion.h" + +#include "StringUtils.h" + +#include +#include + +JavaVersion& JavaVersion::operator=(const QString& javaVersionString) +{ + m_string = javaVersionString; + + auto getCapturedInteger = [](const QRegularExpressionMatch& match, const QString& what) -> int { + auto str = match.captured(what); + if (str.isEmpty()) { + return 0; + } + return str.toInt(); + }; + + QRegularExpression pattern; + if (javaVersionString.startsWith("1.")) { + static const QRegularExpression s_withOne( + "1[.](?[0-9]+)([.](?[0-9]+))?(_(?[0-9]+)?)?(-(?[a-zA-Z0-9]+))?"); + pattern = s_withOne; + } else { + static const QRegularExpression s_withoutOne( + "(?[0-9]+)([.](?[0-9]+))?([.](?[0-9]+))?(-(?[a-zA-Z0-9]+))?"); + pattern = s_withoutOne; + } + + auto match = pattern.match(m_string); + m_parseable = match.hasMatch(); + m_major = getCapturedInteger(match, "major"); + m_minor = getCapturedInteger(match, "minor"); + m_security = getCapturedInteger(match, "security"); + m_prerelease = match.captured("prerelease"); + return *this; +} + +JavaVersion::JavaVersion(const QString& rhs) +{ + operator=(rhs); +} + +QString JavaVersion::toString() const +{ + return m_string; +} + +bool JavaVersion::requiresPermGen() const +{ + return !m_parseable || m_major < 8; +} + +bool JavaVersion::defaultsToUtf8() const +{ + // starting from Java 18, UTF-8 is the default charset: https://openjdk.org/jeps/400 + return m_parseable && m_major >= 18; +} + +bool JavaVersion::isModular() const +{ + return m_parseable && m_major >= 9; +} + +bool JavaVersion::operator<(const JavaVersion& rhs) const +{ + if (m_parseable && rhs.m_parseable) { + auto major = m_major; + auto rmajor = rhs.m_major; + + if (major < rmajor) + return true; + if (major > rmajor) + return false; + if (m_minor < rhs.m_minor) + return true; + if (m_minor > rhs.m_minor) + return false; + if (m_security < rhs.m_security) + return true; + if (m_security > rhs.m_security) + return false; + + // everything else being equal, consider prerelease status + bool thisPre = !m_prerelease.isEmpty(); + bool rhsPre = !rhs.m_prerelease.isEmpty(); + if (thisPre && !rhsPre) { + // this is a prerelease and the other one isn't -> lesser + return true; + } else if (!thisPre && rhsPre) { + // this isn't a prerelease and the other one is -> greater + return false; + } else if (thisPre && rhsPre) { + // both are prereleases - use natural compare... + return StringUtils::naturalCompare(m_prerelease, rhs.m_prerelease, Qt::CaseSensitive) < 0; + } + // neither is prerelease, so they are the same -> this cannot be less than rhs + return false; + } else + return StringUtils::naturalCompare(m_string, rhs.m_string, Qt::CaseSensitive) < 0; +} + +bool JavaVersion::operator==(const JavaVersion& rhs) const +{ + if (m_parseable && rhs.m_parseable) { + return m_major == rhs.m_major && m_minor == rhs.m_minor && m_security == rhs.m_security && m_prerelease == rhs.m_prerelease; + } + return m_string == rhs.m_string; +} + +bool JavaVersion::operator>(const JavaVersion& rhs) const +{ + return (!operator<(rhs)) && (!operator==(rhs)); +} + +JavaVersion::JavaVersion(int major, int minor, int security, int build, QString name) + : m_major(major), m_minor(minor), m_security(security), m_name(name), m_parseable(true) +{ + QStringList versions; + if (build != 0) { + m_prerelease = QString::number(build); + versions.push_front(m_prerelease); + } + if (m_security != 0) + versions.push_front(QString::number(m_security)); + else if (!versions.isEmpty()) + versions.push_front("0"); + + if (m_minor != 0) + versions.push_front(QString::number(m_minor)); + else if (!versions.isEmpty()) + versions.push_front("0"); + versions.push_front(QString::number(m_major)); + m_string = versions.join("."); +} diff --git a/launcher/java/JavaVersion.h b/launcher/java/JavaVersion.h new file mode 100644 index 0000000..143ddd2 --- /dev/null +++ b/launcher/java/JavaVersion.h @@ -0,0 +1,47 @@ +#pragma once + +#include + +// NOTE: apparently the GNU C library pollutes the global namespace with these... undef them. +#ifdef major +#undef major +#endif +#ifdef minor +#undef minor +#endif + +class JavaVersion { + friend class JavaVersionTest; + + public: + JavaVersion() {} + JavaVersion(const QString& rhs); + JavaVersion(int major, int minor, int security, int build = 0, QString name = ""); + + JavaVersion& operator=(const QString& rhs); + + bool operator<(const JavaVersion& rhs) const; + bool operator==(const JavaVersion& rhs) const; + bool operator>(const JavaVersion& rhs) const; + + bool requiresPermGen() const; + bool defaultsToUtf8() const; + bool isModular() const; + + QString toString() const; + + int major() const { return m_major; } + int minor() const { return m_minor; } + int security() const { return m_security; } + QString build() const { return m_prerelease; } + QString name() const { return m_name; } + + private: + QString m_string; + int m_major = 0; + int m_minor = 0; + int m_security = 0; + QString m_name = ""; + bool m_parseable = false; + QString m_prerelease; +}; diff --git a/launcher/java/download/ArchiveDownloadTask.cpp b/launcher/java/download/ArchiveDownloadTask.cpp new file mode 100644 index 0000000..c60908c --- /dev/null +++ b/launcher/java/download/ArchiveDownloadTask.cpp @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023-2024 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +#include "java/download/ArchiveDownloadTask.h" +#include + +#include "Application.h" +#include "archive/ArchiveReader.h" +#include "archive/ExtractZipTask.h" +#include "net/ChecksumValidator.h" +#include "net/NetJob.h" +#include "tasks/Task.h" + +namespace Java { +ArchiveDownloadTask::ArchiveDownloadTask(QUrl url, QString final_path, QString checksumType, QString checksumHash) + : m_url(url), m_final_path(final_path), m_checksum_type(checksumType), m_checksum_hash(checksumHash) +{} + +void ArchiveDownloadTask::executeTask() +{ + // JRE found ! download the zip + setStatus(tr("Downloading Java")); + + MetaEntryPtr entry = APPLICATION->metacache()->resolveEntry("java", m_url.fileName()); + + auto download = makeShared(QString("JRE::DownloadJava"), APPLICATION->network()); + auto action = Net::Download::makeCached(m_url, entry); + if (!m_checksum_hash.isEmpty() && !m_checksum_type.isEmpty()) { + auto hashType = QCryptographicHash::Algorithm::Sha1; + if (m_checksum_type == "sha256") { + hashType = QCryptographicHash::Algorithm::Sha256; + } + action->addValidator(new Net::ChecksumValidator(hashType, QByteArray::fromHex(m_checksum_hash.toUtf8()))); + } + download->addNetAction(action); + auto fullPath = entry->getFullPath(); + + connect(download.get(), &Task::failed, this, &ArchiveDownloadTask::emitFailed); + connect(download.get(), &Task::progress, this, &ArchiveDownloadTask::setProgress); + connect(download.get(), &Task::stepProgress, this, &ArchiveDownloadTask::propagateStepProgress); + connect(download.get(), &Task::status, this, &ArchiveDownloadTask::setStatus); + connect(download.get(), &Task::details, this, &ArchiveDownloadTask::setDetails); + connect(download.get(), &Task::aborted, this, &ArchiveDownloadTask::emitAborted); + connect(download.get(), &Task::succeeded, [this, fullPath] { + // This should do all of the extracting and creating folders + extractJava(fullPath); + }); + m_task = download; + m_task->start(); +} + +void ArchiveDownloadTask::extractJava(QString input) +{ + setStatus(tr("Extracting Java")); + + MMCZip::ArchiveReader zip(input); + if (!zip.collectFiles()) { + emitFailed(tr("Unable to open supplied zip file.")); + return; + } + auto files = zip.getFiles(); + if (files.isEmpty()) { + emitFailed(tr("No files were found in the supplied zip file.")); + return; + } + auto firstFolderParts = files[0].split('/', Qt::SkipEmptyParts); + m_task = makeShared(input, m_final_path, firstFolderParts.value(0)); + + auto progressStep = std::make_shared(); + connect(m_task.get(), &Task::finished, this, [this, progressStep] { + progressStep->state = TaskStepState::Succeeded; + stepProgress(*progressStep); + }); + + connect(m_task.get(), &Task::succeeded, this, &ArchiveDownloadTask::emitSucceeded); + connect(m_task.get(), &Task::aborted, this, &ArchiveDownloadTask::emitAborted); + connect(m_task.get(), &Task::failed, this, [this, progressStep](QString reason) { + progressStep->state = TaskStepState::Failed; + stepProgress(*progressStep); + emitFailed(reason); + }); + connect(m_task.get(), &Task::stepProgress, this, &ArchiveDownloadTask::propagateStepProgress); + + connect(m_task.get(), &Task::progress, this, [this, progressStep](qint64 current, qint64 total) { + progressStep->update(current, total); + stepProgress(*progressStep); + }); + connect(m_task.get(), &Task::status, this, [this, progressStep](QString status) { + progressStep->status = status; + stepProgress(*progressStep); + }); + m_task->start(); + return; +} + +bool ArchiveDownloadTask::abort() +{ + auto aborted = canAbort(); + if (m_task) + aborted = m_task->abort(); + return aborted; +}; +} // namespace Java diff --git a/launcher/java/download/ArchiveDownloadTask.h b/launcher/java/download/ArchiveDownloadTask.h new file mode 100644 index 0000000..cfcdf9d --- /dev/null +++ b/launcher/java/download/ArchiveDownloadTask.h @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023-2024 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include "tasks/Task.h" + +namespace Java { +class ArchiveDownloadTask : public Task { + Q_OBJECT + public: + ArchiveDownloadTask(QUrl url, QString final_path, QString checksumType = "", QString checksumHash = ""); + virtual ~ArchiveDownloadTask() = default; + + bool canAbort() const override { return true; } + void executeTask() override; + virtual bool abort() override; + + private slots: + void extractJava(QString input); + + protected: + QUrl m_url; + QString m_final_path; + QString m_checksum_type; + QString m_checksum_hash; + Task::Ptr m_task; +}; +} // namespace Java diff --git a/launcher/java/download/ManifestDownloadTask.cpp b/launcher/java/download/ManifestDownloadTask.cpp new file mode 100644 index 0000000..0a51741 --- /dev/null +++ b/launcher/java/download/ManifestDownloadTask.cpp @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023-2024 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +#include "java/download/ManifestDownloadTask.h" + +#include "Application.h" +#include "FileSystem.h" +#include "Json.h" +#include "net/ChecksumValidator.h" +#include "net/NetJob.h" + +struct File { + QString path; + QString url; + QByteArray hash; + bool isExec; +}; + +namespace Java { +ManifestDownloadTask::ManifestDownloadTask(QUrl url, QString final_path, QString checksumType, QString checksumHash) + : m_url(url), m_final_path(final_path), m_checksum_type(checksumType), m_checksum_hash(checksumHash) +{} + +void ManifestDownloadTask::executeTask() +{ + setStatus(tr("Downloading Java")); + auto download = makeShared(QString("JRE::DownloadJava"), APPLICATION->network()); + + auto [action, files] = Net::Download::makeByteArray(m_url); + if (!m_checksum_hash.isEmpty() && !m_checksum_type.isEmpty()) { + auto hashType = QCryptographicHash::Algorithm::Sha1; + if (m_checksum_type == "sha256") { + hashType = QCryptographicHash::Algorithm::Sha256; + } + action->addValidator(new Net::ChecksumValidator(hashType, QByteArray::fromHex(m_checksum_hash.toUtf8()))); + } + download->addNetAction(action); + + connect(download.get(), &Task::failed, this, &ManifestDownloadTask::emitFailed); + connect(download.get(), &Task::progress, this, &ManifestDownloadTask::setProgress); + connect(download.get(), &Task::stepProgress, this, &ManifestDownloadTask::propagateStepProgress); + connect(download.get(), &Task::status, this, &ManifestDownloadTask::setStatus); + connect(download.get(), &Task::details, this, &ManifestDownloadTask::setDetails); + + connect(download.get(), &Task::succeeded, [files, this] { + QJsonParseError parse_error{}; + QJsonDocument doc = QJsonDocument::fromJson(*files, &parse_error); + if (parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response at" << parse_error.offset << "reason:" << parse_error.errorString(); + qWarning() << *files; + emitFailed(parse_error.errorString()); + return; + } + downloadJava(doc); + }); + m_task = download; + m_task->start(); +} + +void ManifestDownloadTask::downloadJava(const QJsonDocument& doc) +{ + // valid json doc, begin making jre spot + FS::ensureFolderPathExists(m_final_path); + std::vector toDownload; + auto list = doc.object()["files"].toObject(); + for (const auto& paths : list.keys()) { + auto file = FS::PathCombine(m_final_path, paths); + + const QJsonObject& meta = list[paths].toObject(); + auto type = meta["type"].toString(); + if (type == "directory") { + FS::ensureFolderPathExists(file); + } else if (type == "link") { + // this is *nix only ! + auto path = meta["target"].toString(); + if (!path.isEmpty()) { + QFile::link(path, file); + } + } else if (type == "file") { + // TODO download compressed version if it exists ? + auto raw = meta["downloads"].toObject()["raw"].toObject(); + auto isExec = meta["executable"].toBool(); + auto url = raw["url"].toString(); + if (!url.isEmpty() && QUrl(url).isValid()) { + auto f = File{ file, url, QByteArray::fromHex(raw["sha1"].toString().toLatin1()), isExec }; + toDownload.push_back(f); + } + } + } + auto elementDownload = makeShared("JRE::FileDownload", APPLICATION->network()); + for (const auto& file : toDownload) { + auto dl = Net::Download::makeFile(file.url, file.path); + if (!file.hash.isEmpty()) { + dl->addValidator(new Net::ChecksumValidator(QCryptographicHash::Sha1, file.hash)); + } + if (file.isExec) { + connect(dl.get(), &Net::Download::succeeded, + [file] { QFile(file.path).setPermissions(QFile(file.path).permissions() | QFileDevice::Permissions(0x1111)); }); + } + elementDownload->addNetAction(dl); + } + + connect(elementDownload.get(), &Task::failed, this, &ManifestDownloadTask::emitFailed); + connect(elementDownload.get(), &Task::progress, this, &ManifestDownloadTask::setProgress); + connect(elementDownload.get(), &Task::stepProgress, this, &ManifestDownloadTask::propagateStepProgress); + connect(elementDownload.get(), &Task::status, this, &ManifestDownloadTask::setStatus); + connect(elementDownload.get(), &Task::details, this, &ManifestDownloadTask::setDetails); + + connect(elementDownload.get(), &Task::succeeded, this, &ManifestDownloadTask::emitSucceeded); + m_task = elementDownload; + m_task->start(); +} + +bool ManifestDownloadTask::abort() +{ + auto aborted = canAbort(); + if (m_task) + aborted = m_task->abort(); + emitAborted(); + return aborted; +}; +} // namespace Java diff --git a/launcher/java/download/ManifestDownloadTask.h b/launcher/java/download/ManifestDownloadTask.h new file mode 100644 index 0000000..e68c823 --- /dev/null +++ b/launcher/java/download/ManifestDownloadTask.h @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023-2024 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include "tasks/Task.h" + +namespace Java { + +class ManifestDownloadTask : public Task { + Q_OBJECT + public: + ManifestDownloadTask(QUrl url, QString final_path, QString checksumType = "", QString checksumHash = ""); + virtual ~ManifestDownloadTask() = default; + + bool canAbort() const override { return true; } + void executeTask() override; + virtual bool abort() override; + + private slots: + void downloadJava(const QJsonDocument& doc); + + protected: + QUrl m_url; + QString m_final_path; + QString m_checksum_type; + QString m_checksum_hash; + Task::Ptr m_task; +}; +} // namespace Java diff --git a/launcher/java/download/SymlinkTask.cpp b/launcher/java/download/SymlinkTask.cpp new file mode 100644 index 0000000..9bbd50c --- /dev/null +++ b/launcher/java/download/SymlinkTask.cpp @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023-2024 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +#include "java/download/SymlinkTask.h" +#include + +#include "FileSystem.h" + +namespace Java { +SymlinkTask::SymlinkTask(QString final_path) : m_path(final_path) {} + +QString findBinPath(QString root, QString pattern) +{ + auto path = FS::PathCombine(root, pattern); + if (QFileInfo::exists(path)) { + return path; + } + + auto entries = QDir(root).entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot); + for (auto& entry : entries) { + path = FS::PathCombine(entry.absoluteFilePath(), pattern); + if (QFileInfo::exists(path)) { + return path; + } + } + + return {}; +} + +void SymlinkTask::executeTask() +{ + setStatus(tr("Checking for Java binary path")); + const auto binPath = FS::PathCombine("bin", "java"); + const auto wantedPath = FS::PathCombine(m_path, binPath); + if (QFileInfo::exists(wantedPath)) { + emitSucceeded(); + return; + } + + setStatus(tr("Searching for Java binary path")); + const auto contentsPartialPath = FS::PathCombine("Contents", "Home", binPath); + const auto relativePathToBin = findBinPath(m_path, contentsPartialPath); + if (relativePathToBin.isEmpty()) { + emitFailed(tr("Failed to find Java binary path")); + return; + } + const auto folderToLink = relativePathToBin.chopped(binPath.length()); + + setStatus(tr("Collecting folders to symlink")); + auto entries = QDir(folderToLink).entryInfoList(QDir::NoDotAndDotDot | QDir::AllEntries); + QList files; + setProgress(0, entries.length()); + for (auto& entry : entries) { + files.append({ entry.absoluteFilePath(), FS::PathCombine(m_path, entry.fileName()) }); + } + + setStatus(tr("Symlinking Java binary path")); + FS::create_link folderLink(files); + connect(&folderLink, &FS::create_link::fileLinked, [this](QString src, QString dst) { setProgress(m_progress + 1, m_progressTotal); }); + if (!folderLink()) { + emitFailed(folderLink.getOSError().message().c_str()); + } else { + emitSucceeded(); + } +} + +} // namespace Java diff --git a/launcher/java/download/SymlinkTask.h b/launcher/java/download/SymlinkTask.h new file mode 100644 index 0000000..e38323e --- /dev/null +++ b/launcher/java/download/SymlinkTask.h @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023-2024 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include "tasks/Task.h" +namespace Java { + +class SymlinkTask : public Task { + Q_OBJECT + public: + SymlinkTask(QString final_path); + virtual ~SymlinkTask() = default; + + void executeTask() override; + + protected: + QString m_path; + Task::Ptr m_task; +}; +} // namespace Java diff --git a/launcher/launch/LaunchStep.cpp b/launcher/launch/LaunchStep.cpp new file mode 100644 index 0000000..0b352ea --- /dev/null +++ b/launcher/launch/LaunchStep.cpp @@ -0,0 +1,26 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "LaunchStep.h" +#include "LaunchTask.h" + +LaunchStep::LaunchStep(LaunchTask* parent) : Task(), m_parent(parent) +{ + connect(this, &LaunchStep::readyForLaunch, parent, &LaunchTask::onReadyForLaunch); + connect(this, &LaunchStep::logLine, parent, &LaunchTask::onLogLine); + connect(this, &LaunchStep::logLines, parent, &LaunchTask::onLogLines); + connect(this, &LaunchStep::finished, parent, &LaunchTask::onStepFinished); + connect(this, &LaunchStep::progressReportingRequest, parent, &LaunchTask::onProgressReportingRequested); +} diff --git a/launcher/launch/LaunchStep.h b/launcher/launch/LaunchStep.h new file mode 100644 index 0000000..80dcd31 --- /dev/null +++ b/launcher/launch/LaunchStep.h @@ -0,0 +1,43 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "MessageLevel.h" +#include "tasks/Task.h" + +#include + +class LaunchTask; +class LaunchStep : public Task { + Q_OBJECT + public: /* methods */ + explicit LaunchStep(LaunchTask* parent); + virtual ~LaunchStep() = default; + + signals: + void logLines(QStringList lines, MessageLevel level); + void logLine(QString line, MessageLevel level); + void readyForLaunch(); + void progressReportingRequest(); + + public slots: + virtual void proceed() {}; + // called in the opposite order than the Task launch(), used to clean up or otherwise undo things after the launch ends + virtual void finalize() {}; + + protected: /* data */ + LaunchTask* m_parent; +}; diff --git a/launcher/launch/LaunchTask.cpp b/launcher/launch/LaunchTask.cpp new file mode 100644 index 0000000..26b2b58 --- /dev/null +++ b/launcher/launch/LaunchTask.cpp @@ -0,0 +1,360 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Authors: Orochimarufan + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "launch/LaunchTask.h" +#include +#include +#include +#include +#include +#include +#include +#include "MessageLevel.h" +#include "tasks/Task.h" + +void LaunchTask::init() +{ + m_instance->setRunning(true); +} + +std::unique_ptr LaunchTask::create(MinecraftInstance* inst) +{ + auto task = std::unique_ptr(new LaunchTask(inst)); + task->init(); + return task; +} + +LaunchTask::LaunchTask(MinecraftInstance* instance) : m_instance(instance) {} + +void LaunchTask::appendStep(shared_qobject_ptr step) +{ + m_steps.append(step); +} + +void LaunchTask::prependStep(shared_qobject_ptr step) +{ + m_steps.prepend(step); +} + +void LaunchTask::executeTask() +{ + m_instance->setCrashed(false); + if (!m_steps.size()) { + state = LaunchTask::Finished; + emitSucceeded(); + return; + } + state = LaunchTask::Running; + onStepFinished(); +} + +void LaunchTask::onReadyForLaunch() +{ + state = LaunchTask::Waiting; + emit readyForLaunch(); +} + +void LaunchTask::onStepFinished() +{ + // initial -> just start the first step + if (currentStep == -1) { + currentStep++; + m_steps[currentStep]->start(); + return; + } + + auto step = m_steps[currentStep]; + if (step->wasSuccessful()) { + // end? + if (currentStep == m_steps.size() - 1) { + finalizeSteps(true, QString()); + } else { + currentStep++; + step = m_steps[currentStep]; + step->start(); + } + } else { + finalizeSteps(false, step->failReason()); + } +} + +void LaunchTask::finalizeSteps(bool successful, const QString& error) +{ + for (auto step = currentStep; step >= 0; step--) { + m_steps[step]->finalize(); + } + if (successful) { + emitSucceeded(); + } else { + emitFailed(error); + } +} + +void LaunchTask::onProgressReportingRequested() +{ + state = LaunchTask::Waiting; + emit requestProgress(m_steps[currentStep].get()); +} + +void LaunchTask::setCensorFilter(QMap filter) +{ + m_censorFilter = filter; +} + +QString LaunchTask::censorPrivateInfo(QString in) +{ + auto iter = m_censorFilter.begin(); + while (iter != m_censorFilter.end()) { + in.replace(iter.key(), iter.value()); + iter++; + } + return in; +} + +void LaunchTask::proceed() +{ + if (state != LaunchTask::Waiting) { + return; + } + m_steps[currentStep]->proceed(); +} + +bool LaunchTask::canAbort() const +{ + switch (state) { + case LaunchTask::Aborted: + case LaunchTask::Failed: + case LaunchTask::Finished: + return false; + case LaunchTask::NotStarted: + return true; + case LaunchTask::Running: + case LaunchTask::Waiting: { + auto step = m_steps[currentStep]; + return step->canAbort(); + } + } + return false; +} + +bool LaunchTask::abort() +{ + switch (state) { + case LaunchTask::Aborted: + case LaunchTask::Failed: + case LaunchTask::Finished: + return true; + case LaunchTask::NotStarted: { + state = LaunchTask::Aborted; + emitAborted(); + return true; + } + case LaunchTask::Running: + case LaunchTask::Waiting: { + auto step = m_steps[currentStep]; + if (!step->canAbort()) { + return false; + } + if (step->abort()) { + state = LaunchTask::Aborted; + return true; + } + } + default: + break; + } + return false; +} + +shared_qobject_ptr LaunchTask::getLogModel() +{ + if (!m_logModel) { + m_logModel.reset(new LogModel()); + m_logModel->setMaxLines(getConsoleMaxLines(m_instance->settings())); + m_logModel->setStopOnOverflow(shouldStopOnConsoleOverflow(m_instance->settings())); + // FIXME: should this really be here? + m_logModel->setOverflowMessage(tr("Stopped watching the game log because the log length surpassed %1 lines.\n" + "You may have to fix your mods because the game is still logging to files and" + " likely wasting harddrive space at an alarming rate!") + .arg(m_logModel->getMaxLines())); + } + return m_logModel; +} + +bool LaunchTask::parseXmlLogs(QString const& line, MessageLevel level) +{ + LogParser* parser; + switch (static_cast(level)) { + case MessageLevel::StdErr: + parser = &m_stderrParser; + break; + case MessageLevel::StdOut: + parser = &m_stdoutParser; + break; + default: + return false; + } + + parser->appendLine(line); + auto items = parser->parseAvailable(); + if (auto err = parser->getError(); err.has_value()) { + auto& model = *getLogModel(); + model.append(MessageLevel::Error, tr("[Log4j Parse Error] Failed to parse log4j log event: %1").arg(err.value().errMessage)); + return false; + } + + if (items.isEmpty()) + return true; + + auto model = getLogModel(); + for (auto const& item : items) { + if (std::holds_alternative(item)) { + auto entry = std::get(item); + auto msg = QString("[%1] [%2/%3] [%4]: %5") + .arg(entry.timestamp.toString("HH:mm:ss")) + .arg(entry.thread) + .arg(entry.levelText) + .arg(entry.logger) + .arg(entry.message); + msg = censorPrivateInfo(msg); + model->append(entry.level, msg); + } else if (std::holds_alternative(item)) { + auto msg = std::get(item).message; + + MessageLevel newLevel = MessageLevel::takeFromLine(msg); + + if (newLevel == MessageLevel::Unknown) + newLevel = LogParser::guessLevel(line, model->previousLevel()); + + msg = censorPrivateInfo(msg); + + model->append(newLevel, msg); + } + } + + return true; +} + +void LaunchTask::onLogLines(const QStringList& lines, MessageLevel defaultLevel) +{ + for (auto& line : lines) { + onLogLine(line, defaultLevel); + } +} + +void LaunchTask::onLogLine(QString line, MessageLevel level) +{ + if (parseXmlLogs(line, level)) { + return; + } + + // censor private user info + line = censorPrivateInfo(line); + + getLogModel()->append(level, line); +} + +void LaunchTask::emitSucceeded() +{ + m_instance->setRunning(false); + Task::emitSucceeded(); +} + +void LaunchTask::emitFailed(QString reason) +{ + m_instance->setRunning(false); + m_instance->setCrashed(true); + Task::emitFailed(reason); +} + +QString expandVariables(const QString& input, QProcessEnvironment dict) +{ + QString result = input; + + enum { base, maybeBrace, variable, brace } state = base; + int startIdx = -1; + for (int i = 0; i < result.length();) { + QChar c = result.at(i++); + switch (state) { + case base: + if (c == '$') + state = maybeBrace; + break; + case maybeBrace: + if (c == '{') { + state = brace; + startIdx = i; + } else if (c.isLetterOrNumber() || c == '_') { + state = variable; + startIdx = i - 1; + } else { + state = base; + } + break; + case brace: + if (c == '}') { + const auto res = dict.value(result.mid(startIdx, i - 1 - startIdx), ""); + if (!res.isEmpty()) { + result.replace(startIdx - 2, i - startIdx + 2, res); + i = startIdx - 2 + res.length(); + } + state = base; + } + break; + case variable: + if (!c.isLetterOrNumber() && c != '_') { + const auto res = dict.value(result.mid(startIdx, i - startIdx - 1), ""); + if (!res.isEmpty()) { + result.replace(startIdx - 1, i - startIdx, res); + i = startIdx - 1 + res.length(); + } + state = base; + } + break; + } + } + if (state == variable) { + if (const auto res = dict.value(result.mid(startIdx), ""); !res.isEmpty()) + result.replace(startIdx - 1, result.length() - startIdx + 1, res); + } + return result; +} + +QString LaunchTask::substituteVariables(QString& cmd, bool isLaunch) const +{ + return expandVariables(cmd, isLaunch ? m_instance->createLaunchEnvironment() : m_instance->createEnvironment()); +} diff --git a/launcher/launch/LaunchTask.h b/launcher/launch/LaunchTask.h new file mode 100644 index 0000000..c52273a --- /dev/null +++ b/launcher/launch/LaunchTask.h @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Authors: Orochimarufan + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once +#include +#include +#include +#include "LaunchStep.h" +#include "LogModel.h" +#include "MessageLevel.h" +#include "logs/LogParser.h" + +class LaunchTask : public Task { + Q_OBJECT + protected: + explicit LaunchTask(MinecraftInstance* instance); + void init(); + + public: + enum State { NotStarted, Running, Waiting, Failed, Aborted, Finished }; + + public: /* methods */ + static std::unique_ptr create(MinecraftInstance* inst); + virtual ~LaunchTask() = default; + + void appendStep(shared_qobject_ptr step); + void prependStep(shared_qobject_ptr step); + void setCensorFilter(QMap filter); + + MinecraftInstance* instance() { return m_instance; } + + void setPid(qint64 pid) { m_pid = pid; } + + qint64 pid() { return m_pid; } + + /** + * @brief prepare the process for launch (for multi-stage launch) + */ + virtual void executeTask() override; + + /** + * @brief launch the armed instance + */ + void proceed(); + + /** + * @brief abort launch + */ + bool abort() override; + + bool canAbort() const override; + + shared_qobject_ptr getLogModel(); + + public: + QString substituteVariables(QString& cmd, bool isLaunch = false) const; + QString censorPrivateInfo(QString in); + + protected: /* methods */ + virtual void emitFailed(QString reason) override; + virtual void emitSucceeded() override; + + signals: + /** + * @brief emitted when the launch preparations are done + */ + void readyForLaunch(); + + void requestProgress(Task* task); + + void requestLogging(); + + public slots: + void onLogLines(const QStringList& lines, MessageLevel defaultLevel = MessageLevel::Launcher); + void onLogLine(QString line, MessageLevel defaultLevel = MessageLevel::Launcher); + void onReadyForLaunch(); + void onStepFinished(); + void onProgressReportingRequested(); + + private: /*methods */ + void finalizeSteps(bool successful, const QString& error); + + protected: + bool parseXmlLogs(QString const& line, MessageLevel level); + + protected: /* data */ + MinecraftInstance* m_instance; + shared_qobject_ptr m_logModel; + QList> m_steps; + QMap m_censorFilter; + int currentStep = -1; + State state = NotStarted; + qint64 m_pid = -1; + LogParser m_stdoutParser; + LogParser m_stderrParser; +}; diff --git a/launcher/launch/LogModel.cpp b/launcher/launch/LogModel.cpp new file mode 100644 index 0000000..117867c --- /dev/null +++ b/launcher/launch/LogModel.cpp @@ -0,0 +1,176 @@ +#include "LogModel.h" + +LogModel::LogModel(QObject* parent) : QAbstractListModel(parent) +{ + m_content.resize(m_maxLines); +} + +int LogModel::rowCount(const QModelIndex& parent) const +{ + if (parent.isValid()) + return 0; + + return m_numLines; +} + +QVariant LogModel::data(const QModelIndex& index, int role) const +{ + if (index.row() < 0 || index.row() >= m_numLines) + return QVariant(); + + auto row = index.row(); + auto realRow = (row + m_firstLine) % m_maxLines; + if (role == Qt::DisplayRole || role == Qt::EditRole) { + return m_content[realRow].line; + } + if (role == LevelRole) { + return static_cast(m_content[realRow].level); + } + + return QVariant(); +} + +void LogModel::append(MessageLevel level, QString line) +{ + if (m_suspended) { + return; + } + int lineNum = (m_firstLine + m_numLines) % m_maxLines; + // overflow + if (m_numLines == m_maxLines) { + if (m_stopOnOverflow) { + // nothing more to do, the buffer is full + return; + } + beginRemoveRows(QModelIndex(), 0, 0); + m_firstLine = (m_firstLine + 1) % m_maxLines; + m_numLines--; + endRemoveRows(); + } else if (m_numLines == m_maxLines - 1 && m_stopOnOverflow) { + level = MessageLevel::Fatal; + line = m_overflowMessage; + } + beginInsertRows(QModelIndex(), m_numLines, m_numLines); + m_numLines++; + m_content[lineNum].level = level; + m_content[lineNum].line = line; + endInsertRows(); +} + +void LogModel::suspend(bool suspend) +{ + m_suspended = suspend; +} + +bool LogModel::suspended() +{ + return m_suspended; +} + +void LogModel::clear() +{ + beginResetModel(); + m_firstLine = 0; + m_numLines = 0; + endResetModel(); +} + +QString LogModel::toPlainText() +{ + QString out; + out.reserve(m_numLines * 80); + for (int i = 0; i < m_numLines; i++) { + QString& line = m_content[(m_firstLine + i) % m_maxLines].line; + out.append(line + '\n'); + } + out.squeeze(); + return out; +} + +void LogModel::setMaxLines(int maxLines) +{ + // no-op + if (maxLines == m_maxLines) { + return; + } + // if it all still fits in the buffer, just resize it + if (m_firstLine + m_numLines < m_maxLines) { + m_maxLines = maxLines; + m_content.resize(maxLines); + return; + } + // otherwise, we need to reorganize the data because it crosses the wrap boundary + QList newContent; + newContent.resize(maxLines); + if (m_numLines <= maxLines) { + // if it all fits in the new buffer, just copy it over + for (int i = 0; i < m_numLines; i++) { + newContent[i] = m_content[(m_firstLine + i) % m_maxLines]; + } + m_content.swap(newContent); + } else { + // if it doesn't fit, part of the data needs to be thrown away (the oldest log messages) + int lead = m_numLines - maxLines; + beginRemoveRows(QModelIndex(), 0, lead - 1); + for (int i = 0; i < maxLines; i++) { + newContent[i] = m_content[(m_firstLine + lead + i) % m_maxLines]; + } + m_numLines = m_maxLines; + m_content.swap(newContent); + endRemoveRows(); + } + m_firstLine = 0; + m_maxLines = maxLines; +} + +int LogModel::getMaxLines() +{ + return m_maxLines; +} + +void LogModel::setStopOnOverflow(bool stop) +{ + m_stopOnOverflow = stop; +} + +void LogModel::setOverflowMessage(const QString& overflowMessage) +{ + m_overflowMessage = overflowMessage; +} + +void LogModel::setLineWrap(bool state) +{ + if (m_lineWrap != state) { + m_lineWrap = state; + } +} + +bool LogModel::wrapLines() const +{ + return m_lineWrap; +} + +void LogModel::setColorLines(bool state) +{ + if (m_colorLines != state) { + m_colorLines = state; + } +} + +bool LogModel::colorLines() const +{ + return m_colorLines; +} + +bool LogModel::isOverFlow() +{ + return m_numLines >= m_maxLines && m_stopOnOverflow; +} + +MessageLevel LogModel::previousLevel() +{ + if (m_numLines > 0) { + return m_content[m_numLines - 1].level; + } + return MessageLevel::Unknown; +} diff --git a/launcher/launch/LogModel.h b/launcher/launch/LogModel.h new file mode 100644 index 0000000..847a41f --- /dev/null +++ b/launcher/launch/LogModel.h @@ -0,0 +1,59 @@ +#pragma once + +#include +#include +#include "MessageLevel.h" + +class LogModel : public QAbstractListModel { + Q_OBJECT + public: + explicit LogModel(QObject* parent = 0); + + int rowCount(const QModelIndex& parent = QModelIndex()) const; + QVariant data(const QModelIndex& index, int role) const; + + void append(MessageLevel, QString line); + void clear(); + + void suspend(bool suspend); + bool suspended(); + + QString toPlainText(); + + int getMaxLines(); + void setMaxLines(int maxLines); + void setStopOnOverflow(bool stop); + void setOverflowMessage(const QString& overflowMessage); + bool isOverFlow(); + + void setLineWrap(bool state); + bool wrapLines() const; + void setColorLines(bool state); + bool colorLines() const; + + MessageLevel previousLevel(); + + enum Roles { LevelRole = Qt::UserRole }; + + private /* types */: + struct entry { + MessageLevel level = MessageLevel::Unknown; + QString line; + }; + + private: /* data */ + QList m_content; + int m_maxLines = 1000; + // first line in the circular buffer + int m_firstLine = 0; + // number of lines occupied in the circular buffer + int m_numLines = 0; + bool m_stopOnOverflow = false; + QString m_overflowMessage = "OVERFLOW"; + bool m_suspended = false; + bool m_lineWrap = true; + bool m_colorLines = true; + + private: + Q_DISABLE_COPY(LogModel) +}; diff --git a/launcher/launch/TaskStepWrapper.cpp b/launcher/launch/TaskStepWrapper.cpp new file mode 100644 index 0000000..acf790a --- /dev/null +++ b/launcher/launch/TaskStepWrapper.cpp @@ -0,0 +1,62 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "TaskStepWrapper.h" +#include "tasks/Task.h" + +void TaskStepWrapper::executeTask() +{ + if (m_state == Task::State::AbortedByUser) { + emitFailed(tr("Task aborted.")); + return; + } + connect(m_task.get(), &Task::finished, this, &TaskStepWrapper::updateFinished); + propagateFromOther(m_task.get()); + emit progressReportingRequest(); +} + +void TaskStepWrapper::proceed() +{ + m_task->start(); +} + +void TaskStepWrapper::updateFinished() +{ + if (m_task->wasSuccessful()) { + m_task.reset(); + emitSucceeded(); + } else { + QString reason = tr("Instance update failed because: %1\n\n").arg(m_task->failReason()); + m_task.reset(); + emit logLine(reason, MessageLevel::Fatal); + emitFailed(reason); + } +} + +bool TaskStepWrapper::canAbort() const +{ + if (m_task) { + return m_task->canAbort(); + } + return true; +} + +bool TaskStepWrapper::abort() +{ + if (m_task && m_task->canAbort()) { + return m_task->abort(); + } + return Task::abort(); +} diff --git a/launcher/launch/TaskStepWrapper.h b/launcher/launch/TaskStepWrapper.h new file mode 100644 index 0000000..aec1b70 --- /dev/null +++ b/launcher/launch/TaskStepWrapper.h @@ -0,0 +1,41 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include +#include + +class TaskStepWrapper : public LaunchStep { + Q_OBJECT + public: + explicit TaskStepWrapper(LaunchTask* parent, Task::Ptr task) : LaunchStep(parent), m_task(task) {}; + virtual ~TaskStepWrapper() = default; + + void executeTask() override; + bool canAbort() const override; + void proceed() override; + public slots: + bool abort() override; + + private slots: + void updateFinished(); + + private: + Task::Ptr m_task; +}; diff --git a/launcher/launch/steps/CheckJava.cpp b/launcher/launch/steps/CheckJava.cpp new file mode 100644 index 0000000..1afd5cd --- /dev/null +++ b/launcher/launch/steps/CheckJava.cpp @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "CheckJava.h" +#include +#include +#include +#include +#include +#include "java/JavaUtils.h" + +void CheckJava::executeTask() +{ + auto instance = m_parent->instance(); + auto settings = instance->settings(); + + QString javaPathSetting = settings->get("JavaPath").toString(); + m_javaPath = FS::ResolveExecutable(javaPathSetting); + + bool perInstance = settings->get("OverrideJava").toBool() || settings->get("OverrideJavaLocation").toBool(); + + auto realJavaPath = QStandardPaths::findExecutable(m_javaPath); + if (realJavaPath.isEmpty()) { + if (perInstance) { + emit logLine(QString("The Java binary \"%1\" couldn't be found. Please fix the Java path " + "override in the instance's settings or disable it.") + .arg(javaPathSetting), + MessageLevel::Warning); + } else { + emit logLine(QString("The Java binary \"%1\" couldn't be found. Please set up Java in " + "the settings.") + .arg(javaPathSetting), + MessageLevel::Warning); + } + emitFailed(QString("Java path is not valid.")); + return; + } else { + emit logLine("Java path is:\n " + m_javaPath, MessageLevel::Launcher); + } + + if (JavaUtils::getJavaCheckPath().isEmpty()) { + const char* reason = QT_TR_NOOP("Java checker library could not be found. Please check your installation."); + emit logLine(tr(reason), MessageLevel::Fatal); + emitFailed(tr(reason)); + return; + } + + QFileInfo javaInfo(realJavaPath); + qint64 javaUnixTime = javaInfo.lastModified().toMSecsSinceEpoch(); + auto storedSignature = settings->get("JavaSignature").toString(); + auto storedArchitecture = settings->get("JavaArchitecture").toString(); + auto storedRealArchitecture = settings->get("JavaRealArchitecture").toString(); + auto storedVersion = settings->get("JavaVersion").toString(); + auto storedVendor = settings->get("JavaVendor").toString(); + + QCryptographicHash hash(QCryptographicHash::Sha1); + hash.addData(QByteArray::number(javaUnixTime)); + hash.addData(m_javaPath.toUtf8()); + m_javaSignature = hash.result().toHex(); + + // if timestamps are not the same, or something is missing, check! + if (m_javaSignature != storedSignature || storedVersion.size() == 0 || storedArchitecture.size() == 0 || + storedRealArchitecture.size() == 0 || storedVendor.size() == 0) { + m_JavaChecker.reset(new JavaChecker(realJavaPath, "", 0, 0, 0, 0)); + emit logLine(QString("Checking Java version..."), MessageLevel::Launcher); + connect(m_JavaChecker.get(), &JavaChecker::checkFinished, this, &CheckJava::checkJavaFinished); + m_JavaChecker->start(); + return; + } else { + auto verString = instance->settings()->get("JavaVersion").toString(); + auto archString = instance->settings()->get("JavaArchitecture").toString(); + auto realArchString = settings->get("JavaRealArchitecture").toString(); + auto vendorString = instance->settings()->get("JavaVendor").toString(); + printJavaInfo(verString, archString, realArchString, vendorString); + } + m_parent->instance()->updateRuntimeContext(); + emitSucceeded(); +} + +void CheckJava::checkJavaFinished(const JavaChecker::Result& result) +{ + switch (result.validity) { + case JavaChecker::Result::Validity::Errored: { + // Error message displayed if java can't start + emit logLine(QString("Could not start java:"), MessageLevel::Error); + emit logLines(result.errorLog.split('\n'), MessageLevel::Error); + emit logLine(QString("\nCheck your Java settings."), MessageLevel::Launcher); + emitFailed(QString("Could not start java!")); + return; + } + case JavaChecker::Result::Validity::ReturnedInvalidData: { + emit logLine(QString("Java checker returned some invalid data we don't understand:"), MessageLevel::Error); + emit logLines(result.outLog.split('\n'), MessageLevel::Warning); + emit logLine("\nMinecraft might not start properly.", MessageLevel::Launcher); + m_parent->instance()->updateRuntimeContext(); + emitSucceeded(); + return; + } + case JavaChecker::Result::Validity::Valid: { + auto instance = m_parent->instance(); + printJavaInfo(result.javaVersion.toString(), result.mojangPlatform, result.realPlatform, result.javaVendor); + instance->settings()->set("JavaVersion", result.javaVersion.toString()); + instance->settings()->set("JavaArchitecture", result.mojangPlatform); + instance->settings()->set("JavaRealArchitecture", result.realPlatform); + instance->settings()->set("JavaVendor", result.javaVendor); + instance->settings()->set("JavaSignature", m_javaSignature); + m_parent->instance()->updateRuntimeContext(); + emitSucceeded(); + return; + } + } +} + +void CheckJava::printJavaInfo(const QString& version, const QString& architecture, const QString& realArchitecture, const QString& vendor) +{ + emit logLine( + QString("Java is version %1, using %2 (%3) architecture, from %4").arg(version, architecture, realArchitecture, vendor), + MessageLevel::Launcher); +} diff --git a/launcher/launch/steps/CheckJava.h b/launcher/launch/steps/CheckJava.h new file mode 100644 index 0000000..1c59b00 --- /dev/null +++ b/launcher/launch/steps/CheckJava.h @@ -0,0 +1,41 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +class CheckJava : public LaunchStep { + Q_OBJECT + public: + explicit CheckJava(LaunchTask* parent) : LaunchStep(parent) {}; + virtual ~CheckJava() = default; + + virtual void executeTask(); + virtual bool canAbort() const { return false; } + private slots: + void checkJavaFinished(const JavaChecker::Result& result); + + private: + void printJavaInfo(const QString& version, const QString& architecture, const QString& realArchitecture, const QString& vendor); + void printSystemInfo(bool javaIsKnown, bool javaIs64bit); + + private: + QString m_javaPath; + QString m_javaSignature; + JavaChecker::Ptr m_JavaChecker; +}; diff --git a/launcher/launch/steps/LookupServerAddress.cpp b/launcher/launch/steps/LookupServerAddress.cpp new file mode 100644 index 0000000..cb2f5d7 --- /dev/null +++ b/launcher/launch/steps/LookupServerAddress.cpp @@ -0,0 +1,92 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "LookupServerAddress.h" + +#include + +LookupServerAddress::LookupServerAddress(LaunchTask* parent) : LaunchStep(parent), m_dnsLookup(new QDnsLookup(this)) +{ + connect(m_dnsLookup, &QDnsLookup::finished, this, &LookupServerAddress::on_dnsLookupFinished); + + m_dnsLookup->setType(QDnsLookup::SRV); +} + +void LookupServerAddress::setLookupAddress(const QString& lookupAddress) +{ + m_lookupAddress = lookupAddress; + m_dnsLookup->setName(QString("_minecraft._tcp.%1").arg(lookupAddress)); +} + +void LookupServerAddress::setOutputAddressPtr(MinecraftTarget::Ptr output) +{ + m_output = std::move(output); +} + +bool LookupServerAddress::abort() +{ + m_dnsLookup->abort(); + emitAborted(); + return true; +} + +void LookupServerAddress::executeTask() +{ + m_dnsLookup->lookup(); +} + +void LookupServerAddress::on_dnsLookupFinished() +{ + if (isFinished()) { + // Aborted + return; + } + + if (m_dnsLookup->error() != QDnsLookup::NoError) { + emit logLine(QString("Failed to resolve server address (this is NOT an error!) %1: %2\n") + .arg(m_dnsLookup->name(), m_dnsLookup->errorString()), + MessageLevel::Launcher); + resolve(m_lookupAddress, 25565); // Technically the task failed, however, we don't abort the launch + // and leave it up to minecraft to fail (or maybe not) when connecting + return; + } + + const auto records = m_dnsLookup->serviceRecords(); + if (records.empty()) { + emit logLine(QString("Failed to resolve server address %1: the DNS lookup succeeded, but no records were returned.\n") + .arg(m_dnsLookup->name()), + MessageLevel::Warning); + resolve(m_lookupAddress, 25565); // Technically the task failed, however, we don't abort the launch + // and leave it up to minecraft to fail (or maybe not) when connecting + return; + } + + const auto& firstRecord = records.at(0); + quint16 port = firstRecord.port(); + + emit logLine( + QString("Resolved server address %1 to %2 with port %3\n").arg(m_dnsLookup->name(), firstRecord.target(), QString::number(port)), + MessageLevel::Launcher); + resolve(firstRecord.target(), port); +} + +void LookupServerAddress::resolve(const QString& address, quint16 port) +{ + m_output->address = address; + m_output->port = port; + + m_dnsLookup->deleteLater(); + emitSucceeded(); +} diff --git a/launcher/launch/steps/LookupServerAddress.h b/launcher/launch/steps/LookupServerAddress.h new file mode 100644 index 0000000..506314e --- /dev/null +++ b/launcher/launch/steps/LookupServerAddress.h @@ -0,0 +1,46 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +#include "minecraft/launch/MinecraftTarget.h" + +class LookupServerAddress : public LaunchStep { + Q_OBJECT + public: + explicit LookupServerAddress(LaunchTask* parent); + virtual ~LookupServerAddress() = default; + + virtual void executeTask(); + virtual bool abort(); + virtual bool canAbort() const { return true; } + + void setLookupAddress(const QString& lookupAddress); + void setOutputAddressPtr(MinecraftTarget::Ptr output); + + private slots: + void on_dnsLookupFinished(); + + private: + void resolve(const QString& address, quint16 port); + + QDnsLookup* m_dnsLookup; + QString m_lookupAddress; + MinecraftTarget::Ptr m_output; +}; diff --git a/launcher/launch/steps/PostLaunchCommand.cpp b/launcher/launch/steps/PostLaunchCommand.cpp new file mode 100644 index 0000000..6b96097 --- /dev/null +++ b/launcher/launch/steps/PostLaunchCommand.cpp @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "PostLaunchCommand.h" +#include + +PostLaunchCommand::PostLaunchCommand(LaunchTask* parent) : LaunchStep(parent) +{ + auto instance = m_parent->instance(); + m_command = instance->getPostExitCommand(); + m_process.setProcessEnvironment(instance->createEnvironment()); + connect(&m_process, &LoggedProcess::log, this, &PostLaunchCommand::logLines); + connect(&m_process, &LoggedProcess::stateChanged, this, &PostLaunchCommand::on_state); +} + +void PostLaunchCommand::executeTask() +{ + auto cmd = m_parent->substituteVariables(m_command); + emit logLine(tr("Running Post-Launch command: %1").arg(cmd), MessageLevel::Launcher); + auto args = QProcess::splitCommand(cmd); + + const QString program = args.takeFirst(); + m_process.start(program, args); +} + +void PostLaunchCommand::on_state(LoggedProcess::State state) +{ + auto getError = [this]() { return tr("Post-Launch command failed with code %1.\n\n").arg(m_process.exitCode()); }; + switch (state) { + case LoggedProcess::Aborted: + case LoggedProcess::Crashed: + case LoggedProcess::FailedToStart: { + auto error = getError(); + emit logLine(error, MessageLevel::Fatal); + emitFailed(error); + return; + } + case LoggedProcess::Finished: { + if (m_process.exitCode() != 0) { + auto error = getError(); + emit logLine(error, MessageLevel::Fatal); + emitFailed(error); + } else { + emit logLine(tr("Post-Launch command ran successfully.\n\n"), MessageLevel::Launcher); + emitSucceeded(); + } + } + default: + break; + } +} + +void PostLaunchCommand::setWorkingDirectory(const QString& wd) +{ + m_process.setWorkingDirectory(wd); +} + +bool PostLaunchCommand::abort() +{ + auto state = m_process.state(); + if (state == LoggedProcess::Running || state == LoggedProcess::Starting) { + m_process.kill(); + } + return true; +} diff --git a/launcher/launch/steps/PostLaunchCommand.h b/launcher/launch/steps/PostLaunchCommand.h new file mode 100644 index 0000000..fd1443b --- /dev/null +++ b/launcher/launch/steps/PostLaunchCommand.h @@ -0,0 +1,37 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +class PostLaunchCommand : public LaunchStep { + Q_OBJECT + public: + explicit PostLaunchCommand(LaunchTask* parent); + virtual ~PostLaunchCommand() {}; + + virtual void executeTask(); + virtual bool abort(); + virtual bool canAbort() const { return true; } + void setWorkingDirectory(const QString& wd); + private slots: + void on_state(LoggedProcess::State state); + + private: + LoggedProcess m_process; + QString m_command; +}; diff --git a/launcher/launch/steps/PreLaunchCommand.cpp b/launcher/launch/steps/PreLaunchCommand.cpp new file mode 100644 index 0000000..7e843ca --- /dev/null +++ b/launcher/launch/steps/PreLaunchCommand.cpp @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "PreLaunchCommand.h" +#include + +PreLaunchCommand::PreLaunchCommand(LaunchTask* parent) : LaunchStep(parent) +{ + auto instance = m_parent->instance(); + m_command = instance->getPreLaunchCommand(); + m_process.setProcessEnvironment(instance->createEnvironment()); + connect(&m_process, &LoggedProcess::log, this, &PreLaunchCommand::logLines); + connect(&m_process, &LoggedProcess::stateChanged, this, &PreLaunchCommand::on_state); +} + +void PreLaunchCommand::executeTask() +{ + auto cmd = m_parent->substituteVariables(m_command); + emit logLine(tr("Running Pre-Launch command: %1").arg(cmd), MessageLevel::Launcher); + auto args = QProcess::splitCommand(cmd); + const QString program = args.takeFirst(); + m_process.start(program, args); +} + +void PreLaunchCommand::on_state(LoggedProcess::State state) +{ + auto getError = [this]() { return tr("Pre-Launch command failed with code %1.\n\n").arg(m_process.exitCode()); }; + switch (state) { + case LoggedProcess::Aborted: + case LoggedProcess::Crashed: + case LoggedProcess::FailedToStart: { + auto error = getError(); + emit logLine(error, MessageLevel::Fatal); + emitFailed(error); + return; + } + case LoggedProcess::Finished: { + if (m_process.exitCode() != 0) { + auto error = getError(); + emit logLine(error, MessageLevel::Fatal); + emitFailed(error); + } else { + emit logLine(tr("Pre-Launch command ran successfully.\n\n"), MessageLevel::Launcher); + emitSucceeded(); + } + } + default: + break; + } +} + +void PreLaunchCommand::setWorkingDirectory(const QString& wd) +{ + m_process.setWorkingDirectory(wd); +} + +bool PreLaunchCommand::abort() +{ + auto state = m_process.state(); + if (state == LoggedProcess::Running || state == LoggedProcess::Starting) { + m_process.kill(); + } + return true; +} diff --git a/launcher/launch/steps/PreLaunchCommand.h b/launcher/launch/steps/PreLaunchCommand.h new file mode 100644 index 0000000..b6dc6cd --- /dev/null +++ b/launcher/launch/steps/PreLaunchCommand.h @@ -0,0 +1,37 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "LoggedProcess.h" +#include "launch/LaunchStep.h" + +class PreLaunchCommand : public LaunchStep { + Q_OBJECT + public: + explicit PreLaunchCommand(LaunchTask* parent); + virtual ~PreLaunchCommand() {}; + + virtual void executeTask(); + virtual bool abort(); + virtual bool canAbort() const { return true; } + void setWorkingDirectory(const QString& wd); + private slots: + void on_state(LoggedProcess::State state); + + private: + LoggedProcess m_process; + QString m_command; +}; diff --git a/launcher/launch/steps/PrintServers.cpp b/launcher/launch/steps/PrintServers.cpp new file mode 100644 index 0000000..ac0e4bf --- /dev/null +++ b/launcher/launch/steps/PrintServers.cpp @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2024 Leia uwu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "PrintServers.h" +#include "QHostInfo" + +PrintServers::PrintServers(LaunchTask* parent, const QStringList& servers) : LaunchStep(parent) +{ + m_servers = servers; +} + +void PrintServers::executeTask() +{ + for (QString server : m_servers) { + QHostInfo::lookupHost(server, this, &PrintServers::resolveServer); + } +} + +void PrintServers::resolveServer(const QHostInfo& host_info) +{ + QString server = host_info.hostName(); + QString addresses = server + " resolves to:\n "; + + if (!host_info.addresses().isEmpty()) { + for (QHostAddress address : host_info.addresses()) { + addresses += address.toString(); + if (!host_info.addresses().endsWith(address)) { + addresses += ", "; + } + } + } else { + addresses += "N/A"; + } + addresses += "\n"; + + m_server_to_address.insert(server, addresses); + + // print server info in order once all servers are resolved + if (m_server_to_address.size() >= m_servers.size()) { + for (QString serv : m_servers) { + emit logLine(m_server_to_address.value(serv), MessageLevel::Launcher); + } + emitSucceeded(); + } +} + +bool PrintServers::canAbort() const +{ + return true; +} diff --git a/launcher/launch/steps/PrintServers.h b/launcher/launch/steps/PrintServers.h new file mode 100644 index 0000000..7d2f1b1 --- /dev/null +++ b/launcher/launch/steps/PrintServers.h @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2024 Leia uwu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +#pragma once + +#include +#include +#include +#include + +class PrintServers : public LaunchStep { + Q_OBJECT + public: + PrintServers(LaunchTask* parent, const QStringList& servers); + + virtual void executeTask(); + virtual bool canAbort() const; + + private: + void resolveServer(const QHostInfo& host_info); + QMap m_server_to_address; + QStringList m_servers; +}; diff --git a/launcher/launch/steps/QuitAfterGameStop.cpp b/launcher/launch/steps/QuitAfterGameStop.cpp new file mode 100644 index 0000000..3895604 --- /dev/null +++ b/launcher/launch/steps/QuitAfterGameStop.cpp @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 dada513 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "QuitAfterGameStop.h" +#include +#include "Application.h" + +void QuitAfterGameStop::executeTask() +{ + APPLICATION->quit(); +} diff --git a/launcher/launch/steps/QuitAfterGameStop.h b/launcher/launch/steps/QuitAfterGameStop.h new file mode 100644 index 0000000..19ca596 --- /dev/null +++ b/launcher/launch/steps/QuitAfterGameStop.h @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 dada513 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include + +class QuitAfterGameStop : public LaunchStep { + Q_OBJECT + public: + explicit QuitAfterGameStop(LaunchTask* parent) : LaunchStep(parent) {}; + virtual ~QuitAfterGameStop() = default; + + virtual void executeTask(); + virtual bool canAbort() const { return false; } +}; diff --git a/launcher/launch/steps/TextPrint.cpp b/launcher/launch/steps/TextPrint.cpp new file mode 100644 index 0000000..f96d113 --- /dev/null +++ b/launcher/launch/steps/TextPrint.cpp @@ -0,0 +1,29 @@ +#include "TextPrint.h" + +TextPrint::TextPrint(LaunchTask* parent, const QStringList& lines, MessageLevel level) : LaunchStep(parent) +{ + m_lines = lines; + m_level = level; +} +TextPrint::TextPrint(LaunchTask* parent, const QString& line, MessageLevel level) : LaunchStep(parent) +{ + m_lines.append(line); + m_level = level; +} + +void TextPrint::executeTask() +{ + emit logLines(m_lines, m_level); + emitSucceeded(); +} + +bool TextPrint::canAbort() const +{ + return true; +} + +bool TextPrint::abort() +{ + emitAborted(); + return true; +} diff --git a/launcher/launch/steps/TextPrint.h b/launcher/launch/steps/TextPrint.h new file mode 100644 index 0000000..4479a26 --- /dev/null +++ b/launcher/launch/steps/TextPrint.h @@ -0,0 +1,40 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +/* + * FIXME: maybe do not export + */ + +class TextPrint : public LaunchStep { + Q_OBJECT + public: + explicit TextPrint(LaunchTask* parent, const QStringList& lines, MessageLevel level); + explicit TextPrint(LaunchTask* parent, const QString& line, MessageLevel level); + virtual ~TextPrint() {}; + + virtual void executeTask(); + virtual bool canAbort() const; + virtual bool abort(); + + private: + QStringList m_lines; + MessageLevel m_level; +}; diff --git a/launcher/logs/AnonymizeLog.cpp b/launcher/logs/AnonymizeLog.cpp new file mode 100644 index 0000000..b808b35 --- /dev/null +++ b/launcher/logs/AnonymizeLog.cpp @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2025 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +#include "AnonymizeLog.h" + +#include + +struct RegReplace { + RegReplace(QRegularExpression r, QString w) : reg(r), with(w) { reg.optimize(); } + QRegularExpression reg; + QString with; +}; + +static const QVector anonymizeRules = { + RegReplace(QRegularExpression("C:\\\\Users\\\\([^\\\\]+)\\\\", QRegularExpression::CaseInsensitiveOption), + "C:\\Users\\********\\"), // windows + RegReplace(QRegularExpression("C:\\/Users\\/([^\\/]+)\\/", QRegularExpression::CaseInsensitiveOption), + "C:/Users/********/"), // windows with forward slashes + RegReplace(QRegularExpression("(?)"), // SESSION_TOKEN + RegReplace(QRegularExpression("new refresh token: \"[^\"]+\"", QRegularExpression::CaseInsensitiveOption), + "new refresh token: \"\""), // refresh token + RegReplace(QRegularExpression("\"device_code\" : \"[^\"]+\"", QRegularExpression::CaseInsensitiveOption), + "\"device_code\" : \"\""), // device code +}; + +void anonymizeLog(QString& log) +{ + for (auto rule : anonymizeRules) { + log.replace(rule.reg, rule.with); + } +} diff --git a/launcher/logs/AnonymizeLog.h b/launcher/logs/AnonymizeLog.h new file mode 100644 index 0000000..215d1e4 --- /dev/null +++ b/launcher/logs/AnonymizeLog.h @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2025 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +#pragma once + +#include + +void anonymizeLog(QString& log); diff --git a/launcher/logs/LogParser.cpp b/launcher/logs/LogParser.cpp new file mode 100644 index 0000000..bab4b9b --- /dev/null +++ b/launcher/logs/LogParser.cpp @@ -0,0 +1,359 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2025 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "LogParser.h" + +#include +#include "MessageLevel.h" + +using namespace Qt::Literals::StringLiterals; + +void LogParser::appendLine(QAnyStringView data) +{ + if (!m_partialData.isEmpty()) { + m_buffer = QString(m_partialData); + m_buffer.append("\n"); + m_partialData.clear(); + } + m_buffer.append(data.toString()); +} + +std::optional LogParser::getError() +{ + return m_error; +} + +std::optional LogParser::parseAttributes() +{ + LogParser::LogEntry entry{ + "", + MessageLevel::Info, + }; + auto attributes = m_parser.attributes(); + + for (const auto& attr : attributes) { + auto name = attr.name(); + auto value = attr.value(); + if (name == "logger"_L1) { + entry.logger = value.trimmed().toString(); + } else if (name == "timestamp"_L1) { + if (value.trimmed().isEmpty()) { + m_parser.raiseError("log4j:Event Missing required attribute: timestamp"); + return {}; + } + entry.timestamp = QDateTime::fromSecsSinceEpoch(value.trimmed().toLongLong()); + } else if (name == "level"_L1) { + entry.levelText = value.trimmed().toString(); + entry.level = MessageLevel::fromName(entry.levelText); + } else if (name == "thread"_L1) { + entry.thread = value.trimmed().toString(); + } + } + if (entry.logger.isEmpty()) { + m_parser.raiseError("log4j:Event Missing required attribute: logger"); + return {}; + } + + return entry; +} + +void LogParser::setError() +{ + m_error = { + m_parser.errorString(), + m_parser.error(), + }; +} + +void LogParser::clearError() +{ + m_error = {}; // clear previous error +} + +bool isPotentialLog4JStart(QStringView buffer) +{ + static QString target = QStringLiteral(" LogParser::parseNext() +{ + clearError(); + + if (m_buffer.isEmpty()) { + return {}; + } + + if (m_buffer.trimmed().isEmpty()) { + auto text = QString(m_buffer); + m_buffer.clear(); + return LogParser::PlainText{ text }; + } + + // check if we have a full xml log4j event + bool isCompleteLog4j = false; + m_parser.clear(); + m_parser.setNamespaceProcessing(false); + m_parser.addData(m_buffer); + if (m_parser.readNextStartElement()) { + if (m_parser.qualifiedName().compare("log4j:Event"_L1, Qt::CaseInsensitive) == 0) { + int depth = 1; + bool eod = false; + while (depth > 0 && !eod) { + auto tok = m_parser.readNext(); + switch (tok) { + case QXmlStreamReader::TokenType::StartElement: { + depth += 1; + } break; + case QXmlStreamReader::TokenType::EndElement: { + depth -= 1; + } break; + case QXmlStreamReader::TokenType::EndDocument: { + eod = true; // break outer while loop + } break; + default: { + // no op + } + } + if (m_parser.hasError()) { + break; + } + } + + isCompleteLog4j = depth == 0; + } + } + + if (isCompleteLog4j) { + return parseLog4J(); + } else { + if (isPotentialLog4JStart(m_buffer)) { + m_partialData = QString(m_buffer); + return LogParser::Partial{ QString(m_buffer) }; + } + + int start = 0; + auto bufView = QStringView(m_buffer); + while (start < bufView.length()) { + if (qsizetype pos = bufView.right(bufView.length() - start).indexOf('<'); pos != -1) { + auto slicestart = start + pos; + auto slice = bufView.right(bufView.length() - slicestart); + if (isPotentialLog4JStart(slice)) { + if (slicestart > 0) { + auto text = m_buffer.left(slicestart); + m_buffer = m_buffer.right(m_buffer.length() - slicestart); + if (!text.trimmed().isEmpty()) { + return LogParser::PlainText{ text }; + } + } + m_partialData = QString(m_buffer); + return LogParser::Partial{ QString(m_buffer) }; + } + start = slicestart + 1; + } else { + break; + } + } + + // no log4j found, all plain text + auto text = QString(m_buffer); + m_buffer.clear(); + return LogParser::PlainText{ text }; + } +} + +QList LogParser::parseAvailable() +{ + QList items; + bool doNext = true; + while (doNext) { + auto item_ = parseNext(); + if (m_error.has_value()) { + return {}; + } + if (item_.has_value()) { + auto item = item_.value(); + if (std::holds_alternative(item)) { + break; + } else { + items.push_back(item); + } + } else { + doNext = false; + } + } + return items; +} + +std::optional LogParser::parseLog4J() +{ + m_parser.clear(); + m_parser.setNamespaceProcessing(false); + m_parser.addData(m_buffer); + + m_parser.readNextStartElement(); + if (m_parser.qualifiedName().compare("log4j:Event"_L1, Qt::CaseInsensitive) == 0) { + auto entry_ = parseAttributes(); + if (!entry_.has_value()) { + setError(); + return {}; + } + auto entry = entry_.value(); + + bool foundMessage = false; + int depth = 1; + + enum parseOp { noOp, entryReady, parseError }; + + auto foundStart = [&]() -> parseOp { + depth += 1; + if (m_parser.qualifiedName().compare("log4j:Message"_L1, Qt::CaseInsensitive) == 0) { + QString message; + bool messageComplete = false; + + while (!messageComplete) { + auto tok = m_parser.readNext(); + + switch (tok) { + case QXmlStreamReader::TokenType::Characters: { + message.append(m_parser.text()); + } break; + case QXmlStreamReader::TokenType::EndElement: { + if (m_parser.qualifiedName().compare("log4j:Message"_L1, Qt::CaseInsensitive) == 0) { + messageComplete = true; + } + } break; + case QXmlStreamReader::TokenType::EndDocument: { + return parseError; // parse fail + } break; + default: { + // no op + } + } + + if (m_parser.hasError()) { + return parseError; + } + } + + entry.message = message; + foundMessage = true; + depth -= 1; + } + return noOp; + }; + + auto foundEnd = [&]() -> parseOp { + depth -= 1; + if (depth == 0 && m_parser.qualifiedName().compare("log4j:Event"_L1, Qt::CaseInsensitive) == 0) { + if (foundMessage) { + auto consumed = m_parser.characterOffset(); + if (consumed > 0 && consumed <= m_buffer.length()) { + m_buffer = m_buffer.right(m_buffer.length() - consumed); + // potential whitespace preserved for next item + } + clearError(); + return entryReady; + } + m_parser.raiseError("log4j:Event Missing required attribute: message"); + setError(); + return parseError; + } + return noOp; + }; + + while (!m_parser.atEnd()) { + auto tok = m_parser.readNext(); + parseOp op = noOp; + switch (tok) { + case QXmlStreamReader::TokenType::StartElement: { + op = foundStart(); + } break; + case QXmlStreamReader::TokenType::EndElement: { + op = foundEnd(); + } break; + case QXmlStreamReader::TokenType::EndDocument: { + return {}; + } break; + default: { + // no op + } + } + + switch (op) { + case parseError: + return {}; // parse fail or error + case entryReady: + return entry; + case noOp: + default: { + // no op + } + } + + if (m_parser.hasError()) { + return {}; + } + } + } + + throw std::runtime_error("unreachable: already verified this was a complete log4j:Event"); +} + +MessageLevel LogParser::guessLevel(const QString& line, MessageLevel previous) +{ + static const QRegularExpression LINE_WITH_LEVEL("^\\[(?[0-9:]+)\\] \\[[^/]+/(?[^\\]]+)\\]"); + auto match = LINE_WITH_LEVEL.match(line); + if (match.hasMatch()) { + // New style logs from log4j + QString timestamp = match.captured("timestamp"); + QString levelStr = match.captured("level"); + + return MessageLevel::fromName(levelStr); + } else { + // Old style forge logs + if (line.contains("[INFO]") || line.contains("[CONFIG]") || line.contains("[FINE]") || line.contains("[FINER]") || + line.contains("[FINEST]")) + return MessageLevel::Info; + if (line.contains("[SEVERE]") || line.contains("[STDERR]")) + return MessageLevel::Error; + if (line.contains("[WARNING]")) + return MessageLevel::Warning; + if (line.contains("[DEBUG]")) + return MessageLevel::Debug; + } + + if (line.contains("Exception: ") || line.contains("Throwable: ")) + return MessageLevel::Error; + + if (line.startsWith("Caused by: ") || line.startsWith("Exception in thread")) + return MessageLevel::Error; + + if (line.contains("overwriting existing")) + return MessageLevel::Fatal; + + if (line.startsWith("\t") || line.startsWith(" ")) + return previous; + + return MessageLevel::Unknown; +} diff --git a/launcher/logs/LogParser.h b/launcher/logs/LogParser.h new file mode 100644 index 0000000..ae65729 --- /dev/null +++ b/launcher/logs/LogParser.h @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2025 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include "MessageLevel.h" + +class LogParser { + public: + struct LogEntry { + QString logger; + MessageLevel level; + QString levelText; + QDateTime timestamp; + QString thread; + QString message; + }; + struct Partial { + QString data; + }; + struct PlainText { + QString message; + }; + struct Error { + QString errMessage; + QXmlStreamReader::Error error; + }; + + using ParsedItem = std::variant; + + public: + LogParser() = default; + + void appendLine(QAnyStringView data); + std::optional parseNext(); + QList parseAvailable(); + std::optional getError(); + + /// guess log level from a line of game log + static MessageLevel guessLevel(const QString& line, MessageLevel previous); + + protected: + std::optional parseAttributes(); + void setError(); + void clearError(); + + std::optional parseLog4J(); + + private: + QString m_buffer; + QString m_partialData; + QXmlStreamReader m_parser; + std::optional m_error; +}; diff --git a/launcher/macsandbox/SecurityBookmarkFileAccess.h b/launcher/macsandbox/SecurityBookmarkFileAccess.h new file mode 100644 index 0000000..69b344a --- /dev/null +++ b/launcher/macsandbox/SecurityBookmarkFileAccess.h @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2024 Kenneth Chew <79120643+kthchew@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef FILEACCESS_H +#define FILEACCESS_H + +#include +#include +Q_FORWARD_DECLARE_OBJC_CLASS(NSData); +Q_FORWARD_DECLARE_OBJC_CLASS(NSURL); +Q_FORWARD_DECLARE_OBJC_CLASS(NSString); +Q_FORWARD_DECLARE_OBJC_CLASS(NSAutoreleasePool); +Q_FORWARD_DECLARE_OBJC_CLASS(NSMutableDictionary); +Q_FORWARD_DECLARE_OBJC_CLASS(NSMutableSet); +class QString; +class QByteArray; +class QUrl; + +class SecurityBookmarkFileAccess { + /// The keys are bookmarks and the values are URLs. + NSMutableDictionary* m_bookmarks; + /// The keys are paths and the values are bookmarks. + NSMutableDictionary* m_paths; + /// Contains URLs that are currently being accessed. + NSMutableSet* m_activeURLs; + + bool m_readOnly; + + NSURL* securityScopedBookmarkToNSURL(QByteArray& bookmark, bool& isStale); + + public: + /// \param readOnly A boolean indicating whether the bookmark should be read-only. + SecurityBookmarkFileAccess(bool readOnly = false); + ~SecurityBookmarkFileAccess(); + + /// Get a security scoped bookmark from a URL. + /// + /// The URL must be accessible before calling this function. That is, call `startAccessingSecurityScopedResource()` before calling + /// this function. Note that this is called implicitly if the user selects the directory from a file picker. + /// \param url The URL to get the security scoped bookmark from. + /// \return A QByteArray containing the security scoped bookmark. + QByteArray urlToSecurityScopedBookmark(const QUrl& url); + /// Get a security scoped bookmark from a path. + /// + /// The path must be accessible before calling this function. That is, call `startAccessingSecurityScopedResource()` before calling + /// this function. Note that this is called implicitly if the user selects the directory from a file picker. + /// \param path The path to get the security scoped bookmark from. + /// \return A QByteArray containing the security scoped bookmark. + QByteArray pathToSecurityScopedBookmark(const QString& path); + /// Get a QUrl from a security scoped bookmark. If the bookmark is stale, isStale will be set to true and the bookmark will be updated. + /// + /// You must check whether the URL is valid before using it. + /// \param bookmark The security scoped bookmark to get the URL from. + /// \param isStale A boolean that will be set to true if the bookmark is stale. + /// \return The URL from the security scoped bookmark. + QUrl securityScopedBookmarkToURL(QByteArray& bookmark, bool& isStale); + + /// Makes the file or directory at the path pointed to by the bookmark accessible. Unlike `startAccessingSecurityScopedResource()`, this + /// class ensures that only one "access" is active at a time. Calling this function again after the security-scoped resource has + /// already been used will do nothing, and a single call to `stopUsingSecurityScopedBookmark()` will release the resource provided that + /// this is the only `SecurityBookmarkFileAccess` accessing the resource. + /// + /// If the bookmark is stale, `isStale` will be set to true and the bookmark will be updated. Stored copies of the bookmark need to be + /// updated. + /// \param bookmark The security scoped bookmark to start accessing. + /// \param isStale A boolean that will be set to true if the bookmark is stale. + /// \return A boolean indicating whether the bookmark was successfully accessed. + bool startUsingSecurityScopedBookmark(QByteArray& bookmark, bool& isStale); + void stopUsingSecurityScopedBookmark(QByteArray& bookmark); + + /// Returns true if access to the `path` is currently being maintained by this object. + bool isAccessingPath(const QString& path); +}; + +#endif // FILEACCESS_H diff --git a/launcher/macsandbox/SecurityBookmarkFileAccess.mm b/launcher/macsandbox/SecurityBookmarkFileAccess.mm new file mode 100644 index 0000000..bee854a --- /dev/null +++ b/launcher/macsandbox/SecurityBookmarkFileAccess.mm @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2024 Kenneth Chew <79120643+kthchew@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "SecurityBookmarkFileAccess.h" + +#include +#include +#include + +QByteArray SecurityBookmarkFileAccess::urlToSecurityScopedBookmark(const QUrl& url) +{ + if (!url.isLocalFile()) + return {}; + + NSError* error = nil; + NSURL* nsurl = [url.toNSURL() absoluteURL]; + NSData* bookmark; + if ([m_paths objectForKey:[nsurl path]]) { + bookmark = m_paths[[nsurl path]]; + } else { + bookmark = [nsurl bookmarkDataWithOptions:NSURLBookmarkCreationWithSecurityScope | + (m_readOnly ? NSURLBookmarkCreationSecurityScopeAllowOnlyReadAccess : 0) + includingResourceValuesForKeys:nil + relativeToURL:nil + error:&error]; + } + if (error) { + return {}; + } + + // remove/reapply access to ensure that write access is immediately cut off for read-only bookmarks + // sometimes you need to call this twice to actually stop access (extra calls aren't harmful) + [nsurl stopAccessingSecurityScopedResource]; + [nsurl stopAccessingSecurityScopedResource]; + nsurl = [NSURL URLByResolvingBookmarkData:bookmark + options:NSURLBookmarkResolutionWithSecurityScope | + (m_readOnly ? NSURLBookmarkCreationSecurityScopeAllowOnlyReadAccess : 0) + relativeToURL:nil + bookmarkDataIsStale:nil + error:&error]; + m_paths[[nsurl path]] = bookmark; + m_bookmarks[bookmark] = nsurl; + + QByteArray qBookmark = QByteArray::fromNSData(bookmark); + bool isStale = false; + startUsingSecurityScopedBookmark(qBookmark, isStale); + + return qBookmark; +} + +SecurityBookmarkFileAccess::SecurityBookmarkFileAccess(bool readOnly) : m_readOnly(readOnly) +{ + m_bookmarks = [NSMutableDictionary new]; + m_paths = [NSMutableDictionary new]; + m_activeURLs = [NSMutableSet new]; +} + +SecurityBookmarkFileAccess::~SecurityBookmarkFileAccess() +{ + for (NSURL* url : m_activeURLs) { + [url stopAccessingSecurityScopedResource]; + } +} + +QByteArray SecurityBookmarkFileAccess::pathToSecurityScopedBookmark(const QString& path) +{ + return urlToSecurityScopedBookmark(QUrl::fromLocalFile(path)); +} + +NSURL* SecurityBookmarkFileAccess::securityScopedBookmarkToNSURL(QByteArray& bookmark, bool& isStale) +{ + NSError* error = nil; + BOOL localStale = NO; + NSURL* nsurl = [NSURL URLByResolvingBookmarkData:bookmark.toNSData() + options:NSURLBookmarkResolutionWithSecurityScope | + (m_readOnly ? NSURLBookmarkCreationSecurityScopeAllowOnlyReadAccess : 0) + relativeToURL:nil + bookmarkDataIsStale:&localStale + error:&error]; + if (error) { + return nil; + } + isStale = localStale; + if (isStale) { + NSData* nsBookmark = [nsurl bookmarkDataWithOptions:NSURLBookmarkCreationWithSecurityScope | + (m_readOnly ? NSURLBookmarkCreationSecurityScopeAllowOnlyReadAccess : 0) + includingResourceValuesForKeys:nil + relativeToURL:nil + error:&error]; + if (error) { + return nil; + } + bookmark = QByteArray::fromNSData(nsBookmark); + } + + NSData* nsBookmark = bookmark.toNSData(); + m_paths[[nsurl path]] = nsBookmark; + m_bookmarks[nsBookmark] = nsurl; + + return nsurl; +} + +QUrl SecurityBookmarkFileAccess::securityScopedBookmarkToURL(QByteArray& bookmark, bool& isStale) +{ + if (bookmark.isEmpty()) + return {}; + + NSURL* url = securityScopedBookmarkToNSURL(bookmark, isStale); + if (!url) + return {}; + + return QUrl::fromNSURL(url); +} + +bool SecurityBookmarkFileAccess::startUsingSecurityScopedBookmark(QByteArray& bookmark, bool& isStale) +{ + NSURL* url = [m_bookmarks objectForKey:bookmark.toNSData()] ? m_bookmarks[bookmark.toNSData()] + : securityScopedBookmarkToNSURL(bookmark, isStale); + if ([m_activeURLs containsObject:url]) + return false; + + [url stopAccessingSecurityScopedResource]; + if ([url startAccessingSecurityScopedResource]) { + [m_activeURLs addObject:url]; + return true; + } + return false; +} + +void SecurityBookmarkFileAccess::stopUsingSecurityScopedBookmark(QByteArray& bookmark) +{ + if (![m_bookmarks objectForKey:bookmark.toNSData()]) + return; + NSURL* url = m_bookmarks[bookmark.toNSData()]; + + if ([m_activeURLs containsObject:url]) { + [url stopAccessingSecurityScopedResource]; + [url stopAccessingSecurityScopedResource]; + + [m_activeURLs removeObject:url]; + [m_paths removeObjectForKey:[url path]]; + [m_bookmarks removeObjectForKey:bookmark.toNSData()]; + } +} + +bool SecurityBookmarkFileAccess::isAccessingPath(const QString& path) +{ + NSData* bookmark = [m_paths objectForKey:path.toNSString()]; + if (!bookmark && path.endsWith('/')) { + bookmark = [m_paths objectForKey:path.left(path.length() - 1).toNSString()]; + } + if (!bookmark) { + return false; + } + NSURL* url = [m_bookmarks objectForKey:bookmark]; + return [m_activeURLs containsObject:url]; +} diff --git a/launcher/main.cpp b/launcher/main.cpp new file mode 100644 index 0000000..c537077 --- /dev/null +++ b/launcher/main.cpp @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include + +#include "Application.h" + +#if defined Q_OS_WIN32 +#include "console/WindowsConsole.h" +#endif + +int main(int argc, char* argv[]) +{ +#if defined Q_OS_WIN32 + // used on Windows to attach the standard IO streams + console::WindowsConsoleGuard _consoleGuard; +#endif + + // initialize Qt + Application app(argc, argv); + switch (app.status()) { + case Application::StartingUp: + case Application::Initialized: { + Q_INIT_RESOURCE(flat_white); + Q_INIT_RESOURCE(backgrounds); + Q_INIT_RESOURCE(documents); + Q_INIT_RESOURCE(prismlauncher); + Q_INIT_RESOURCE(racked_ru); + Q_INIT_RESOURCE(shaders); + return app.exec(); + } + case Application::Failed: + return 1; + case Application::Succeeded: + return 0; + default: + return -1; + } +} diff --git a/launcher/meta/BaseEntity.cpp b/launcher/meta/BaseEntity.cpp new file mode 100644 index 0000000..c809f85 --- /dev/null +++ b/launcher/meta/BaseEntity.cpp @@ -0,0 +1,199 @@ +/* Copyright 2015-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "BaseEntity.h" + +#include "Exception.h" +#include "FileSystem.h" +#include "Json.h" +#include "modplatform/helpers/HashUtils.h" +#include "net/ApiDownload.h" +#include "net/ChecksumValidator.h" +#include "net/HttpMetaCache.h" +#include "net/Mode.h" +#include "net/NetJob.h" + +#include "Application.h" +#include "settings/SettingsObject.h" +#include "BuildConfig.h" +#include "tasks/Task.h" + +namespace Meta { + +class ParsingValidator : public Net::Validator { + public: /* con/des */ + ParsingValidator(BaseEntity* entity) : m_entity(entity) {}; + virtual ~ParsingValidator() = default; + + public: /* methods */ + bool init(QNetworkRequest&) override + { + m_data.clear(); + return true; + } + bool write(QByteArray& data) override + { + this->m_data.append(data); + return true; + } + bool abort() override + { + m_data.clear(); + return true; + } + bool validate(QNetworkReply&) override + { + auto fname = m_entity->localFilename(); + try { + auto doc = Json::requireDocument(m_data, fname); + auto obj = Json::requireObject(doc, fname); + m_entity->parse(obj); + return true; + } catch (const Exception& e) { + qWarning() << "Unable to parse response:" << e.cause(); + return false; + } + } + + private: /* data */ + QByteArray m_data; + BaseEntity* m_entity; +}; + +QUrl BaseEntity::url() const +{ + auto s = APPLICATION->settings(); + QString metaOverride = s->get("MetaURLOverride").toString(); + if (metaOverride.isEmpty()) { + return QUrl(BuildConfig.META_URL).resolved(localFilename()); + } + return QUrl(metaOverride).resolved(localFilename()); +} + +Task::Ptr BaseEntity::loadTask(Net::Mode mode) +{ + if (m_task && m_task->isRunning()) { + return m_task; + } + m_task.reset(new BaseEntityLoadTask(this, mode)); + return m_task; +} + +bool BaseEntity::isLoaded() const +{ + // consider it loaded only if the main hash is either empty and was remote loadded or the hashes match and was loaded + return m_sha256.isEmpty() ? m_load_status == LoadStatus::Remote : m_load_status != LoadStatus::NotLoaded && m_sha256 == m_file_sha256; +} + +void BaseEntity::setSha256(QString sha256) +{ + m_sha256 = sha256; +} + +BaseEntity::LoadStatus BaseEntity::status() const +{ + return m_load_status; +} + +BaseEntityLoadTask::BaseEntityLoadTask(BaseEntity* parent, Net::Mode mode) : m_entity(parent), m_mode(mode) {} + +void BaseEntityLoadTask::executeTask() +{ + const QString fname = QDir("meta").absoluteFilePath(m_entity->localFilename()); + auto hashMatches = false; + // the file exists on disk try to load it + if (QFile::exists(fname)) { + try { + QByteArray fileData; + // read local file if nothing is loaded yet + if (m_entity->m_load_status == BaseEntity::LoadStatus::NotLoaded || m_entity->m_file_sha256.isEmpty()) { + setStatus(tr("Loading local file")); + fileData = FS::read(fname); + m_entity->m_file_sha256 = Hashing::hash(fileData, Hashing::Algorithm::Sha256); + } + + // on online the hash needs to match + hashMatches = m_entity->m_sha256 == m_entity->m_file_sha256; + if (m_mode == Net::Mode::Online && !m_entity->m_sha256.isEmpty() && !hashMatches) { + throw Exception("mismatched checksum"); + } + + // load local file + if (m_entity->m_load_status == BaseEntity::LoadStatus::NotLoaded) { + auto doc = Json::requireDocument(fileData, fname); + auto obj = Json::requireObject(doc, fname); + m_entity->parse(obj); + m_entity->m_load_status = BaseEntity::LoadStatus::Local; + } + + } catch (const Exception& e) { + qDebug() << QString("Unable to parse file %1: %2").arg(fname, e.cause()); + // just make sure it's gone and we never consider it again. + FS::deletePath(fname); + m_entity->m_load_status = BaseEntity::LoadStatus::NotLoaded; + } + } + // if we need remote update, run the update task + auto wasLoadedOffline = m_entity->m_load_status != BaseEntity::LoadStatus::NotLoaded && m_mode == Net::Mode::Offline; + // if has is not present allways fetch from remote(e.g. the main index file), else only fetch if hash doesn't match + auto wasLoadedRemote = m_entity->m_sha256.isEmpty() ? m_entity->m_load_status == BaseEntity::LoadStatus::Remote : hashMatches; + if (wasLoadedOffline || wasLoadedRemote) { + emitSucceeded(); + return; + } + m_task.reset(new NetJob(QObject::tr("Download of meta file %1").arg(m_entity->localFilename()), APPLICATION->network())); + auto url = m_entity->url(); + auto entry = APPLICATION->metacache()->resolveEntry("meta", m_entity->localFilename()); + entry->setStale(true); + auto dl = Net::ApiDownload::makeCached(url, entry); + /* + * The validator parses the file and loads it into the object. + * If that fails, the file is not written to storage. + */ + if (!m_entity->m_sha256.isEmpty()) + dl->addValidator(new Net::ChecksumValidator(QCryptographicHash::Algorithm::Sha256, m_entity->m_sha256)); + dl->addValidator(new ParsingValidator(m_entity)); + m_task->addNetAction(dl); + m_task->setAskRetry(false); + connect(m_task.get(), &Task::failed, this, &BaseEntityLoadTask::emitFailed); + connect(m_task.get(), &Task::succeeded, this, &BaseEntityLoadTask::emitSucceeded); + connect(m_task.get(), &Task::succeeded, this, [this]() { + m_entity->m_load_status = BaseEntity::LoadStatus::Remote; + m_entity->m_file_sha256 = m_entity->m_sha256; + }); + + connect(m_task.get(), &Task::progress, this, &Task::setProgress); + connect(m_task.get(), &Task::stepProgress, this, &BaseEntityLoadTask::propagateStepProgress); + connect(m_task.get(), &Task::status, this, &Task::setStatus); + connect(m_task.get(), &Task::details, this, &Task::setDetails); + + m_task->start(); +} + +bool BaseEntityLoadTask::canAbort() const +{ + return m_task ? m_task->canAbort() : false; +} + +bool BaseEntityLoadTask::abort() +{ + if (m_task) { + Task::abort(); + return m_task->abort(); + } + return Task::abort(); +} + +} // namespace Meta diff --git a/launcher/meta/BaseEntity.h b/launcher/meta/BaseEntity.h new file mode 100644 index 0000000..17aa0cb --- /dev/null +++ b/launcher/meta/BaseEntity.h @@ -0,0 +1,73 @@ +/* Copyright 2015-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +#include "net/Mode.h" +#include "net/NetJob.h" +#include "tasks/Task.h" + +namespace Meta { +class BaseEntityLoadTask; +class BaseEntity { + friend BaseEntityLoadTask; + + public: /* types */ + using Ptr = std::shared_ptr; + enum class LoadStatus { NotLoaded, Local, Remote }; + + public: + virtual ~BaseEntity() = default; + + virtual QString localFilename() const = 0; + virtual QUrl url() const; + bool isLoaded() const; + LoadStatus status() const; + + /* for parsers */ + void setSha256(QString sha256); + + virtual void parse(const QJsonObject& obj) = 0; + [[nodiscard]] Task::Ptr loadTask(Net::Mode loadType = Net::Mode::Online); + + protected: + QString m_sha256; // the expected sha256 + QString m_file_sha256; // the file sha256 + + private: + LoadStatus m_load_status = LoadStatus::NotLoaded; + Task::Ptr m_task; +}; + +class BaseEntityLoadTask : public Task { + Q_OBJECT + + public: + explicit BaseEntityLoadTask(BaseEntity* parent, Net::Mode mode); + ~BaseEntityLoadTask() override = default; + + virtual void executeTask() override; + virtual bool canAbort() const override; + virtual bool abort() override; + + private: + BaseEntity* m_entity; + Net::Mode m_mode; + NetJob::Ptr m_task; +}; +} // namespace Meta diff --git a/launcher/meta/Index.cpp b/launcher/meta/Index.cpp new file mode 100644 index 0000000..d0c7075 --- /dev/null +++ b/launcher/meta/Index.cpp @@ -0,0 +1,162 @@ +/* Copyright 2015-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "Index.h" + +#include "JsonFormat.h" +#include "QObjectPtr.h" +#include "VersionList.h" +#include "meta/BaseEntity.h" +#include "tasks/SequentialTask.h" + +namespace Meta { +Index::Index(QObject* parent) : QAbstractListModel(parent) {} +Index::Index(const QList& lists, QObject* parent) : QAbstractListModel(parent), m_lists(lists) +{ + for (int i = 0; i < m_lists.size(); ++i) { + m_uids.insert(m_lists.at(i)->uid(), m_lists.at(i)); + connectVersionList(i, m_lists.at(i)); + } +} + +QVariant Index::data(const QModelIndex& index, int role) const +{ + if (index.parent().isValid() || index.row() < 0 || index.row() >= m_lists.size()) { + return QVariant(); + } + + VersionList::Ptr list = m_lists.at(index.row()); + switch (role) { + case Qt::DisplayRole: + if (index.column() == 0) { + return list->humanReadable(); + } else { + break; + } + case UidRole: + return list->uid(); + case NameRole: + return list->name(); + case ListPtrRole: + return QVariant::fromValue(list); + } + return QVariant(); +} + +int Index::rowCount(const QModelIndex& parent) const +{ + return parent.isValid() ? 0 : m_lists.size(); +} + +int Index::columnCount(const QModelIndex& parent) const +{ + return parent.isValid() ? 0 : 1; +} + +QVariant Index::headerData(int section, Qt::Orientation orientation, int role) const +{ + if (orientation == Qt::Horizontal && role == Qt::DisplayRole && section == 0) { + return tr("Name"); + } else { + return QVariant(); + } +} + +bool Index::hasUid(const QString& uid) const +{ + return m_uids.contains(uid); +} + +VersionList::Ptr Index::get(const QString& uid) +{ + VersionList::Ptr out = m_uids.value(uid, nullptr); + if (!out) { + out = std::make_shared(uid); + m_uids[uid] = out; + m_lists.append(out); + } + return out; +} + +Version::Ptr Index::get(const QString& uid, const QString& version) +{ + auto list = get(uid); + return list->getVersion(version); +} + +void Index::parse(const QJsonObject& obj) +{ + parseIndex(obj, this); +} + +void Index::merge(const std::shared_ptr& other) +{ + const QList lists = other->m_lists; + // initial load, no need to merge + if (m_lists.isEmpty()) { + beginResetModel(); + m_lists = lists; + for (int i = 0; i < lists.size(); ++i) { + m_uids.insert(lists.at(i)->uid(), lists.at(i)); + connectVersionList(i, lists.at(i)); + } + endResetModel(); + } else { + for (const VersionList::Ptr& list : lists) { + if (m_uids.contains(list->uid())) { + m_uids[list->uid()]->mergeFromIndex(list); + } else { + beginInsertRows(QModelIndex(), m_lists.size(), m_lists.size()); + connectVersionList(m_lists.size(), list); + m_lists.append(list); + m_uids.insert(list->uid(), list); + endInsertRows(); + } + } + } +} + +void Index::connectVersionList(const int row, const VersionList::Ptr& list) +{ + connect(list.get(), &VersionList::nameChanged, this, [this, row] { emit dataChanged(index(row), index(row), { Qt::DisplayRole }); }); +} + +Task::Ptr Index::loadVersion(const QString& uid, const QString& version, Net::Mode mode, bool force) +{ + if (mode == Net::Mode::Offline) { + return get(uid, version)->loadTask(mode); + } + + auto versionList = get(uid); + auto loadTask = + makeShared(tr("Load meta for %1:%2", "This is for the task name that loads the meta index.").arg(uid, version)); + if (status() != BaseEntity::LoadStatus::Remote || force) { + loadTask->addTask(this->loadTask(mode)); + } + loadTask->addTask(versionList->loadTask(mode)); + loadTask->addTask(versionList->getVersion(version)->loadTask(mode)); + return loadTask; +} + +Version::Ptr Index::getLoadedVersion(const QString& uid, const QString& version) +{ + QEventLoop ev; + auto task = loadVersion(uid, version); + connect(task.get(), &Task::finished, &ev, &QEventLoop::quit); + task->start(); + ev.exec(); + return get(uid, version); +} +} // namespace Meta diff --git a/launcher/meta/Index.h b/launcher/meta/Index.h new file mode 100644 index 0000000..fe5bf21 --- /dev/null +++ b/launcher/meta/Index.h @@ -0,0 +1,68 @@ +/* Copyright 2015-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +#include "BaseEntity.h" +#include "meta/VersionList.h" +#include "net/Mode.h" + +class Task; + +namespace Meta { + +class Index : public QAbstractListModel, public BaseEntity { + Q_OBJECT + public: + explicit Index(QObject* parent = nullptr); + explicit Index(const QList& lists, QObject* parent = nullptr); + virtual ~Index() = default; + + enum { UidRole = Qt::UserRole, NameRole, ListPtrRole }; + + QVariant data(const QModelIndex& index, int role) const override; + int rowCount(const QModelIndex& parent) const override; + int columnCount(const QModelIndex& parent) const override; + QVariant headerData(int section, Qt::Orientation orientation, int role) const override; + + QString localFilename() const override { return "index.json"; } + + // queries + VersionList::Ptr get(const QString& uid); + Version::Ptr get(const QString& uid, const QString& version); + bool hasUid(const QString& uid) const; + + QList lists() const { return m_lists; } + + Task::Ptr loadVersion(const QString& uid, const QString& version = {}, Net::Mode mode = Net::Mode::Online, bool force = false); + + // this blocks until the version is loaded + Version::Ptr getLoadedVersion(const QString& uid, const QString& version); + + public: // for usage by parsers only + void merge(const std::shared_ptr& other); + + protected: + void parse(const QJsonObject& obj) override; + + private: + QList m_lists; + QHash m_uids; + + void connectVersionList(int row, const VersionList::Ptr& list); +}; +} // namespace Meta diff --git a/launcher/meta/JsonFormat.cpp b/launcher/meta/JsonFormat.cpp new file mode 100644 index 0000000..db19476 --- /dev/null +++ b/launcher/meta/JsonFormat.cpp @@ -0,0 +1,201 @@ +/* Copyright 2015-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "JsonFormat.h" + +// FIXME: remove this from here... somehow +#include "Json.h" +#include "minecraft/OneSixVersionFormat.h" + +#include "Index.h" +#include "Version.h" +#include "VersionList.h" + +using namespace Json; + +namespace Meta { + +MetadataVersion currentFormatVersion() +{ + return MetadataVersion::InitialRelease; +} + +// Index +static std::shared_ptr parseIndexInternal(const QJsonObject& obj) +{ + const QList objects = requireIsArrayOf(obj, "packages"); + QList lists; + lists.reserve(objects.size()); + std::transform(objects.begin(), objects.end(), std::back_inserter(lists), [](const QJsonObject& obj) { + VersionList::Ptr list = std::make_shared(requireString(obj, "uid")); + list->setName(obj["name"].toString()); + list->setSha256(obj["sha256"].toString()); + return list; + }); + return std::make_shared(lists); +} + +// Version +static Version::Ptr parseCommonVersion(const QString& uid, const QJsonObject& obj) +{ + Version::Ptr version = std::make_shared(uid, requireString(obj, "version")); + version->setTime(QDateTime::fromString(requireString(obj, "releaseTime"), Qt::ISODate).toMSecsSinceEpoch() / 1000); + version->setType(obj["type"].toString()); + version->setRecommended(obj["recommended"].toBool()); + version->setVolatile(obj["volatile"].toBool()); + RequireSet reqs, conflicts; + parseRequires(obj, &reqs, "requires"); + parseRequires(obj, &conflicts, "conflicts"); + version->setRequires(reqs, conflicts); + if (auto sha256 = obj["sha256"].toString(); !sha256.isEmpty()) { + version->setSha256(sha256); + } + return version; +} + +static Version::Ptr parseVersionInternal(const QJsonObject& obj) +{ + Version::Ptr version = parseCommonVersion(requireString(obj, "uid"), obj); + + version->setData(OneSixVersionFormat::versionFileFromJson( + QJsonDocument(obj), QString("%1/%2.json").arg(version->uid(), version->version()), obj.contains("order"))); + return version; +} + +// Version list / package +static VersionList::Ptr parseVersionListInternal(const QJsonObject& obj) +{ + const QString uid = requireString(obj, "uid"); + + const QList versionsRaw = requireIsArrayOf(obj, "versions"); + QList versions; + versions.reserve(versionsRaw.size()); + std::transform(versionsRaw.begin(), versionsRaw.end(), std::back_inserter(versions), [uid](const QJsonObject& vObj) { + auto version = parseCommonVersion(uid, vObj); + version->setProvidesRecommendations(); + return version; + }); + + VersionList::Ptr list = std::make_shared(uid); + list->setName(obj["name"].toString()); + list->setVersions(versions); + return list; +} + +MetadataVersion parseFormatVersion(const QJsonObject& obj, bool required) +{ + if (!obj.contains("formatVersion")) { + if (required) { + return MetadataVersion::Invalid; + } + return MetadataVersion::InitialRelease; + } + if (!obj.value("formatVersion").isDouble()) { + return MetadataVersion::Invalid; + } + switch (obj.value("formatVersion").toInt()) { + case 0: + case 1: + return MetadataVersion::InitialRelease; + default: + return MetadataVersion::Invalid; + } +} + +void serializeFormatVersion(QJsonObject& obj, Meta::MetadataVersion version) +{ + if (version == MetadataVersion::Invalid) { + return; + } + obj.insert("formatVersion", int(version)); +} + +void parseIndex(const QJsonObject& obj, Index* ptr) +{ + const MetadataVersion version = parseFormatVersion(obj); + switch (version) { + case MetadataVersion::InitialRelease: + ptr->merge(parseIndexInternal(obj)); + break; + case MetadataVersion::Invalid: + throw ParseException(QObject::tr("Unknown format version!")); + } +} + +void parseVersionList(const QJsonObject& obj, VersionList* ptr) +{ + const MetadataVersion version = parseFormatVersion(obj); + switch (version) { + case MetadataVersion::InitialRelease: + ptr->merge(parseVersionListInternal(obj)); + break; + case MetadataVersion::Invalid: + throw ParseException(QObject::tr("Unknown format version!")); + } +} + +void parseVersion(const QJsonObject& obj, Version* ptr) +{ + const MetadataVersion version = parseFormatVersion(obj); + switch (version) { + case MetadataVersion::InitialRelease: + ptr->merge(parseVersionInternal(obj)); + break; + case MetadataVersion::Invalid: + throw ParseException(QObject::tr("Unknown format version!")); + } +} + +/* +[ +{"uid":"foo", "equals":"version"} +] +*/ +void parseRequires(const QJsonObject& obj, RequireSet* ptr, const char* keyName) +{ + if (obj.contains(keyName)) { + auto reqArray = requireArray(obj, keyName); + auto iter = reqArray.begin(); + while (iter != reqArray.end()) { + auto reqObject = requireObject(*iter); + auto uid = requireString(reqObject, "uid"); + auto equals = reqObject["equals"].toString(); + auto suggests = reqObject["suggests"].toString(); + ptr->insert({ uid, equals, suggests }); + iter++; + } + } +} +void serializeRequires(QJsonObject& obj, RequireSet* ptr, const char* keyName) +{ + if (!ptr || ptr->empty()) { + return; + } + QJsonArray arrOut; + for (auto& iter : *ptr) { + QJsonObject reqOut; + reqOut.insert("uid", iter.uid); + if (!iter.equalsVersion.isEmpty()) { + reqOut.insert("equals", iter.equalsVersion); + } + if (!iter.suggests.isEmpty()) { + reqOut.insert("suggests", iter.suggests); + } + arrOut.append(reqOut); + } + obj.insert(keyName, arrOut); +} + +} // namespace Meta diff --git a/launcher/meta/JsonFormat.h b/launcher/meta/JsonFormat.h new file mode 100644 index 0000000..7fbf808 --- /dev/null +++ b/launcher/meta/JsonFormat.h @@ -0,0 +1,58 @@ +/* Copyright 2015-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +#include +#include "Exception.h" + +namespace Meta { +class Index; +class Version; +class VersionList; + +enum class MetadataVersion { Invalid = -1, InitialRelease = 1 }; + +class ParseException : public Exception { + public: + using Exception::Exception; +}; +struct Require { + bool operator==(const Require& rhs) const { return uid == rhs.uid; } + bool operator<(const Require& rhs) const { return uid < rhs.uid; } + bool deepEquals(const Require& rhs) const { return uid == rhs.uid && equalsVersion == rhs.equalsVersion && suggests == rhs.suggests; } + QString uid; + QString equalsVersion; + QString suggests; +}; + +using RequireSet = std::set; + +void parseIndex(const QJsonObject& obj, Index* ptr); +void parseVersion(const QJsonObject& obj, Version* ptr); +void parseVersionList(const QJsonObject& obj, VersionList* ptr); + +MetadataVersion parseFormatVersion(const QJsonObject& obj, bool required = true); +void serializeFormatVersion(QJsonObject& obj, MetadataVersion version); + +// FIXME: this has a different shape than the others...FIX IT!? +void parseRequires(const QJsonObject& obj, RequireSet* ptr, const char* keyName = "requires"); +void serializeRequires(QJsonObject& objOut, RequireSet* ptr, const char* keyName = "requires"); +MetadataVersion currentFormatVersion(); +} // namespace Meta + +Q_DECLARE_METATYPE(std::set) diff --git a/launcher/meta/Version.cpp b/launcher/meta/Version.cpp new file mode 100644 index 0000000..ce9a9cc --- /dev/null +++ b/launcher/meta/Version.cpp @@ -0,0 +1,131 @@ +/* Copyright 2015-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "Version.h" + +#include + +#include "JsonFormat.h" + +Meta::Version::Version(const QString& uid, const QString& version) : BaseVersion(), m_uid(uid), m_version(version) {} + +QString Meta::Version::descriptor() const +{ + return m_version; +} +QString Meta::Version::name() const +{ + if (m_data) + return m_data->name; + return m_uid; +} +QString Meta::Version::typeString() const +{ + return m_type; +} + +QDateTime Meta::Version::time() const +{ + return QDateTime::fromMSecsSinceEpoch(m_time * 1000, Qt::UTC); +} + +void Meta::Version::parse(const QJsonObject& obj) +{ + parseVersion(obj, this); +} + +void Meta::Version::mergeFromList(const Meta::Version::Ptr& other) +{ + if (other->m_providesRecommendations) { + if (m_recommended != other->m_recommended) { + setRecommended(other->m_recommended); + } + } + if (m_type != other->m_type) { + setType(other->m_type); + } + if (m_time != other->m_time) { + setTime(other->m_time); + } + if (m_requires != other->m_requires) { + m_requires = other->m_requires; + } + if (m_conflicts != other->m_conflicts) { + m_conflicts = other->m_conflicts; + } + if (m_volatile != other->m_volatile) { + setVolatile(other->m_volatile); + } + if (!other->m_sha256.isEmpty()) { + m_sha256 = other->m_sha256; + } +} + +void Meta::Version::merge(const Version::Ptr& other) +{ + mergeFromList(other); + if (other->m_data) { + setData(other->m_data); + } +} + +QString Meta::Version::localFilename() const +{ + return m_uid + '/' + m_version + ".json"; +} + +::Version Meta::Version::toComparableVersion() const +{ + return { descriptor() }; +} + +void Meta::Version::setType(const QString& type) +{ + m_type = type; + emit typeChanged(); +} + +void Meta::Version::setTime(const qint64 time) +{ + m_time = time; + emit timeChanged(); +} + +void Meta::Version::setRequires(const Meta::RequireSet& reqs, const Meta::RequireSet& conflicts) +{ + m_requires = reqs; + m_conflicts = conflicts; + emit requiresChanged(); +} + +void Meta::Version::setVolatile(bool volatile_) +{ + m_volatile = volatile_; +} + +void Meta::Version::setData(const VersionFilePtr& data) +{ + m_data = data; +} + +void Meta::Version::setProvidesRecommendations() +{ + m_providesRecommendations = true; +} + +void Meta::Version::setRecommended(bool recommended) +{ + m_recommended = recommended; +} diff --git a/launcher/meta/Version.h b/launcher/meta/Version.h new file mode 100644 index 0000000..a2bbc61 --- /dev/null +++ b/launcher/meta/Version.h @@ -0,0 +1,94 @@ +/* Copyright 2015-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "../Version.h" +#include "BaseVersion.h" + +#include +#include +#include +#include + +#include "minecraft/VersionFile.h" + +#include "BaseEntity.h" + +#include "JsonFormat.h" + +namespace Meta { + +class Version : public QObject, public BaseVersion, public BaseEntity { + Q_OBJECT + + public: + using Ptr = std::shared_ptr; + + explicit Version(const QString& uid, const QString& version); + virtual ~Version() = default; + + QString descriptor() const override; + QString name() const override; + QString typeString() const override; + + QString uid() const { return m_uid; } + QString version() const { return m_version; } + QString type() const { return m_type; } + QDateTime time() const; + qint64 rawTime() const { return m_time; } + const Meta::RequireSet& requiredSet() const { return m_requires; } + VersionFilePtr data() const { return m_data; } + bool isRecommended() const { return m_recommended; } + bool isLoaded() const { return m_data != nullptr && BaseEntity::isLoaded(); } + + void merge(const Version::Ptr& other); + void mergeFromList(const Version::Ptr& other); + void parse(const QJsonObject& obj) override; + + QString localFilename() const override; + + ::Version toComparableVersion() const; + + public: // for usage by format parsers only + void setType(const QString& type); + void setTime(qint64 time); + void setRequires(const Meta::RequireSet& reqs, const Meta::RequireSet& conflicts); + void setVolatile(bool volatile_); + void setRecommended(bool recommended); + void setProvidesRecommendations(); + void setData(const VersionFilePtr& data); + + signals: + void typeChanged(); + void timeChanged(); + void requiresChanged(); + + private: + bool m_providesRecommendations = false; + bool m_recommended = false; + QString m_name; + QString m_uid; + QString m_version; + QString m_type; + qint64 m_time = 0; + Meta::RequireSet m_requires; + Meta::RequireSet m_conflicts; + bool m_volatile = false; + VersionFilePtr m_data; +}; +} // namespace Meta + +Q_DECLARE_METATYPE(Meta::Version::Ptr) diff --git a/launcher/meta/VersionList.cpp b/launcher/meta/VersionList.cpp new file mode 100644 index 0000000..dfca52d --- /dev/null +++ b/launcher/meta/VersionList.cpp @@ -0,0 +1,320 @@ +/* Copyright 2015-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "VersionList.h" + +#include +#include + +#include "Application.h" +#include "Index.h" +#include "JsonFormat.h" +#include "Version.h" +#include "meta/BaseEntity.h" +#include "net/Mode.h" +#include "tasks/SequentialTask.h" + +namespace Meta { +VersionList::VersionList(const QString& uid, QObject* parent) : BaseVersionList(parent), m_uid(uid) +{ + setObjectName("Version list: " + uid); +} + +Task::Ptr VersionList::getLoadTask() +{ + auto loadTask = makeShared(tr("Load meta for %1", "This is for the task name that loads the meta index.").arg(m_uid)); + loadTask->addTask(APPLICATION->metadataIndex()->loadTask(Net::Mode::Online)); + loadTask->addTask(this->loadTask(Net::Mode::Online)); + return loadTask; +} + +bool VersionList::isLoaded() +{ + return BaseEntity::isLoaded(); +} + +const BaseVersion::Ptr VersionList::at(int i) const +{ + return m_versions.at(i); +} +int VersionList::count() const +{ + return m_versions.size(); +} + +void VersionList::sortVersions() +{ + beginResetModel(); + std::sort(m_versions.begin(), m_versions.end(), [](const Version::Ptr& a, const Version::Ptr& b) { return *a.get() < *b.get(); }); + endResetModel(); +} + +QVariant VersionList::data(const QModelIndex& index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_versions.size() || index.parent().isValid()) { + return QVariant(); + } + + Version::Ptr version = m_versions.at(index.row()); + + switch (role) { + case VersionPointerRole: + return QVariant::fromValue(std::dynamic_pointer_cast(version)); + case VersionRole: + case VersionIdRole: + return version->version(); + case ParentVersionRole: { + // FIXME: HACK: this should be generic and be replaced by something else. Anything that is a hard 'equals' dep is a 'parent + // uid'. + auto& reqs = version->requiredSet(); + auto iter = std::find_if(reqs.begin(), reqs.end(), [](const Require& req) { return req.uid == "net.minecraft"; }); + if (iter != reqs.end()) { + return (*iter).equalsVersion; + } + return QVariant(); + } + case TypeRole: + return version->type(); + + case UidRole: + return version->uid(); + case TimeRole: + return version->time(); + case RequiresRole: + return QVariant::fromValue(version->requiredSet()); + case SortRole: + return version->rawTime(); + case VersionPtrRole: + return QVariant::fromValue(version); + case RecommendedRole: + return version->isRecommended() || m_externalRecommendsVersions.contains(version->version()); + case JavaMajorRole: { + auto major = version->version(); + if (major.startsWith("java")) { + major = "Java " + major.mid(4); + } + return major; + } + // FIXME: this should be determined in whatever view/proxy is used... + // case LatestRole: return version == getLatestStable(); + default: + return QVariant(); + } +} + +BaseVersionList::RoleList VersionList::providesRoles() const +{ + return m_provided_roles; +} + +void VersionList::setProvidedRoles(RoleList roles) +{ + m_provided_roles = roles; +}; + +QHash VersionList::roleNames() const +{ + QHash roles = BaseVersionList::roleNames(); + roles.insert(UidRole, "uid"); + roles.insert(TimeRole, "time"); + roles.insert(SortRole, "sort"); + roles.insert(RequiresRole, "requires"); + return roles; +} + +QString VersionList::localFilename() const +{ + return m_uid + "/index.json"; +} + +QString VersionList::humanReadable() const +{ + return m_name.isEmpty() ? m_uid : m_name; +} + +Version::Ptr VersionList::getVersion(const QString& version) +{ + Version::Ptr out = m_lookup.value(version, nullptr); + if (!out) { + out = std::make_shared(m_uid, version); + m_lookup[version] = out; + setupAddedVersion(m_versions.size(), out); + m_versions.append(out); + } + return out; +} + +bool VersionList::hasVersion(QString version) const +{ + auto ver = std::find_if(m_versions.constBegin(), m_versions.constEnd(), + [version](Meta::Version::Ptr const& a) { return a->version() == version; }); + return (ver != m_versions.constEnd()); +} + +void VersionList::setName(const QString& name) +{ + m_name = name; + emit nameChanged(name); +} + +void VersionList::setVersions(const QList& versions) +{ + beginResetModel(); + m_versions = versions; + std::sort(m_versions.begin(), m_versions.end(), + [](const Version::Ptr& a, const Version::Ptr& b) { return a->rawTime() > b->rawTime(); }); + for (int i = 0; i < m_versions.size(); ++i) { + m_lookup.insert(m_versions.at(i)->version(), m_versions.at(i)); + setupAddedVersion(i, m_versions.at(i)); + } + + // FIXME: this is dumb, we have 'recommended' as part of the metadata already... + auto recommendedIt = + std::find_if(m_versions.constBegin(), m_versions.constEnd(), [](const Version::Ptr& ptr) { return ptr->type() == "release"; }); + m_recommended = recommendedIt == m_versions.constEnd() ? nullptr : *recommendedIt; + endResetModel(); +} + +void VersionList::parse(const QJsonObject& obj) +{ + parseVersionList(obj, this); +} + +void VersionList::addExternalRecommends(const QStringList& recommends) +{ + m_externalRecommendsVersions.append(recommends); +} + +void VersionList::clearExternalRecommends() +{ + m_externalRecommendsVersions.clear(); +} + +// FIXME: this is dumb, we have 'recommended' as part of the metadata already... +static const Meta::Version::Ptr& getBetterVersion(const Meta::Version::Ptr& a, const Meta::Version::Ptr& b) +{ + if (!a) + return b; + if (!b) + return a; + if (a->type() == b->type()) { + // newer of same type wins + return (a->rawTime() > b->rawTime() ? a : b); + } + // 'release' type wins + return (a->type() == "release" ? a : b); +} + +void VersionList::mergeFromIndex(const VersionList::Ptr& other) +{ + if (m_name != other->m_name) { + setName(other->m_name); + } + if (!other->m_sha256.isEmpty()) { + m_sha256 = other->m_sha256; + } +} + +void VersionList::merge(const VersionList::Ptr& other) +{ + if (m_name != other->m_name) { + setName(other->m_name); + } + if (!other->m_sha256.isEmpty()) { + m_sha256 = other->m_sha256; + } + + // TODO: do not reset the whole model. maybe? + beginResetModel(); + if (other->m_versions.isEmpty()) { + qWarning() << "Empty list loaded ..."; + } + for (auto version : other->m_versions) { + // we already have the version. merge the contents + if (m_lookup.contains(version->version())) { + auto existing = m_lookup.value(version->version()); + existing->mergeFromList(version); + version = existing; + } else { + m_lookup.insert(version->version(), version); + // connect it. + setupAddedVersion(m_versions.size(), version); + m_versions.append(version); + } + m_recommended = getBetterVersion(m_recommended, version); + } + endResetModel(); +} + +void VersionList::setupAddedVersion(const int row, const Version::Ptr& version) +{ + disconnect(version.get(), &Version::requiresChanged, this, nullptr); + disconnect(version.get(), &Version::timeChanged, this, nullptr); + disconnect(version.get(), &Version::typeChanged, this, nullptr); + + connect(version.get(), &Version::requiresChanged, this, + [this, row]() { emit dataChanged(index(row), index(row), QList() << RequiresRole); }); + connect(version.get(), &Version::timeChanged, this, + [this, row]() { emit dataChanged(index(row), index(row), { TimeRole, SortRole }); }); + connect(version.get(), &Version::typeChanged, this, [this, row]() { emit dataChanged(index(row), index(row), { TypeRole }); }); +} + +BaseVersion::Ptr VersionList::getRecommended() const +{ + return m_recommended; +} + +void VersionList::waitToLoad() +{ + if (isLoaded()) + return; + QEventLoop ev; + auto task = getLoadTask(); + connect(task.get(), &Task::finished, &ev, &QEventLoop::quit); + task->start(); + ev.exec(); +} + +Version::Ptr VersionList::getRecommendedForParent(const QString& uid, const QString& version) +{ + auto foundExplicit = std::find_if(m_versions.begin(), m_versions.end(), [uid, version](Version::Ptr ver) -> bool { + auto& reqs = ver->requiredSet(); + auto parentReq = std::find_if(reqs.begin(), reqs.end(), [uid, version](const Require& req) -> bool { + return req.uid == uid && req.equalsVersion == version; + }); + return parentReq != reqs.end() && ver->isRecommended(); + }); + if (foundExplicit != m_versions.end()) { + return *foundExplicit; + } + return nullptr; +} + +Version::Ptr VersionList::getLatestForParent(const QString& uid, const QString& version) +{ + Version::Ptr latestCompat = nullptr; + for (auto ver : m_versions) { + auto& reqs = ver->requiredSet(); + auto parentReq = std::find_if(reqs.begin(), reqs.end(), [uid, version](const Require& req) -> bool { + return req.uid == uid && req.equalsVersion == version; + }); + if (parentReq != reqs.end()) { + latestCompat = getBetterVersion(latestCompat, ver); + } + } + return latestCompat; +} + +} // namespace Meta diff --git a/launcher/meta/VersionList.h b/launcher/meta/VersionList.h new file mode 100644 index 0000000..18681b8 --- /dev/null +++ b/launcher/meta/VersionList.h @@ -0,0 +1,99 @@ +/* Copyright 2015-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include "BaseEntity.h" +#include "BaseVersionList.h" + +#include "meta/Version.h" + +namespace Meta { + +class VersionList : public BaseVersionList, public BaseEntity { + Q_OBJECT + Q_PROPERTY(QString uid READ uid CONSTANT) + Q_PROPERTY(QString name READ name NOTIFY nameChanged) + public: + explicit VersionList(const QString& uid, QObject* parent = nullptr); + virtual ~VersionList() = default; + + using Ptr = std::shared_ptr; + + enum Roles { UidRole = Qt::UserRole + 100, TimeRole, RequiresRole, VersionPtrRole }; + + bool isLoaded() override; + Task::Ptr getLoadTask() override; + const BaseVersion::Ptr at(int i) const override; + int count() const override; + void sortVersions() override; + + BaseVersion::Ptr getRecommended() const override; + Version::Ptr getRecommendedForParent(const QString& uid, const QString& version); + Version::Ptr getLatestForParent(const QString& uid, const QString& version); + + QVariant data(const QModelIndex& index, int role) const override; + RoleList providesRoles() const override; + QHash roleNames() const override; + + void setProvidedRoles(RoleList roles); + + QString localFilename() const override; + + QString uid() const { return m_uid; } + QString name() const { return m_name; } + QString humanReadable() const; + + Version::Ptr getVersion(const QString& version); + bool hasVersion(QString version) const; + + QList versions() const { return m_versions; } + + // this blocks until the version list is loaded + void waitToLoad(); + + public: // for usage only by parsers + void setName(const QString& name); + void setVersions(const QList& versions); + void merge(const VersionList::Ptr& other); + void mergeFromIndex(const VersionList::Ptr& other); + void parse(const QJsonObject& obj) override; + void addExternalRecommends(const QStringList& recommends); + void clearExternalRecommends(); + + signals: + void nameChanged(const QString& name); + + protected slots: + void updateListData(QList) override {} + + private: + QList m_versions; + QStringList m_externalRecommendsVersions; + QHash m_lookup; + QString m_uid; + QString m_name; + + Version::Ptr m_recommended; + + RoleList m_provided_roles = { VersionPointerRole, VersionRole, VersionIdRole, ParentVersionRole, TypeRole, UidRole, + TimeRole, RequiresRole, SortRole, RecommendedRole, LatestRole, VersionPtrRole }; + + void setupAddedVersion(int row, const Version::Ptr& version); +}; +} // namespace Meta +Q_DECLARE_METATYPE(Meta::VersionList::Ptr) diff --git a/launcher/minecraft/Agent.h b/launcher/minecraft/Agent.h new file mode 100644 index 0000000..2432679 --- /dev/null +++ b/launcher/minecraft/Agent.h @@ -0,0 +1,13 @@ +#pragma once + +#include + +#include "Library.h" + +struct Agent { + /// The library pointing to the jar this Java agent is contained within + LibraryPtr library; + + /// The argument to the Java agent, passed after an = if present + QString argument; +}; diff --git a/launcher/minecraft/AssetsUtils.cpp b/launcher/minecraft/AssetsUtils.cpp new file mode 100644 index 0000000..37d02b5 --- /dev/null +++ b/launcher/minecraft/AssetsUtils.cpp @@ -0,0 +1,323 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "AssetsUtils.h" +#include "BuildConfig.h" +#include "FileSystem.h" +#include "net/ApiDownload.h" +#include "net/ChecksumValidator.h" +#include "net/Download.h" + +#include "Application.h" +#include "net/NetRequest.h" +#include "update/AssetUpdateTask.h" + +namespace { +QSet collectPathsFromDir(QString dirPath) +{ + QFileInfo dirInfo(dirPath); + + if (!dirInfo.exists()) { + return {}; + } + + QSet out; + + QDirIterator iter(dirPath, QDirIterator::Subdirectories); + while (iter.hasNext()) { + QString value = iter.next(); + QFileInfo info(value); + if (info.isFile()) { + out.insert(value); + qDebug() << value; + } + } + return out; +} +} // namespace + +namespace AssetsUtils { + +/* + * Returns true on success, with index populated + * index is undefined otherwise + */ +bool loadAssetsIndexJson(const QString& assetsId, const QString& path, AssetsIndex& index) +{ + /* + { + "objects": { + "icons/icon_16x16.png": { + "hash": "bdf48ef6b5d0d23bbb02e17d04865216179f510a", + "size": 3665 + }, + ... + } + } + } + */ + + QFile file(path); + + // Try to open the file and fail if we can't. + // TODO: We should probably report this error to the user. + if (!file.open(QIODevice::ReadOnly)) { + qCritical() << "Failed to read assets index file" << path << "error:" << file.errorString(); + return false; + } + index.id = assetsId; + + // Read the file and close it. + QByteArray jsonData = file.readAll(); + file.close(); + + QJsonParseError parseError; + QJsonDocument jsonDoc = QJsonDocument::fromJson(jsonData, &parseError); + + // Fail if the JSON is invalid. + if (parseError.error != QJsonParseError::NoError) { + qCritical() << "Failed to parse assets index file:" << parseError.errorString() << "at offset " + << QString::number(parseError.offset); + return false; + } + + // Make sure the root is an object. + if (!jsonDoc.isObject()) { + qCritical() << "Invalid assets index JSON: Root should be an array."; + return false; + } + + QJsonObject root = jsonDoc.object(); + + QJsonValue isVirtual = root.value("virtual"); + if (!isVirtual.isUndefined()) { + index.isVirtual = isVirtual.toBool(false); + } + + QJsonValue mapToResources = root.value("map_to_resources"); + if (!mapToResources.isUndefined()) { + index.mapToResources = mapToResources.toBool(false); + } + + QJsonValue objects = root.value("objects"); + QVariantMap map = objects.toVariant().toMap(); + + for (QVariantMap::const_iterator iter = map.begin(); iter != map.end(); ++iter) { + // qDebug() << iter.key(); + + QVariant variant = iter.value(); + QVariantMap nested_objects = variant.toMap(); + + AssetObject object; + + for (QVariantMap::const_iterator nested_iter = nested_objects.begin(); nested_iter != nested_objects.end(); ++nested_iter) { + // qDebug() << nested_iter.key() << nested_iter.value().toString(); + QString key = nested_iter.key(); + QVariant value = nested_iter.value(); + + if (key == "hash") { + object.hash = value.toString(); + } else if (key == "size") { + object.size = value.toLongLong(); + } + } + + index.objects.insert(iter.key(), object); + } + + return true; +} + +// FIXME: ugly code duplication +QDir getAssetsDir(const QString& assetsId, const QString& resourcesFolder) +{ + QDir assetsDir = QDir("assets/"); + QDir indexDir = QDir(FS::PathCombine(assetsDir.path(), "indexes")); + QDir objectDir = QDir(FS::PathCombine(assetsDir.path(), "objects")); + QDir virtualDir = QDir(FS::PathCombine(assetsDir.path(), "virtual")); + + QString indexPath = FS::PathCombine(indexDir.path(), assetsId + ".json"); + QFile indexFile(indexPath); + QDir virtualRoot(FS::PathCombine(virtualDir.path(), assetsId)); + + if (!indexFile.exists()) { + qCritical() << "No assets index file" << indexPath << "; can't determine assets path!"; + return virtualRoot; + } + + AssetsIndex index; + if (!AssetsUtils::loadAssetsIndexJson(assetsId, indexPath, index)) { + qCritical() << "Failed to load asset index file" << indexPath << "; can't determine assets path!"; + return virtualRoot; + } + + QString targetPath; + if (index.isVirtual) { + return virtualRoot; + } else if (index.mapToResources) { + return QDir(resourcesFolder); + } + return virtualRoot; +} + +// FIXME: ugly code duplication +bool reconstructAssets(QString assetsId, QString resourcesFolder) +{ + QDir assetsDir = QDir("assets/"); + QDir indexDir = QDir(FS::PathCombine(assetsDir.path(), "indexes")); + QDir objectDir = QDir(FS::PathCombine(assetsDir.path(), "objects")); + QDir virtualDir = QDir(FS::PathCombine(assetsDir.path(), "virtual")); + + QString indexPath = FS::PathCombine(indexDir.path(), assetsId + ".json"); + QFile indexFile(indexPath); + QDir virtualRoot(FS::PathCombine(virtualDir.path(), assetsId)); + + if (!indexFile.exists()) { + qCritical() << "No assets index file" << indexPath << "; can't reconstruct assets!"; + return false; + } + + qDebug() << "reconstructAssets" << assetsDir.path() << indexDir.path() << objectDir.path() << virtualDir.path() << virtualRoot.path(); + + AssetsIndex index; + if (!AssetsUtils::loadAssetsIndexJson(assetsId, indexPath, index)) { + qCritical() << "Failed to load asset index file" << indexPath << "; can't reconstruct assets!"; + return false; + } + + QString targetPath; + bool removeLeftovers = false; + if (index.isVirtual) { + targetPath = virtualRoot.path(); + removeLeftovers = true; + qDebug() << "Reconstructing virtual assets folder at" << targetPath; + } else if (index.mapToResources) { + targetPath = resourcesFolder; + qDebug() << "Reconstructing resources folder at" << targetPath; + } + + if (!targetPath.isNull()) { + auto presentFiles = collectPathsFromDir(targetPath); + for (QString map : index.objects.keys()) { + AssetObject asset_object = index.objects.value(map); + QString target_path = FS::PathCombine(targetPath, map); + QFile target(target_path); + + QString tlk = asset_object.hash.left(2); + + QString original_path = FS::PathCombine(objectDir.path(), tlk, asset_object.hash); + QFile original(original_path); + if (!original.exists()) + continue; + + presentFiles.remove(target_path); + + if (!target.exists()) { + QFileInfo info(target_path); + QDir target_dir = info.dir(); + + qDebug() << target_dir.path(); + FS::ensureFolderPathExists(target_dir.path()); + + bool couldCopy = original.copy(target_path); + qDebug() << " Copying" << original_path << "to" << target_path << QString::number(couldCopy); + } + } + + // TODO: Write last used time to virtualRoot/.lastused + if (removeLeftovers) { + for (auto& file : presentFiles) { + qDebug() << "Would remove" << file; + } + } + } + return true; +} + +} // namespace AssetsUtils + +Net::NetRequest::Ptr AssetObject::getDownloadAction() +{ + QFileInfo objectFile(getLocalPath()); + if ((!objectFile.isFile()) || (objectFile.size() != size)) { + auto objectDL = Net::ApiDownload::makeFile(getUrl(), objectFile.filePath()); + if (hash.size()) { + objectDL->addValidator(new Net::ChecksumValidator(QCryptographicHash::Sha1, hash)); + } + objectDL->setProgress(objectDL->getProgress(), size); + return objectDL; + } + return nullptr; +} + +QString AssetObject::getLocalPath() +{ + return "assets/objects/" + getRelPath(); +} + +QUrl AssetObject::getUrl() +{ + auto resourceURL = AssetUpdateTask::resourceUrl(); + return resourceURL + getRelPath(); +} + +QString AssetObject::getRelPath() +{ + return hash.left(2) + "/" + hash; +} + +NetJob::Ptr AssetsIndex::getDownloadJob() +{ + auto job = makeShared(QObject::tr("Assets for %1").arg(id), APPLICATION->network()); + for (auto& object : objects.values()) { + auto dl = object.getDownloadAction(); + if (dl) { + job->addNetAction(dl); + } + } + if (job->size()) + return job; + return nullptr; +} diff --git a/launcher/minecraft/AssetsUtils.h b/launcher/minecraft/AssetsUtils.h new file mode 100644 index 0000000..ea3613b --- /dev/null +++ b/launcher/minecraft/AssetsUtils.h @@ -0,0 +1,50 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include "net/NetJob.h" +#include "net/NetRequest.h" + +struct AssetObject { + QString getRelPath(); + QUrl getUrl(); + QString getLocalPath(); + Net::NetRequest::Ptr getDownloadAction(); + + QString hash; + qint64 size; +}; + +struct AssetsIndex { + NetJob::Ptr getDownloadJob(); + + QString id; + QMap objects; + bool isVirtual = false; + bool mapToResources = false; +}; + +/// FIXME: this is absolutely horrendous. REDO!!!! +namespace AssetsUtils { +bool loadAssetsIndexJson(const QString& id, const QString& file, AssetsIndex& index); + +QDir getAssetsDir(const QString& assetsId, const QString& resourcesFolder); + +/// Reconstruct a virtual assets folder for the given assets ID and return the folder +bool reconstructAssets(QString assetsId, QString resourcesFolder); +} // namespace AssetsUtils diff --git a/launcher/minecraft/Component.cpp b/launcher/minecraft/Component.cpp new file mode 100644 index 0000000..6a8bb27 --- /dev/null +++ b/launcher/minecraft/Component.cpp @@ -0,0 +1,485 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "Component.h" +#include +#include + +#include + +#include "Application.h" +#include "FileSystem.h" +#include "OneSixVersionFormat.h" +#include "VersionFile.h" +#include "meta/Version.h" +#include "minecraft/Component.h" +#include "minecraft/PackProfile.h" + +#include + +const QMap Component::KNOWN_MODLOADERS = { + { "net.neoforged", { ModPlatform::NeoForge, { "net.minecraftforge", "net.fabricmc.fabric-loader", "org.quiltmc.quilt-loader" } } }, + { "net.minecraftforge", { ModPlatform::Forge, { "net.neoforged", "net.fabricmc.fabric-loader", "org.quiltmc.quilt-loader" } } }, + { "net.fabricmc.fabric-loader", { ModPlatform::Fabric, { "net.minecraftforge", "net.neoforged", "org.quiltmc.quilt-loader" } } }, + { "org.quiltmc.quilt-loader", { ModPlatform::Quilt, { "net.minecraftforge", "net.neoforged", "net.fabricmc.fabric-loader" } } }, + { "com.mumfrey.liteloader", { ModPlatform::LiteLoader, {} } } +}; + +Component::Component(PackProfile* parent, const QString& uid) +{ + assert(parent); + m_parent = parent; + + m_uid = uid; +} + +Component::Component(PackProfile* parent, const QString& uid, std::shared_ptr file) +{ + assert(parent); + m_parent = parent; + + m_file = file; + m_uid = uid; + m_cachedVersion = m_file->version; + m_cachedName = m_file->name; + m_loaded = true; +} + +std::shared_ptr Component::getMeta() +{ + return m_metaVersion; +} + +void Component::applyTo(LaunchProfile* profile) +{ + // do not apply disabled components + if (!isEnabled()) { + return; + } + auto vfile = getVersionFile(); + if (vfile) { + vfile->applyTo(profile, m_parent->runtimeContext()); + } else { + profile->applyProblemSeverity(getProblemSeverity()); + } +} + +std::shared_ptr Component::getVersionFile() const +{ + if (m_metaVersion) { + return m_metaVersion->data(); + } else { + return m_file; + } +} + +std::shared_ptr Component::getVersionList() const +{ + // FIXME: what if the metadata index isn't loaded yet? + if (APPLICATION->metadataIndex()->hasUid(m_uid)) { + return APPLICATION->metadataIndex()->get(m_uid); + } + return nullptr; +} + +int Component::getOrder() +{ + if (m_orderOverride) + return m_order; + + auto vfile = getVersionFile(); + if (vfile) { + return vfile->order; + } + return 0; +} + +void Component::setOrder(int order) +{ + m_orderOverride = true; + m_order = order; +} + +QString Component::getID() +{ + return m_uid; +} + +QString Component::getName() +{ + if (!m_cachedName.isEmpty()) + return m_cachedName; + return m_uid; +} + +QString Component::getVersion() +{ + return m_cachedVersion; +} + +QString Component::getFilename() +{ + return m_parent->patchFilePathForUid(m_uid); +} + +QDateTime Component::getReleaseDateTime() +{ + if (m_metaVersion) { + return m_metaVersion->time(); + } + auto vfile = getVersionFile(); + if (vfile) { + return vfile->releaseTime; + } + // FIXME: fake + return QDateTime::currentDateTime(); +} + +bool Component::isEnabled() +{ + return !canBeDisabled() || !m_disabled; +} + +bool Component::canBeDisabled() +{ + return isRemovable() && !m_dependencyOnly; +} + +bool Component::setEnabled(bool state) +{ + bool intendedDisabled = !state; + if (!canBeDisabled()) { + intendedDisabled = false; + } + if (intendedDisabled != m_disabled) { + m_disabled = intendedDisabled; + emit dataChanged(); + return true; + } + return false; +} + +bool Component::isCustom() +{ + return m_file != nullptr; +} + +bool Component::isCustomizable() +{ + return m_metaVersion && getVersionFile(); +} + +bool Component::isRemovable() +{ + return !m_important; +} + +bool Component::isRevertible() +{ + if (isCustom()) { + if (APPLICATION->metadataIndex()->hasUid(m_uid)) { + return true; + } + } + return false; +} + +bool Component::isMoveable() +{ + // HACK, FIXME: this was too dumb and wouldn't follow dependency constraints anyway. For now hardcoded to 'true'. + return true; +} + +bool Component::isVersionChangeable(bool wait) +{ + auto list = getVersionList(); + if (list) { + if (wait) + list->waitToLoad(); + return list->count() != 0; + } + return false; +} + +bool Component::isKnownModloader() +{ + auto iter = KNOWN_MODLOADERS.find(m_uid); + return iter != KNOWN_MODLOADERS.cend(); +} + +QStringList Component::knownConflictingComponents() +{ + auto iter = KNOWN_MODLOADERS.find(m_uid); + if (iter != KNOWN_MODLOADERS.cend()) { + return (*iter).knownConflictingComponents; + } else { + return {}; + } +} + +void Component::setImportant(bool state) +{ + if (m_important != state) { + m_important = state; + emit dataChanged(); + } +} + +ProblemSeverity Component::getProblemSeverity() const +{ + auto file = getVersionFile(); + if (file) { + auto severity = file->getProblemSeverity(); + return m_componentProblemSeverity > severity ? m_componentProblemSeverity : severity; + } + return ProblemSeverity::Error; +} + +const QList Component::getProblems() const +{ + auto file = getVersionFile(); + if (file) { + auto problems = file->getProblems(); + problems.append(m_componentProblems); + return problems; + } + return { { ProblemSeverity::Error, QObject::tr("Patch is not loaded yet.") } }; +} + +void Component::addComponentProblem(ProblemSeverity severity, const QString& description) +{ + if (severity > m_componentProblemSeverity) { + m_componentProblemSeverity = severity; + } + m_componentProblems.append({ severity, description }); + + emit dataChanged(); +} + +void Component::resetComponentProblems() +{ + m_componentProblems.clear(); + m_componentProblemSeverity = ProblemSeverity::None; + + emit dataChanged(); +} + +void Component::setVersion(const QString& version) +{ + if (version == m_version) { + return; + } + m_version = version; + if (m_loaded) { + // we are loaded and potentially have state to invalidate + if (m_file) { + // we have a file... explicit version has been changed and there is nothing else to do. + } else { + // we don't have a file, therefore we are loaded with metadata + m_cachedVersion = version; + // see if the meta version is loaded + auto metaVersion = APPLICATION->metadataIndex()->get(m_uid, version); + if (metaVersion->isLoaded()) { + // if yes, we can continue with that. + m_metaVersion = metaVersion; + } else { + // if not, we need loading + m_metaVersion.reset(); + m_loaded = false; + } + updateCachedData(); + } + } else { + // not loaded... assume it will be sorted out later by the update task + } + emit dataChanged(); +} + +bool Component::customize() +{ + if (isCustom()) { + return false; + } + + auto filename = getFilename(); + if (!FS::ensureFilePathExists(filename)) { + return false; + } + // FIXME: get rid of this try-catch. + try { + QSaveFile jsonFile(filename); + if (!jsonFile.open(QIODevice::WriteOnly)) { + return false; + } + auto vfile = getVersionFile(); + if (!vfile) { + return false; + } + auto document = OneSixVersionFormat::versionFileToJson(vfile); + jsonFile.write(document.toJson()); + if (!jsonFile.commit()) { + return false; + } + m_file = vfile; + m_metaVersion.reset(); + emit dataChanged(); + } catch (const Exception& error) { + qWarning() << "Version could not be loaded:" << error.cause(); + } + return true; +} + +bool Component::revert() +{ + if (!isCustom()) { + // already not custom + return true; + } + auto filename = getFilename(); + bool result = true; + // just kill the file and reload + if (QFile::exists(filename)) { + result = FS::deletePath(filename); + } + if (result) { + // file gone... + m_file.reset(); + + // check local cache for metadata... + auto version = APPLICATION->metadataIndex()->get(m_uid, m_version); + if (version->isLoaded()) { + m_metaVersion = version; + } else { + m_metaVersion.reset(); + m_loaded = false; + } + emit dataChanged(); + } + return result; +} + +/** + * deep inspecting compare for requirement sets + * By default, only uids are compared for set operations. + * This compares all fields of the Require structs in the sets. + */ +static bool deepCompare(const std::set& a, const std::set& b) +{ + // NOTE: this needs to be rewritten if the type of Meta::RequireSet changes + if (a.size() != b.size()) { + return false; + } + for (const auto& reqA : a) { + const auto& iter2 = b.find(reqA); + if (iter2 == b.cend()) { + return false; + } + const auto& reqB = *iter2; + if (!reqA.deepEquals(reqB)) { + return false; + } + } + return true; +} + +void Component::updateCachedData() +{ + auto file = getVersionFile(); + if (file) { + bool changed = false; + if (m_cachedName != file->name) { + m_cachedName = file->name; + changed = true; + } + if (m_cachedVersion != file->version) { + m_cachedVersion = file->version; + changed = true; + } + if (m_cachedVolatile != file->m_volatile) { + m_cachedVolatile = file->m_volatile; + changed = true; + } + if (!deepCompare(m_cachedRequires, file->m_requires)) { + m_cachedRequires = file->m_requires; + changed = true; + } + if (!deepCompare(m_cachedConflicts, file->conflicts)) { + m_cachedConflicts = file->conflicts; + changed = true; + } + if (changed) { + emit dataChanged(); + } + } else { + // in case we removed all the metadata + m_cachedRequires.clear(); + m_cachedConflicts.clear(); + emit dataChanged(); + } +} + +void Component::waitLoadMeta() +{ + if (!m_loaded) { + if (!m_metaVersion || !m_metaVersion->isLoaded()) { + // wait for the loaded version from meta + m_metaVersion = APPLICATION->metadataIndex()->getLoadedVersion(m_uid, m_version); + } + m_loaded = true; + updateCachedData(); + } +} + +void Component::setUpdateAction(const UpdateAction& action) +{ + m_updateAction = action; +} + +UpdateAction Component::getUpdateAction() +{ + return m_updateAction; +} + +void Component::clearUpdateAction() +{ + m_updateAction = UpdateAction{ UpdateActionNone{} }; +} + +QDebug operator<<(QDebug d, const Component& comp) +{ + QDebugStateSaver saver(d); + d.nospace() << "Component(" << comp.m_uid << " : " << comp.m_cachedVersion << ")"; + return d; +} diff --git a/launcher/minecraft/Component.h b/launcher/minecraft/Component.h new file mode 100644 index 0000000..eafdb8e --- /dev/null +++ b/launcher/minecraft/Component.h @@ -0,0 +1,157 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include "ProblemProvider.h" +#include "QObjectPtr.h" +#include "meta/JsonFormat.h" +#include "modplatform/ModIndex.h" + +class PackProfile; +class LaunchProfile; +namespace Meta { +class Version; +class VersionList; +} // namespace Meta +class VersionFile; + +struct UpdateActionChangeVersion { + /// version to change to + QString targetVersion; +}; +struct UpdateActionLatestRecommendedCompatible { + /// Parent uid + QString parentUid; + QString parentName; + /// Parent version + QString version; + /// +}; +struct UpdateActionRemove {}; +struct UpdateActionImportantChanged { + QString oldVersion; +}; + +using UpdateActionNone = std::monostate; + +using UpdateAction = std::variant; + +struct ModloaderMapEntry { + ModPlatform::ModLoaderType type; + QStringList knownConflictingComponents; +}; + +class Component : public QObject, public ProblemProvider { + Q_OBJECT + public: + Component(PackProfile* parent, const QString& uid); + + // DEPRECATED: remove these constructors? + Component(PackProfile* parent, const QString& uid, std::shared_ptr file); + + virtual ~Component() {} + + static const QMap KNOWN_MODLOADERS; + + void applyTo(LaunchProfile* profile); + + bool isEnabled(); + bool setEnabled(bool state); + bool canBeDisabled(); + + bool isMoveable(); + bool isCustomizable(); + bool isRevertible(); + bool isRemovable(); + bool isCustom(); + bool isVersionChangeable(bool wait = true); + bool isKnownModloader(); + QStringList knownConflictingComponents(); + + // DEPRECATED: explicit numeric order values, used for loading old non-component config. TODO: refactor and move to migration code + void setOrder(int order); + int getOrder(); + + QString getID(); + QString getName(); + QString getVersion(); + std::shared_ptr getMeta(); + QDateTime getReleaseDateTime(); + + QString getFilename(); + + std::shared_ptr getVersionFile() const; + std::shared_ptr getVersionList() const; + + void setImportant(bool state); + + const QList getProblems() const override; + ProblemSeverity getProblemSeverity() const override; + void addComponentProblem(ProblemSeverity severity, const QString& description); + void resetComponentProblems(); + + void setVersion(const QString& version); + bool customize(); + bool revert(); + + void updateCachedData(); + + void waitLoadMeta(); + + void setUpdateAction(const UpdateAction& action); + void clearUpdateAction(); + UpdateAction getUpdateAction(); + + signals: + void dataChanged(); + + public: /* data */ + PackProfile* m_parent; + + // BEGIN: persistent component list properties + /// ID of the component + QString m_uid; + /// version of the component - when there's a custom json override, this is also the version the component reverts to + QString m_version; + /// if true, this has been added automatically to satisfy dependencies and may be automatically removed + bool m_dependencyOnly = false; + /// if true, the component is either the main component of the instance, or otherwise important and cannot be removed. + bool m_important = false; + /// if true, the component is disabled + bool m_disabled = false; + + /// cached name for display purposes, taken from the version file (meta or local override) + QString m_cachedName; + /// cached version for display AND other purposes, taken from the version file (meta or local override) + QString m_cachedVersion; + /// cached set of requirements, taken from the version file (meta or local override) + Meta::RequireSet m_cachedRequires; + Meta::RequireSet m_cachedConflicts; + /// if true, the component is volatile and may be automatically removed when no longer needed + bool m_cachedVolatile = false; + // END: persistent component list properties + + // DEPRECATED: explicit numeric order values, used for loading old non-component config. TODO: refactor and move to migration code + bool m_orderOverride = false; + int m_order = 0; + + // load state + std::shared_ptr m_metaVersion; + std::shared_ptr m_file; + bool m_loaded = false; + + private: + QList m_componentProblems; + ProblemSeverity m_componentProblemSeverity = ProblemSeverity::None; + UpdateAction m_updateAction = UpdateAction{ UpdateActionNone{} }; +}; + +using ComponentPtr = shared_qobject_ptr; diff --git a/launcher/minecraft/ComponentUpdateTask.cpp b/launcher/minecraft/ComponentUpdateTask.cpp new file mode 100644 index 0000000..73203f7 --- /dev/null +++ b/launcher/minecraft/ComponentUpdateTask.cpp @@ -0,0 +1,817 @@ +#include "ComponentUpdateTask.h" +#include + +#include "Component.h" +#include "ComponentUpdateTask_p.h" +#include "PackProfile.h" +#include "PackProfile_p.h" +#include "ProblemProvider.h" +#include "Version.h" +#include "cassert" +#include "meta/Index.h" +#include "meta/Version.h" +#include "minecraft/MinecraftInstance.h" +#include "minecraft/OneSixVersionFormat.h" +#include "minecraft/ProfileUtils.h" +#include "net/Mode.h" + +#include "Application.h" +#include "tasks/Task.h" + +#include "minecraft/Logging.h" + +/* + * This is responsible for loading the components of a component list AND resolving dependency issues between them + */ + +/* + * FIXME: the 'one shot async task' nature of this does not fit the intended usage + * Really, it should be a reactor/state machine that receives input from the application + * and dynamically adapts to changing requirements... + * + * The reactor should be the only entry into manipulating the PackProfile. + * See: https://en.wikipedia.org/wiki/Reactor_pattern + */ + +/* + * Or make this operate on a snapshot of the PackProfile state, then merge results in as long as the snapshot and PackProfile didn't change? + * If the component list changes, start over. + */ + +/* + * TODO: This task launches multiple other tasks. As such it should be converted to a ConcurrentTask + */ +ComponentUpdateTask::ComponentUpdateTask(Mode mode, Net::Mode netmode, PackProfile* list) : Task() +{ + d.reset(new ComponentUpdateTaskData); + d->m_profile = list; + d->mode = mode; + d->netmode = netmode; +} + +ComponentUpdateTask::~ComponentUpdateTask() {} + +bool ComponentUpdateTask::canAbort() const +{ + for (const auto& status : d->remoteLoadStatusList) { + if (status.task && !status.task->canAbort()) { + return false; + } + } + + return true; +} + +bool ComponentUpdateTask::abort() +{ + bool aborted = true; + for (const auto& status : d->remoteLoadStatusList) { + if (status.task && !status.task->abort()) { + aborted = false; + } + } + + return aborted; +} + +Net::Mode ComponentUpdateTask::netMode() +{ + return d->netmode; +} + +void ComponentUpdateTask::executeTask() +{ + qCDebug(instanceProfileResolveC) << "Loading components"; + setStatus(tr("Loading components")); + loadComponents(); +} + +namespace { +enum class LoadResult { LoadedLocal, RequiresRemote, Failed }; + +LoadResult composeLoadResult(LoadResult a, LoadResult b) +{ + if (a < b) { + return b; + } + return a; +} + +static LoadResult loadComponent(ComponentPtr component, Task::Ptr& loadTask, Net::Mode netmode) +{ + if (component->m_loaded) { + qCDebug(instanceProfileResolveC) << component->getName() << "is already loaded"; + return LoadResult::LoadedLocal; + } + + LoadResult result = LoadResult::Failed; + auto customPatchFilename = component->getFilename(); + if (QFile::exists(customPatchFilename)) { + // if local file exists... + + // check for uid problems inside... + bool fileChanged = false; + auto file = ProfileUtils::parseJsonFile(QFileInfo(customPatchFilename), false); + if (file->uid != component->m_uid) { + file->uid = component->m_uid; + fileChanged = true; + } + if (fileChanged) { + // FIXME: @QUALITY do not ignore return value + ProfileUtils::saveJsonFile(OneSixVersionFormat::versionFileToJson(file), customPatchFilename); + } + + component->m_file = file; + component->m_loaded = true; + result = LoadResult::LoadedLocal; + } else { + auto metaVersion = APPLICATION->metadataIndex()->get(component->m_uid, component->m_version); + component->m_metaVersion = metaVersion; + if (metaVersion->isLoaded()) { + component->m_loaded = true; + result = LoadResult::LoadedLocal; + } else { + loadTask = APPLICATION->metadataIndex()->loadVersion(component->m_uid, component->m_version, netmode); + loadTask->start(); + if (netmode == Net::Mode::Online) + result = LoadResult::RequiresRemote; + else if (metaVersion->isLoaded()) + result = LoadResult::LoadedLocal; + else + result = LoadResult::Failed; + } + } + return result; +} + +// FIXME: dead code. determine if this can still be useful? +/* +static LoadResult loadPackProfile(ComponentPtr component, Task::Ptr& loadTask, Net::Mode netmode) +{ + if(component->m_loaded) + { + qDebug() << component->getName() << "is already loaded"; + return LoadResult::LoadedLocal; + } + + LoadResult result = LoadResult::Failed; + auto metaList = APPLICATION->metadataIndex()->get(component->m_uid); + if(metaList->isLoaded()) + { + component->m_loaded = true; + result = LoadResult::LoadedLocal; + } + else + { + metaList->load(netmode); + loadTask = metaList->getCurrentTask(); + result = LoadResult::RequiresRemote; + } + return result; +} +*/ + +} // namespace + +void ComponentUpdateTask::loadComponents() +{ + LoadResult result = LoadResult::LoadedLocal; + size_t taskIndex = 0; + size_t componentIndex = 0; + d->remoteLoadSuccessful = true; + + // load all the components OR their lists... + for (auto component : d->m_profile->d->components) { + Task::Ptr loadTask; + LoadResult singleResult; + RemoteLoadStatus::Type loadType; + component->resetComponentProblems(); + // FIXME: to do this right, we need to load the lists and decide on which versions to use during dependency resolution. For now, + // ignore all that... +#if 0 + switch(d->mode) + { + case Mode::Launch: + { + singleResult = loadComponent(component, loadTask, d->netmode); + loadType = RemoteLoadStatus::Type::Version; + break; + } + case Mode::Resolution: + { + singleResult = loadPackProfile(component, loadTask, d->netmode); + loadType = RemoteLoadStatus::Type::List; + break; + } + } +#else + singleResult = loadComponent(component, loadTask, d->netmode); + loadType = RemoteLoadStatus::Type::Version; +#endif + if (singleResult == LoadResult::LoadedLocal) { + component->updateCachedData(); + } + result = composeLoadResult(result, singleResult); + if (loadTask) { + qCDebug(instanceProfileResolveC) << d->m_profile->d->m_instance->name() << "|" + << "Remote loading is being run for" << component->getName(); + connect(loadTask.get(), &Task::succeeded, this, [this, taskIndex]() { remoteLoadSucceeded(taskIndex); }); + connect(loadTask.get(), &Task::failed, this, [this, taskIndex](const QString& error) { remoteLoadFailed(taskIndex, error); }); + connect(loadTask.get(), &Task::aborted, this, [this, taskIndex]() { remoteLoadFailed(taskIndex, tr("Aborted")); }); + RemoteLoadStatus status; + status.type = loadType; + status.PackProfileIndex = componentIndex; + status.task = loadTask; + d->remoteLoadStatusList.append(status); + taskIndex++; + } + componentIndex++; + } + d->remoteTasksInProgress = taskIndex; + m_progressTotal = static_cast(taskIndex); + switch (result) { + case LoadResult::LoadedLocal: { + // Everything got loaded. Advance to dependency resolution. + performUpdateActions(); + resolveDependencies(d->mode == Mode::Launch || d->netmode == Net::Mode::Offline); + return; + } + case LoadResult::RequiresRemote: { + // we wait for signals. + break; + } + case LoadResult::Failed: { + emitFailed(tr("Some component metadata load tasks failed.")); + return; + } + } + + setDetails(tr("Downloading metadata for %1 components").arg(taskIndex)); +} + +namespace { +struct RequireEx : public Meta::Require { + size_t indexOfFirstDependee = 0; +}; +struct RequireCompositionResult { + bool ok; + RequireEx outcome; +}; +using RequireExSet = std::set; +} // namespace + +static RequireCompositionResult composeRequirement(const RequireEx& a, const RequireEx& b) +{ + assert(a.uid == b.uid); + RequireEx out; + out.uid = a.uid; + out.indexOfFirstDependee = std::min(a.indexOfFirstDependee, b.indexOfFirstDependee); + if (a.equalsVersion.isEmpty()) { + out.equalsVersion = b.equalsVersion; + } else if (b.equalsVersion.isEmpty()) { + out.equalsVersion = a.equalsVersion; + } else if (a.equalsVersion == b.equalsVersion) { + out.equalsVersion = a.equalsVersion; + } else { + // FIXME: mark error as explicit version conflict + return { false, out }; + } + + if (a.suggests.isEmpty()) { + out.suggests = b.suggests; + } else if (b.suggests.isEmpty()) { + out.suggests = a.suggests; + } else { + Version aVer(a.suggests); + Version bVer(b.suggests); + out.suggests = (aVer < bVer ? b.suggests : a.suggests); + } + return { true, out }; +} + +// gather the requirements from all components, finding any obvious conflicts +static bool gatherRequirementsFromComponents(const ComponentContainer& input, RequireExSet& output) +{ + bool succeeded = true; + size_t componentNum = 0; + for (auto component : input) { + auto& componentRequires = component->m_cachedRequires; + for (const auto& componentRequire : componentRequires) { + auto found = std::find_if(output.cbegin(), output.cend(), + [componentRequire](const Meta::Require& req) { return req.uid == componentRequire.uid; }); + + RequireEx componenRequireEx; + componenRequireEx.uid = componentRequire.uid; + componenRequireEx.suggests = componentRequire.suggests; + componenRequireEx.equalsVersion = componentRequire.equalsVersion; + componenRequireEx.indexOfFirstDependee = componentNum; + + if (found != output.cend()) { + // found... process it further + auto result = composeRequirement(componenRequireEx, *found); + if (result.ok) { + output.erase(componenRequireEx); + output.insert(result.outcome); + } else { + qCCritical(instanceProfileResolveC) << "Conflicting requirements:" << componentRequire.uid + << "versions:" << componentRequire.equalsVersion << ";" << (*found).equalsVersion; + } + succeeded &= result.ok; + } else { + // not found, accumulate + output.insert(componenRequireEx); + } + } + componentNum++; + } + return succeeded; +} + +/// Get list of uids that can be trivially removed because nothing is depending on them anymore (and they are installed as deps) +static void getTrivialRemovals(const ComponentContainer& components, const RequireExSet& reqs, QStringList& toRemove) +{ + for (const auto& component : components) { + if (!component->m_dependencyOnly) + continue; + if (!component->m_cachedVolatile) + continue; + RequireEx reqNeedle; + reqNeedle.uid = component->m_uid; + const auto iter = reqs.find(reqNeedle); + if (iter == reqs.cend()) { + toRemove.append(component->m_uid); + } + } +} + +/** + * handles: + * - trivial addition (there is an unmet requirement and it can be trivially met by adding something) + * - trivial version conflict of dependencies == explicit version required and installed is different + * + * toAdd - set of requirements than mean adding a new component + * toChange - set of requirements that mean changing version of an existing component + */ +static bool getTrivialComponentChanges(const ComponentIndex& index, const RequireExSet& input, RequireExSet& toAdd, RequireExSet& toChange) +{ + enum class Decision { Undetermined, Met, Missing, VersionNotSame, LockedVersionNotSame } decision = Decision::Undetermined; + + QString reqStr; + bool succeeded = true; + // list the composed requirements and say if they are met or unmet + for (auto& req : input) { + do { + if (req.equalsVersion.isEmpty()) { + reqStr = QString("Req: %1").arg(req.uid); + if (index.contains(req.uid)) { + decision = Decision::Met; + } else { + toAdd.insert(req); + decision = Decision::Missing; + } + break; + } else { + reqStr = QString("Req: %1 == %2").arg(req.uid, req.equalsVersion); + const auto& compIter = index.find(req.uid); + if (compIter == index.cend()) { + toAdd.insert(req); + decision = Decision::Missing; + break; + } + auto& comp = (*compIter); + if (comp->getVersion() != req.equalsVersion) { + if (comp->isCustom()) { + decision = Decision::LockedVersionNotSame; + } else { + if (comp->m_dependencyOnly) { + decision = Decision::VersionNotSame; + } else { + decision = Decision::LockedVersionNotSame; + } + } + break; + } + decision = Decision::Met; + } + } while (false); + switch (decision) { + case Decision::Undetermined: + qCCritical(instanceProfileResolveC) << "No decision for" << reqStr; + succeeded = false; + break; + case Decision::Met: + qCDebug(instanceProfileResolveC) << reqStr << "Is met."; + break; + case Decision::Missing: + qCDebug(instanceProfileResolveC) << reqStr << "Is missing and should be added at" << req.indexOfFirstDependee; + toAdd.insert(req); + break; + case Decision::VersionNotSame: + qCDebug(instanceProfileResolveC) << reqStr << "already has different version that can be changed."; + toChange.insert(req); + break; + case Decision::LockedVersionNotSame: + qCDebug(instanceProfileResolveC) << reqStr << "already has different version that cannot be changed."; + succeeded = false; + break; + } + } + return succeeded; +} + +ComponentContainer ComponentUpdateTask::collectTreeLinked(const QString& uid) +{ + ComponentContainer linked; + + auto& components = d->m_profile->d->components; + auto& componentIndex = d->m_profile->d->componentIndex; + auto& instance = d->m_profile->d->m_instance; + for (auto comp : components) { + qCDebug(instanceProfileResolveC) << instance->name() << "|" + << "scanning" << comp->getID() << ":" << comp->getVersion() << "for tree link"; + auto dep = std::find_if(comp->m_cachedRequires.cbegin(), comp->m_cachedRequires.cend(), + [uid](const Meta::Require& req) -> bool { return req.uid == uid; }); + if (dep != comp->m_cachedRequires.cend()) { + qCDebug(instanceProfileResolveC) << instance->name() << "|" << comp->getID() << ":" << comp->getVersion() << "depends on" + << uid; + linked.append(comp); + } + } + auto iter = componentIndex.find(uid); + if (iter != componentIndex.end()) { + ComponentPtr comp = *iter; + comp->updateCachedData(); + qCDebug(instanceProfileResolveC) << instance->name() << "|" << comp->getID() << ":" << comp->getVersion() << "has" + << comp->m_cachedRequires.size() << "dependencies"; + for (auto dep : comp->m_cachedRequires) { + qCDebug(instanceProfileC) << instance->name() << "|" << uid << "depends on" << dep.uid; + auto found = componentIndex.find(dep.uid); + if (found != componentIndex.end()) { + qCDebug(instanceProfileC) << instance->name() << "|" << (*found)->getID() << "is present"; + linked.append(*found); + } + } + } + return linked; +} + +// FIXME, TODO: decouple dependency resolution from loading +// FIXME: This works directly with the PackProfile internals. It shouldn't! It needs richer data types than PackProfile uses. +// FIXME: throw all this away and use a graph +void ComponentUpdateTask::resolveDependencies(bool checkOnly) +{ + qCDebug(instanceProfileResolveC) << "Resolving dependencies"; + /* + * this is a naive dependency resolving algorithm. all it does is check for following conditions and react in simple ways: + * 1. There are conflicting dependencies on the same uid with different exact version numbers + * -> hard error + * 2. A dependency has non-matching exact version number + * -> hard error + * 3. A dependency is entirely missing and needs to be injected before the dependee(s) + * -> requirements are injected + * + * NOTE: this is a placeholder and should eventually be replaced with something 'serious' + */ + auto& components = d->m_profile->d->components; + auto& componentIndex = d->m_profile->d->componentIndex; + + RequireExSet allRequires; + QStringList toRemove; + do { + allRequires.clear(); + toRemove.clear(); + if (!gatherRequirementsFromComponents(components, allRequires)) { + finalizeComponents(); + emitFailed(tr("Conflicting requirements detected during dependency checking!")); + return; + } + getTrivialRemovals(components, allRequires, toRemove); + if (!toRemove.isEmpty()) { + qCDebug(instanceProfileResolveC) << "Removing obsolete components..."; + for (auto& remove : toRemove) { + qCDebug(instanceProfileResolveC) << "Removing" << remove; + d->m_profile->remove(remove); + } + } + } while (!toRemove.isEmpty()); + RequireExSet toAdd; + RequireExSet toChange; + bool succeeded = getTrivialComponentChanges(componentIndex, allRequires, toAdd, toChange); + if (!succeeded) { + finalizeComponents(); + emitFailed(tr("Instance has conflicting dependencies.")); + return; + } + if (checkOnly) { + finalizeComponents(); + if (toAdd.size() || toChange.size()) { + emitFailed(tr("Instance has unresolved dependencies while loading/checking for launch.")); + } else { + emitSucceeded(); + } + return; + } + + bool recursionNeeded = false; + if (toAdd.size()) { + // add stuff... + for (auto& add : toAdd) { + auto component = makeShared(d->m_profile, add.uid); + if (!add.equalsVersion.isEmpty()) { + // exact version + qCDebug(instanceProfileResolveC) + << "Adding" << add.uid << "version" << add.equalsVersion << "at position" << add.indexOfFirstDependee; + component->m_version = add.equalsVersion; + } else { + // version needs to be decided + qCDebug(instanceProfileResolveC) << "Adding" << add.uid << "at position" << add.indexOfFirstDependee; + // ############################################################################################################ + // HACK HACK HACK HACK FIXME: this is a placeholder for deciding what version to use. For now, it is hardcoded. + if (!add.suggests.isEmpty()) { + component->m_version = add.suggests; + } else { + if (add.uid == "org.lwjgl") { + component->m_version = "2.9.1"; + } else if (add.uid == "org.lwjgl3") { + component->m_version = "3.1.2"; + } else if (add.uid == "net.fabricmc.intermediary" || add.uid == "org.quiltmc.hashed") { + auto minecraft = std::find_if(components.begin(), components.end(), + [](ComponentPtr& cmp) { return cmp->getID() == "net.minecraft"; }); + if (minecraft != components.end()) { + component->m_version = (*minecraft)->getVersion(); + } + } + } + // HACK HACK HACK HACK FIXME: this is a placeholder for deciding what version to use. For now, it is hardcoded. + // ############################################################################################################ + } + component->m_dependencyOnly = true; + // FIXME: this should not work directly with the component list + d->m_profile->insertComponent(add.indexOfFirstDependee, component); + componentIndex[add.uid] = component; + } + recursionNeeded = true; + } + if (toChange.size()) { + // change a version of something that exists + for (auto& change : toChange) { + // FIXME: this should not work directly with the component list + qCDebug(instanceProfileResolveC) << "Setting version of" << change.uid << "to" << change.equalsVersion; + auto component = componentIndex[change.uid]; + component->setVersion(change.equalsVersion); + } + recursionNeeded = true; + } + + if (recursionNeeded) { + loadComponents(); + } else { + finalizeComponents(); + emitSucceeded(); + } +} + +// Variant visitation via lambda +template +struct overload : Ts... { + using Ts::operator()...; +}; +template +overload(Ts...) -> overload; + +void ComponentUpdateTask::performUpdateActions() +{ + auto& instance = d->m_profile->d->m_instance; + bool addedActions; + QStringList toRemove; + do { + addedActions = false; + toRemove.clear(); + auto& components = d->m_profile->d->components; + auto& componentIndex = d->m_profile->d->componentIndex; + for (auto component : components) { + if (!component) { + continue; + } + auto action = component->getUpdateAction(); + auto visitor = + overload{ [](const UpdateActionNone&) { + // noop + }, + [&component, &instance](const UpdateActionChangeVersion& cv) { + qCDebug(instanceProfileResolveC) << instance->name() << "|" + << "UpdateActionChangeVersion" << component->getID() << ":" + << component->getVersion() << "change to" << cv.targetVersion; + component->setVersion(cv.targetVersion); + component->waitLoadMeta(); + }, + [&component, &instance](const UpdateActionLatestRecommendedCompatible& lrc) { + qCDebug(instanceProfileResolveC) + << instance->name() << "|" + << "UpdateActionLatestRecommendedCompatible" << component->getID() << ":" << component->getVersion() + << "updating to latest recommend or compatible with" << lrc.parentUid << lrc.version; + auto versionList = APPLICATION->metadataIndex()->get(component->getID()); + if (versionList) { + versionList->waitToLoad(); + auto recommended = versionList->getRecommendedForParent(lrc.parentUid, lrc.version); + if (!recommended) { + recommended = versionList->getLatestForParent(lrc.parentUid, lrc.version); + } + if (recommended) { + component->setVersion(recommended->version()); + component->waitLoadMeta(); + return; + } else { + component->addComponentProblem(ProblemSeverity::Error, + QObject::tr("No compatible version of %1 found for %2 %3") + .arg(component->getName(), lrc.parentName, lrc.version)); + } + } else { + component->addComponentProblem( + ProblemSeverity::Error, + QObject::tr("No version list in metadata index for %1").arg(component->getID())); + } + }, + [&component, &instance, &toRemove](const UpdateActionRemove&) { + qCDebug(instanceProfileResolveC) + << instance->name() << "|" + << "UpdateActionRemove" << component->getID() << ":" << component->getVersion() << "removing"; + toRemove.append(component->getID()); + }, + [this, &component, &instance, &addedActions, &componentIndex](const UpdateActionImportantChanged& ic) { + qCDebug(instanceProfileResolveC) + << instance->name() << "|" + << "UpdateImportantChanged" << component->getID() << ":" << component->getVersion() << "was changed from" + << ic.oldVersion << "updating linked components"; + auto oldVersion = APPLICATION->metadataIndex()->getLoadedVersion(component->getID(), ic.oldVersion); + for (auto oldReq : oldVersion->requiredSet()) { + auto currentlyRequired = component->m_cachedRequires.find(oldReq); + if (currentlyRequired == component->m_cachedRequires.cend()) { + auto oldReqComp = componentIndex.find(oldReq.uid); + if (oldReqComp != componentIndex.cend()) { + (*oldReqComp)->setUpdateAction(UpdateAction{ UpdateActionRemove{} }); + addedActions = true; + } + } + } + auto linked = collectTreeLinked(component->getID()); + for (auto comp : linked) { + if (comp->isCustom()) { + continue; + } + auto compUid = comp->getID(); + auto parentReq = std::find_if(component->m_cachedRequires.begin(), component->m_cachedRequires.end(), + [compUid](const Meta::Require& req) { return req.uid == compUid; }); + if (parentReq != component->m_cachedRequires.end()) { + auto newVersion = parentReq->equalsVersion.isEmpty() ? parentReq->suggests : parentReq->equalsVersion; + if (!newVersion.isEmpty()) { + comp->setUpdateAction(UpdateAction{ UpdateActionChangeVersion{ newVersion } }); + } else { + comp->setUpdateAction(UpdateAction{ UpdateActionLatestRecommendedCompatible{ + component->getID(), + component->getName(), + component->getVersion(), + } }); + } + } else { + comp->setUpdateAction(UpdateAction{ UpdateActionLatestRecommendedCompatible{ + component->getID(), + component->getName(), + component->getVersion(), + } }); + } + addedActions = true; + } + } }; + std::visit(visitor, action); + component->clearUpdateAction(); + for (auto uid : toRemove) { + d->m_profile->remove(uid); + } + } + } while (addedActions); +} + +void ComponentUpdateTask::finalizeComponents() +{ + auto& components = d->m_profile->d->components; + auto& componentIndex = d->m_profile->d->componentIndex; + for (auto component : components) { + for (auto req : component->m_cachedRequires) { + auto found = componentIndex.find(req.uid); + if (found == componentIndex.cend()) { + component->addComponentProblem( + ProblemSeverity::Error, + QObject::tr("%1 is missing requirement %2 %3") + .arg(component->getName(), req.uid, req.equalsVersion.isEmpty() ? req.suggests : req.equalsVersion)); + } else { + auto reqComp = *found; + if (!reqComp->getProblems().isEmpty()) { + component->addComponentProblem( + reqComp->getProblemSeverity(), + QObject::tr("%1, a dependency of this component, has reported issues").arg(reqComp->getName())); + } + if (!req.equalsVersion.isEmpty() && req.equalsVersion != reqComp->getVersion()) { + component->addComponentProblem(ProblemSeverity::Error, + QObject::tr("%1, a dependency of this component, is not the required version %2") + .arg(reqComp->getName(), req.equalsVersion)); + } else if (!req.suggests.isEmpty() && req.suggests != reqComp->getVersion()) { + component->addComponentProblem(ProblemSeverity::Warning, + QObject::tr("%1, a dependency of this component, is not the suggested version %2") + .arg(reqComp->getName(), req.suggests)); + } + } + } + for (auto conflict : component->knownConflictingComponents()) { + auto found = componentIndex.find(conflict); + if (found != componentIndex.cend()) { + auto foundComp = *found; + if (foundComp->isCustom()) { + continue; + } + component->addComponentProblem( + ProblemSeverity::Warning, + QObject::tr("%1 and %2 are known to not work together. It is recommended to remove one of them.") + .arg(component->getName(), foundComp->getName())); + } + } + } +} + +void ComponentUpdateTask::remoteLoadSucceeded(size_t taskIndex) +{ + if (static_cast(d->remoteLoadStatusList.size()) < taskIndex) { + qCWarning(instanceProfileResolveC) << "Got task index outside of results" << taskIndex; + return; + } + auto& taskSlot = d->remoteLoadStatusList[taskIndex]; + disconnect(taskSlot.task.get(), &Task::succeeded, this, nullptr); + disconnect(taskSlot.task.get(), &Task::failed, this, nullptr); + disconnect(taskSlot.task.get(), &Task::aborted, this, nullptr); + if (taskSlot.finished) { + qCWarning(instanceProfileResolveC) << "Got multiple results from remote load task" << taskIndex; + return; + } + qCDebug(instanceProfileResolveC) << "Remote task" << taskIndex << "succeeded"; + taskSlot.succeeded = false; + taskSlot.finished = true; + d->remoteTasksInProgress--; + // update the cached data of the component from the downloaded version file. + if (taskSlot.type == RemoteLoadStatus::Type::Version) { + auto component = d->m_profile->getComponent(taskSlot.PackProfileIndex); + component->m_loaded = true; + component->updateCachedData(); + } + checkIfAllFinished(); +} + +void ComponentUpdateTask::remoteLoadFailed(size_t taskIndex, const QString& msg) +{ + if (static_cast(d->remoteLoadStatusList.size()) < taskIndex) { + qCWarning(instanceProfileResolveC) << "Got task index outside of results" << taskIndex; + return; + } + auto& taskSlot = d->remoteLoadStatusList[taskIndex]; + disconnect(taskSlot.task.get(), &Task::succeeded, this, nullptr); + disconnect(taskSlot.task.get(), &Task::failed, this, nullptr); + disconnect(taskSlot.task.get(), &Task::aborted, this, nullptr); + if (taskSlot.finished) { + qCWarning(instanceProfileResolveC) << "Got multiple results from remote load task" << taskIndex; + return; + } + qCDebug(instanceProfileResolveC) << "Remote task" << taskIndex << "failed:" << msg; + d->remoteLoadSuccessful = false; + taskSlot.succeeded = false; + taskSlot.finished = true; + d->remoteTasksInProgress--; + checkIfAllFinished(); +} + +void ComponentUpdateTask::checkIfAllFinished() +{ + setProgress(m_progress + 1, m_progressTotal); + if (d->remoteTasksInProgress) { + // not yet... + return; + } + if (d->remoteLoadSuccessful) { + // nothing bad happened... clear the temp load status and proceed with looking at dependencies + d->remoteLoadStatusList.clear(); + performUpdateActions(); + resolveDependencies(d->mode == Mode::Launch); + } else { + // remote load failed... report error and bail + QStringList allErrorsList; + for (auto& item : d->remoteLoadStatusList) { + if (!item.succeeded) { + const ComponentPtr component = d->m_profile->getComponent(item.PackProfileIndex); + allErrorsList.append(tr("Could not download metadata for %1 %2. Please change the version or try again later.") + .arg(component->getName(), component->m_version)); + } + } + d->remoteLoadStatusList.clear(); + + auto allErrors = allErrorsList.join("\n"); + emitFailed(tr("Component metadata update task failed while downloading from remote server:\n%1").arg(allErrors)); + } +} diff --git a/launcher/minecraft/ComponentUpdateTask.h b/launcher/minecraft/ComponentUpdateTask.h new file mode 100644 index 0000000..2ef9737 --- /dev/null +++ b/launcher/minecraft/ComponentUpdateTask.h @@ -0,0 +1,41 @@ +#pragma once + +#include "minecraft/Component.h" +#include "net/Mode.h" +#include "tasks/Task.h" + +#include +class PackProfile; +struct ComponentUpdateTaskData; + +class ComponentUpdateTask : public Task { + Q_OBJECT + public: + enum class Mode { Launch, Resolution }; + + public: + explicit ComponentUpdateTask(Mode mode, Net::Mode netmode, PackProfile* list); + virtual ~ComponentUpdateTask(); + + bool canAbort() const override; + bool abort() override; + Net::Mode netMode(); + + protected: + void executeTask() override; + + private: + void loadComponents(); + /// collects components that are dependent on or dependencies of the component + QList collectTreeLinked(const QString& uid); + void resolveDependencies(bool checkOnly); + void performUpdateActions(); + void finalizeComponents(); + + void remoteLoadSucceeded(size_t index); + void remoteLoadFailed(size_t index, const QString& msg); + void checkIfAllFinished(); + + private: + std::unique_ptr d; +}; diff --git a/launcher/minecraft/ComponentUpdateTask_p.h b/launcher/minecraft/ComponentUpdateTask_p.h new file mode 100644 index 0000000..8ffb9c7 --- /dev/null +++ b/launcher/minecraft/ComponentUpdateTask_p.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include +#include +#include "net/Mode.h" +#include "tasks/Task.h" + +#include "minecraft/ComponentUpdateTask.h" + +class PackProfile; + +struct RemoteLoadStatus { + enum class Type { Index, List, Version } type = Type::Version; + size_t PackProfileIndex = 0; + bool finished = false; + bool succeeded = false; + Task::Ptr task; +}; + +struct ComponentUpdateTaskData { + PackProfile* m_profile = nullptr; + QList remoteLoadStatusList; + bool remoteLoadSuccessful = true; + size_t remoteTasksInProgress = 0; + ComponentUpdateTask::Mode mode; + Net::Mode netmode; +}; diff --git a/launcher/minecraft/GradleSpecifier.h b/launcher/minecraft/GradleSpecifier.h new file mode 100644 index 0000000..65297ab --- /dev/null +++ b/launcher/minecraft/GradleSpecifier.h @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +struct GradleSpecifier { + GradleSpecifier() { m_valid = false; } + GradleSpecifier(const QString& value) + { + /* + org.gradle.test.classifiers : service : 1.0 : jdk15 @ jar + 0 "org.gradle.test.classifiers:service:1.0:jdk15@jar" + 1 "org.gradle.test.classifiers" + 2 "service" + 3 "1.0" + 4 "jdk15" + 5 "jar" + */ + static const QRegularExpression s_matcher( + QRegularExpression::anchoredPattern("([^:@]+):([^:@]+):([^:@]+)" + "(?::([^:@]+))?" + "(?:@([^:@]+))?")); + QRegularExpressionMatch match = s_matcher.match(value); + m_valid = match.hasMatch(); + if (!m_valid) { + m_invalidValue = value; + return; + } + auto elements = match.captured(); + m_groupId = match.captured(1); + m_artifactId = match.captured(2); + m_version = match.captured(3); + m_classifier = match.captured(4); + if (match.lastCapturedIndex() >= 5) { + m_extension = match.captured(5); + } + } + QString serialize() const + { + if (!m_valid) { + return m_invalidValue; + } + QString retval = m_groupId + ":" + m_artifactId + ":" + m_version; + if (!m_classifier.isEmpty()) { + retval += ":" + m_classifier; + } + if (m_extension.has_value()) { + retval += "@" + m_extension.value(); + } + return retval; + } + QString getFileName() const + { + if (!m_valid) { + return QString(); + } + QString filename = m_artifactId + '-' + m_version; + if (!m_classifier.isEmpty()) { + filename += "-" + m_classifier; + } + filename += "." + m_extension.value_or("jar"); + return filename; + } + QString toPath(const QString& filenameOverride = QString()) const + { + if (!m_valid) { + return QString(); + } + QString filename; + if (filenameOverride.isEmpty()) { + filename = getFileName(); + } else { + filename = filenameOverride; + } + QString path = m_groupId; + path.replace('.', '/'); + path += '/' + m_artifactId + '/' + m_version + '/' + filename; + return path; + } + inline bool valid() const { return m_valid; } + inline QString version() const { return m_version; } + inline QString groupId() const { return m_groupId; } + inline QString artifactId() const { return m_artifactId; } + inline void setClassifier(const QString& classifier) { m_classifier = classifier; } + inline QString classifier() const { return m_classifier; } + inline std::optional extension() const { return m_extension; } + inline QString artifactPrefix() const { return m_groupId + ":" + m_artifactId; } + bool matchName(const GradleSpecifier& other) const + { + return other.artifactId() == artifactId() && other.groupId() == groupId() && other.classifier() == classifier(); + } + bool operator ==(const GradleSpecifier &other) const = default; + + private: + QString m_invalidValue; + QString m_groupId; + QString m_artifactId; + QString m_version; + QString m_classifier; + std::optional m_extension; + bool m_valid = false; +}; diff --git a/launcher/minecraft/LaunchProfile.cpp b/launcher/minecraft/LaunchProfile.cpp new file mode 100644 index 0000000..fb74d4a --- /dev/null +++ b/launcher/minecraft/LaunchProfile.cpp @@ -0,0 +1,379 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "LaunchProfile.h" +#include + +void LaunchProfile::clear() +{ + m_minecraftVersion.clear(); + m_minecraftVersionType.clear(); + m_minecraftAssets.reset(); + m_minecraftArguments.clear(); + m_addnJvmArguments.clear(); + m_tweakers.clear(); + m_mainClass.clear(); + m_appletClass.clear(); + m_libraries.clear(); + m_mavenFiles.clear(); + m_agents.clear(); + m_traits.clear(); + m_jarMods.clear(); + m_mainJar.reset(); + m_problemSeverity = ProblemSeverity::None; +} + +static void applyString(const QString& from, QString& to) +{ + if (from.isEmpty()) + return; + to = from; +} + +void LaunchProfile::applyMinecraftVersion(const QString& id) +{ + applyString(id, this->m_minecraftVersion); +} + +void LaunchProfile::applyAppletClass(const QString& appletClass) +{ + applyString(appletClass, this->m_appletClass); +} + +void LaunchProfile::applyMainClass(const QString& mainClass) +{ + applyString(mainClass, this->m_mainClass); +} + +void LaunchProfile::applyMinecraftArguments(const QString& minecraftArguments) +{ + applyString(minecraftArguments, this->m_minecraftArguments); +} + +void LaunchProfile::applyAddnJvmArguments(const QStringList& addnJvmArguments) +{ + this->m_addnJvmArguments.append(addnJvmArguments); +} + +void LaunchProfile::applyMinecraftVersionType(const QString& type) +{ + applyString(type, this->m_minecraftVersionType); +} + +void LaunchProfile::applyMinecraftAssets(MojangAssetIndexInfo::Ptr assets) +{ + if (assets) { + m_minecraftAssets = assets; + } +} + +void LaunchProfile::applyTraits(const QSet& traits) +{ + this->m_traits.unite(traits); +} + +void LaunchProfile::applyTweakers(const QStringList& tweakers) +{ + // if the applied tweakers override an existing one, skip it. this effectively moves it later in the sequence + QStringList newTweakers; + for (auto& tweaker : m_tweakers) { + if (tweakers.contains(tweaker)) { + continue; + } + newTweakers.append(tweaker); + } + // then just append the new tweakers (or moved original ones) + newTweakers += tweakers; + m_tweakers = newTweakers; +} + +void LaunchProfile::applyJarMods(const QList& jarMods) +{ + this->m_jarMods.append(jarMods); +} + +static int findLibraryByName(QList* haystack, const GradleSpecifier& needle) +{ + int retval = -1; + for (int i = 0; i < haystack->size(); ++i) { + if (haystack->at(i)->rawName().matchName(needle)) { + // only one is allowed. + if (retval != -1) + return -1; + retval = i; + } + } + return retval; +} + +void LaunchProfile::applyMods(const QList& mods) +{ + QList* list = &m_mods; + for (auto& mod : mods) { + auto modCopy = Library::limitedCopy(mod); + + // find the mod by name. + const int index = findLibraryByName(list, mod->rawName()); + // mod not found? just add it. + if (index < 0) { + list->append(modCopy); + return; + } + + auto existingLibrary = list->at(index); + // if we are higher it means we should update + if (Version(mod->version()) > Version(existingLibrary->version())) { + list->replace(index, modCopy); + } + } +} + +void LaunchProfile::applyCompatibleJavaMajors(QList& javaMajor) +{ + m_compatibleJavaMajors.append(javaMajor); +} + +void LaunchProfile::applyCompatibleJavaName(QString javaName) +{ + if (!javaName.isEmpty()) + m_compatibleJavaName = javaName; +} + +void LaunchProfile::applyLibrary(LibraryPtr library, const RuntimeContext& runtimeContext) +{ + if (!library->isActive(runtimeContext)) { + return; + } + + QList* list = &m_libraries; + if (library->isNative()) { + list = &m_nativeLibraries; + } + + auto libraryCopy = Library::limitedCopy(library); + + // find the library by name. + const int index = findLibraryByName(list, library->rawName()); + // library not found? just add it. + if (index < 0) { + list->append(libraryCopy); + return; + } + + auto existingLibrary = list->at(index); + // if we are higher it means we should update + if (Version(library->version()) > Version(existingLibrary->version())) { + list->replace(index, libraryCopy); + } +} + +void LaunchProfile::applyMavenFile(LibraryPtr mavenFile, const RuntimeContext& runtimeContext) +{ + if (!mavenFile->isActive(runtimeContext)) { + return; + } + + if (mavenFile->isNative()) { + return; + } + + // unlike libraries, we do not keep only one version or try to dedupe them + m_mavenFiles.append(Library::limitedCopy(mavenFile)); +} + +void LaunchProfile::applyAgent(const Agent& agent, const RuntimeContext& runtimeContext) +{ + auto lib = agent.library; + if (!lib->isActive(runtimeContext)) { + return; + } + + if (lib->isNative()) { + return; + } + + m_agents.append(agent); +} + +const LibraryPtr LaunchProfile::getMainJar() const +{ + return m_mainJar; +} + +void LaunchProfile::applyMainJar(LibraryPtr jar) +{ + if (jar) { + m_mainJar = jar; + } +} + +void LaunchProfile::applyProblemSeverity(ProblemSeverity severity) +{ + if (m_problemSeverity < severity) { + m_problemSeverity = severity; + } +} + +const QList LaunchProfile::getProblems() const +{ + // FIXME: implement something that actually makes sense here + return {}; +} + +QString LaunchProfile::getMinecraftVersion() const +{ + return m_minecraftVersion; +} + +QString LaunchProfile::getAppletClass() const +{ + return m_appletClass; +} + +QString LaunchProfile::getMainClass() const +{ + return m_mainClass; +} + +const QSet& LaunchProfile::getTraits() const +{ + return m_traits; +} + +const QStringList& LaunchProfile::getTweakers() const +{ + return m_tweakers; +} + +bool LaunchProfile::hasTrait(const QString& trait) const +{ + return m_traits.contains(trait); +} + +ProblemSeverity LaunchProfile::getProblemSeverity() const +{ + return m_problemSeverity; +} + +QString LaunchProfile::getMinecraftVersionType() const +{ + return m_minecraftVersionType; +} + +std::shared_ptr LaunchProfile::getMinecraftAssets() const +{ + if (!m_minecraftAssets) { + return std::make_shared("legacy"); + } + return m_minecraftAssets; +} + +QString LaunchProfile::getMinecraftArguments() const +{ + return m_minecraftArguments; +} + +const QStringList& LaunchProfile::getAddnJvmArguments() const +{ + return m_addnJvmArguments; +} + +const QList& LaunchProfile::getJarMods() const +{ + return m_jarMods; +} + +const QList& LaunchProfile::getLibraries() const +{ + return m_libraries; +} + +const QList& LaunchProfile::getNativeLibraries() const +{ + return m_nativeLibraries; +} + +const QList& LaunchProfile::getMavenFiles() const +{ + return m_mavenFiles; +} + +const QList& LaunchProfile::getAgents() const +{ + return m_agents; +} + +const QList& LaunchProfile::getCompatibleJavaMajors() const +{ + return m_compatibleJavaMajors; +} + +const QString LaunchProfile::getCompatibleJavaName() const +{ + return m_compatibleJavaName; +} + +void LaunchProfile::getLibraryFiles(const RuntimeContext& runtimeContext, + QStringList& jars, + QStringList& nativeJars, + const QString& overridePath, + const QString& tempPath, + bool addJarMods) const +{ + QStringList native32, native64; + jars.clear(); + nativeJars.clear(); + for (auto lib : getLibraries()) { + lib->getApplicableFiles(runtimeContext, jars, nativeJars, native32, native64, overridePath); + } + // NOTE: order is important here, add main jar last to the lists + if (m_mainJar) { + // FIXME: HACK!! jar modding is weird and unsystematic! + if (m_jarMods.size() && addJarMods) { + QDir tempDir(tempPath); + jars.append(tempDir.absoluteFilePath("minecraft.jar")); + } else { + m_mainJar->getApplicableFiles(runtimeContext, jars, nativeJars, native32, native64, overridePath); + } + } + for (auto lib : getNativeLibraries()) { + lib->getApplicableFiles(runtimeContext, jars, nativeJars, native32, native64, overridePath); + } + if (runtimeContext.javaArchitecture == "32") { + nativeJars.append(native32); + } else if (runtimeContext.javaArchitecture == "64") { + nativeJars.append(native64); + } +} diff --git a/launcher/minecraft/LaunchProfile.h b/launcher/minecraft/LaunchProfile.h new file mode 100644 index 0000000..6dc3d9a --- /dev/null +++ b/launcher/minecraft/LaunchProfile.h @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once +#include +#include +#include "Agent.h" +#include "Library.h" + +class LaunchProfile : public ProblemProvider { + public: + virtual ~LaunchProfile() {} + + public: /* application of profile variables from patches */ + void applyMinecraftVersion(const QString& id); + void applyMainClass(const QString& mainClass); + void applyAppletClass(const QString& appletClass); + void applyMinecraftArguments(const QString& minecraftArguments); + void applyAddnJvmArguments(const QStringList& minecraftArguments); + void applyMinecraftVersionType(const QString& type); + void applyMinecraftAssets(MojangAssetIndexInfo::Ptr assets); + void applyTraits(const QSet& traits); + void applyTweakers(const QStringList& tweakers); + void applyJarMods(const QList& jarMods); + void applyMods(const QList& jarMods); + void applyLibrary(LibraryPtr library, const RuntimeContext& runtimeContext); + void applyMavenFile(LibraryPtr library, const RuntimeContext& runtimeContext); + void applyAgent(const Agent& agent, const RuntimeContext& runtimeContext); + void applyCompatibleJavaMajors(QList& javaMajor); + void applyCompatibleJavaName(QString javaName); + void applyMainJar(LibraryPtr jar); + void applyProblemSeverity(ProblemSeverity severity); + /// clear the profile + void clear(); + + public: /* getters for profile variables */ + QString getMinecraftVersion() const; + QString getMainClass() const; + QString getAppletClass() const; + QString getMinecraftVersionType() const; + MojangAssetIndexInfo::Ptr getMinecraftAssets() const; + QString getMinecraftArguments() const; + const QStringList& getAddnJvmArguments() const; + const QSet& getTraits() const; + const QStringList& getTweakers() const; + const QList& getJarMods() const; + const QList& getLibraries() const; + const QList& getNativeLibraries() const; + const QList& getMavenFiles() const; + const QList& getAgents() const; + const QList& getCompatibleJavaMajors() const; + const QString getCompatibleJavaName() const; + const LibraryPtr getMainJar() const; + void getLibraryFiles(const RuntimeContext& runtimeContext, + QStringList& jars, + QStringList& nativeJars, + const QString& overridePath, + const QString& tempPath, + bool addJarMods = true) const; + bool hasTrait(const QString& trait) const; + ProblemSeverity getProblemSeverity() const override; + const QList getProblems() const override; + + private: + /// the version of Minecraft - jar to use + QString m_minecraftVersion; + + /// Release type - "release" or "snapshot" + QString m_minecraftVersionType; + + /// Assets type - "legacy" or a version ID + MojangAssetIndexInfo::Ptr m_minecraftAssets; + + /** + * arguments that should be used for launching minecraft + * + * ex: "--username ${auth_player_name} --session ${auth_session} + * --version ${version_name} --gameDir ${game_directory} --assetsDir ${game_assets}" + */ + QString m_minecraftArguments; + + /** + * Additional arguments to pass to the JVM in addition to those the user has configured, + * memory settings, etc. + */ + QStringList m_addnJvmArguments; + + /// A list of all tweaker classes + QStringList m_tweakers; + + /// The main class to load first + QString m_mainClass; + + /// The applet class, for some very old minecraft releases + QString m_appletClass; + + /// the list of libraries + QList m_libraries; + + /// the list of maven files to be placed in the libraries folder, but not acted upon + QList m_mavenFiles; + + /// the list of java agents to add to JVM arguments + QList m_agents; + + /// the main jar + LibraryPtr m_mainJar; + + /// the list of native libraries + QList m_nativeLibraries; + + /// traits, collected from all the version files (version files can only add) + QSet m_traits; + + /// A list of jar mods. version files can add those. + QList m_jarMods; + + /// the list of mods + QList m_mods; + + /// compatible java major versions + QList m_compatibleJavaMajors; + + QString m_compatibleJavaName; + + ProblemSeverity m_problemSeverity = ProblemSeverity::None; +}; diff --git a/launcher/minecraft/Library.cpp b/launcher/minecraft/Library.cpp new file mode 100644 index 0000000..026f9c2 --- /dev/null +++ b/launcher/minecraft/Library.cpp @@ -0,0 +1,406 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "Library.h" +#include "MinecraftInstance.h" +#include "net/NetRequest.h" + +#include +#include +#include +#include + +/** + * @brief Collect applicable files for the library. + * + * Depending on whether the library is native or not, it adds paths to the + * appropriate lists for jar files, native libraries for 32-bit, and native + * libraries for 64-bit. + * + * @param runtimeContext The current runtime context. + * @param jar List to store paths for jar files. + * @param native List to store paths for native libraries. + * @param native32 List to store paths for 32-bit native libraries. + * @param native64 List to store paths for 64-bit native libraries. + * @param overridePath Optional path to override the default storage path. + */ +void Library::getApplicableFiles(const RuntimeContext& runtimeContext, + QStringList& jar, + QStringList& native, + QStringList& native32, + QStringList& native64, + const QString& overridePath) const +{ + bool local = isLocal(); + // Lambda function to get the absolute file path + auto actualPath = [this, local, overridePath](QString relPath) { + relPath = FS::RemoveInvalidPathChars(relPath); + QFileInfo out(FS::PathCombine(storagePrefix(), relPath)); + if (local && !overridePath.isEmpty()) { + QString fileName = out.fileName(); + return QFileInfo(FS::PathCombine(overridePath, fileName)).absoluteFilePath(); + } + return out.absoluteFilePath(); + }; + + QString raw_storage = storageSuffix(runtimeContext); + if (isNative()) { + if (raw_storage.contains("${arch}")) { + auto nat32Storage = raw_storage; + nat32Storage.replace("${arch}", "32"); + auto nat64Storage = raw_storage; + nat64Storage.replace("${arch}", "64"); + native32 += actualPath(nat32Storage); + native64 += actualPath(nat64Storage); + } else { + native += actualPath(raw_storage); + } + } else { + jar += actualPath(raw_storage); + } +} + +/** + * @brief Get download requests for the library files. + * + * Depending on whether the library is native or not, and the current runtime context, + * this function prepares download requests for the necessary files. It handles both local + * and remote files, checks for stale cache entries, and adds checksummed downloads. + * + * @param runtimeContext The current runtime context. + * @param cache Pointer to the HTTP meta cache. + * @param failedLocalFiles List to store paths for failed local files. + * @param overridePath Optional path to override the default storage path. + * @return QList List of download requests. + */ +QList Library::getDownloads(const RuntimeContext& runtimeContext, + class HttpMetaCache* cache, + QStringList& failedLocalFiles, + const QString& overridePath) const +{ + QList out; + bool stale = isAlwaysStale(); + bool local = isLocal(); + + // Lambda function to check if a local file exists + auto check_local_file = [overridePath, &failedLocalFiles](QString storage) { + QFileInfo fileinfo(storage); + QString fileName = fileinfo.fileName(); + auto fullPath = FS::PathCombine(overridePath, fileName); + QFileInfo localFileInfo(fullPath); + if (!localFileInfo.exists()) { + failedLocalFiles.append(localFileInfo.filePath()); + return false; + } + return true; + }; + + // Lambda function to add a download request + auto add_download = [this, local, check_local_file, cache, stale, &out](QString storage, QString url, QString sha1) { + if (local) { + return check_local_file(storage); + } + auto entry = cache->resolveEntry("libraries", storage); + if (stale) { + entry->setStale(true); + } + if (!entry->isStale()) + return true; + Net::Download::Options options; + if (stale) { + options |= Net::Download::Option::AcceptLocalFiles; + } + + // Don't add a time limit for the libraries cache entry validity + options |= Net::Download::Option::MakeEternal; + + if (sha1.size()) { + auto dl = Net::ApiDownload::makeCached(url, entry, options); + dl->addValidator(new Net::ChecksumValidator(QCryptographicHash::Sha1, sha1)); + qDebug() << "Checksummed Download for:" << rawName().serialize() << "storage:" << storage << "url:" << url; + out.append(dl); + } else { + out.append(Net::ApiDownload::makeCached(url, entry, options)); + qDebug() << "Download for:" << rawName().serialize() << "storage:" << storage << "url:" << url; + } + return true; + }; + + QString raw_storage = storageSuffix(runtimeContext); + if (m_mojangDownloads) { + if (isNative()) { + auto nativeClassifier = getCompatibleNative(runtimeContext); + if (!nativeClassifier.isNull()) { + if (nativeClassifier.contains("${arch}")) { + auto nat32Classifier = nativeClassifier; + nat32Classifier.replace("${arch}", "32"); + auto nat64Classifier = nativeClassifier; + nat64Classifier.replace("${arch}", "64"); + auto nat32info = m_mojangDownloads->getDownloadInfo(nat32Classifier); + if (nat32info) { + auto cooked_storage = raw_storage; + cooked_storage.replace("${arch}", "32"); + add_download(cooked_storage, nat32info->url, nat32info->sha1); + } + auto nat64info = m_mojangDownloads->getDownloadInfo(nat64Classifier); + if (nat64info) { + auto cooked_storage = raw_storage; + cooked_storage.replace("${arch}", "64"); + add_download(cooked_storage, nat64info->url, nat64info->sha1); + } + } else { + auto info = m_mojangDownloads->getDownloadInfo(nativeClassifier); + if (info) { + add_download(raw_storage, info->url, info->sha1); + } + } + } else { + qDebug() << "Ignoring native library" << m_name.serialize() << "because it has no classifier for current OS"; + } + } else { + if (m_mojangDownloads->artifact) { + auto artifact = m_mojangDownloads->artifact; + add_download(raw_storage, artifact->url, artifact->sha1); + } else { + qDebug() << "Ignoring java library" << m_name.serialize() << "because it has no artifact"; + } + } + } else { + auto raw_dl = [this, raw_storage]() { + if (!m_absoluteURL.isEmpty()) { + return m_absoluteURL; + } + + if (m_repositoryURL.isEmpty()) { + return BuildConfig.LIBRARY_BASE + raw_storage; + } + + if (m_repositoryURL.endsWith('/')) { + return m_repositoryURL + raw_storage; + } else { + return m_repositoryURL + QChar('/') + raw_storage; + } + }(); + if (raw_storage.contains("${arch}")) { + QString cooked_storage = raw_storage; + QString cooked_dl = raw_dl; + add_download(cooked_storage.replace("${arch}", "32"), cooked_dl.replace("${arch}", "32"), QString()); + cooked_storage = raw_storage; + cooked_dl = raw_dl; + add_download(cooked_storage.replace("${arch}", "64"), cooked_dl.replace("${arch}", "64"), QString()); + } else { + add_download(raw_storage, raw_dl, QString()); + } + } + return out; +} + +/** + * @brief Check if the library is active in the given runtime context. + * + * This function evaluates rules to determine if the library should be active, + * considering both general rules and native compatibility. + * + * @param runtimeContext The current runtime context. + * @return bool True if the library is active, false otherwise. + */ +bool Library::isActive(const RuntimeContext& runtimeContext) const +{ + bool result = true; + if (m_rules.empty()) { + result = true; + } else { + Rule::Action ruleResult = Rule::Disallow; + for (auto rule : m_rules) { + Rule::Action temp = rule.apply(runtimeContext); + if (temp != Rule::Defer) + ruleResult = temp; + } + result = result && (ruleResult == Rule::Allow); + } + if (isNative()) { + result = result && !getCompatibleNative(runtimeContext).isNull(); + } + return result; +} + +/** + * @brief Check if the library is considered local. + * + * @return bool True if the library is local, false otherwise. + */ +bool Library::isLocal() const +{ + return m_hint == "local"; +} + +/** + * @brief Check if the library is always considered stale. + * + * @return bool True if the library is always stale, false otherwise. + */ +bool Library::isAlwaysStale() const +{ + return m_hint == "always-stale"; +} + +/** + * @brief Get the compatible native classifier for the current runtime context. + * + * This function attempts to match the current runtime context with the appropriate + * native classifier. + * + * @param runtimeContext The current runtime context. + * @return QString The compatible native classifier, or an empty string if none is found. + */ +QString Library::getCompatibleNative(const RuntimeContext& runtimeContext) const +{ + // try to match precise classifier "[os]-[arch]" + auto entry = m_nativeClassifiers.constFind(runtimeContext.getClassifier()); + // try to match imprecise classifier on legacy architectures "[os]" + if (entry == m_nativeClassifiers.constEnd() && runtimeContext.isLegacyArch()) + entry = m_nativeClassifiers.constFind(runtimeContext.system); + + if (entry == m_nativeClassifiers.constEnd()) + return QString(); + + return entry.value(); +} + +/** + * @brief Set the storage prefix for the library. + * + * @param prefix The storage prefix to set. + */ +void Library::setStoragePrefix(QString prefix) +{ + m_storagePrefix = prefix; +} + +/** + * @brief Get the default storage prefix for libraries. + * + * @return QString The default storage prefix. + */ +QString Library::defaultStoragePrefix() +{ + return "libraries/"; +} + +/** + * @brief Get the current storage prefix for the library. + * + * @return QString The current storage prefix. + */ +QString Library::storagePrefix() const +{ + if (m_storagePrefix.isEmpty()) { + return defaultStoragePrefix(); + } + return m_storagePrefix; +} + +/** + * @brief Get the filename for the library in the current runtime context. + * + * This function determines the appropriate filename for the library, taking into + * account native classifiers if applicable. + * + * @param runtimeContext The current runtime context. + * @return QString The filename of the library. + */ +QString Library::filename(const RuntimeContext& runtimeContext) const +{ + if (!m_filename.isEmpty()) { + return m_filename; + } + // non-native? use only the gradle specifier + if (!isNative()) { + return m_name.getFileName(); + } + + // otherwise native, override classifiers. Mojang HACK! + GradleSpecifier nativeSpec = m_name; + QString nativeClassifier = getCompatibleNative(runtimeContext); + if (!nativeClassifier.isNull()) { + nativeSpec.setClassifier(nativeClassifier); + } else { + nativeSpec.setClassifier("INVALID"); + } + return nativeSpec.getFileName(); +} + +/** + * @brief Get the display name for the library in the current runtime context. + * + * This function returns the display name for the library, defaulting to the filename + * if no display name is set. + * + * @param runtimeContext The current runtime context. + * @return QString The display name of the library. + */ +QString Library::displayName(const RuntimeContext& runtimeContext) const +{ + if (!m_displayname.isEmpty()) + return m_displayname; + return filename(runtimeContext); +} + +/** + * @brief Get the storage suffix for the library in the current runtime context. + * + * This function determines the appropriate storage suffix for the library, taking into + * account native classifiers if applicable. + * + * @param runtimeContext The current runtime context. + * @return QString The storage suffix of the library. + */ +QString Library::storageSuffix(const RuntimeContext& runtimeContext) const +{ + // non-native? use only the gradle specifier + if (!isNative()) { + return m_name.toPath(m_filename); + } + + // otherwise native, override classifiers. Mojang HACK! + GradleSpecifier nativeSpec = m_name; + QString nativeClassifier = getCompatibleNative(runtimeContext); + if (!nativeClassifier.isNull()) { + nativeSpec.setClassifier(nativeClassifier); + } else { + nativeSpec.setClassifier("INVALID"); + } + return nativeSpec.toPath(m_filename); +} diff --git a/launcher/minecraft/Library.h b/launcher/minecraft/Library.h new file mode 100644 index 0000000..d827554 --- /dev/null +++ b/launcher/minecraft/Library.h @@ -0,0 +1,210 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include + +#include "GradleSpecifier.h" +#include "MojangDownloadInfo.h" +#include "Rule.h" +#include "RuntimeContext.h" +#include "net/NetRequest.h" + +class Library; +class MinecraftInstance; + +using LibraryPtr = std::shared_ptr; + +class Library { + friend class OneSixVersionFormat; + friend class MojangVersionFormat; + friend class LibraryTest; + + public: + Library() {} + Library(const QString& name) { m_name = name; } + /// limited copy without some data. TODO: why? + static LibraryPtr limitedCopy(LibraryPtr base) + { + auto newlib = std::make_shared(); + newlib->m_name = base->m_name; + newlib->m_repositoryURL = base->m_repositoryURL; + newlib->m_hint = base->m_hint; + newlib->m_absoluteURL = base->m_absoluteURL; + newlib->m_extractExcludes = base->m_extractExcludes; + newlib->m_nativeClassifiers = base->m_nativeClassifiers; + newlib->m_rules = base->m_rules; + newlib->m_storagePrefix = base->m_storagePrefix; + newlib->m_mojangDownloads = base->m_mojangDownloads; + newlib->m_filename = base->m_filename; + return newlib; + } + + public: /* methods */ + /// Returns the raw name field + const GradleSpecifier& rawName() const { return m_name; } + + void setRawName(const GradleSpecifier& spec) { m_name = spec; } + + void setClassifier(const QString& spec) { m_name.setClassifier(spec); } + + /// returns the full group and artifact prefix + QString artifactPrefix() const { return m_name.artifactPrefix(); } + + /// get the artifact ID + QString artifactId() const { return m_name.artifactId(); } + + /// get the artifact version + QString version() const { return m_name.version(); } + + /// Returns true if the library is native + bool isNative() const { return m_nativeClassifiers.size() != 0; } + + void setStoragePrefix(QString prefix = QString()); + + /// Set the url base for downloads + void setRepositoryURL(const QString& base_url) { m_repositoryURL = base_url; } + + void getApplicableFiles(const RuntimeContext& runtimeContext, + QStringList& jar, + QStringList& native, + QStringList& native32, + QStringList& native64, + const QString& overridePath) const; + + void setAbsoluteUrl(const QString& absolute_url) { m_absoluteURL = absolute_url; } + + void setFilename(const QString& filename) { m_filename = filename; } + + /// Get the file name of the library + QString filename(const RuntimeContext& runtimeContext) const; + + // DEPRECATED: set a display name, used by jar mods only + void setDisplayName(const QString& displayName) { m_displayname = displayName; } + + /// Get the file name of the library + QString displayName(const RuntimeContext& runtimeContext) const; + + void setMojangDownloadInfo(MojangLibraryDownloadInfo::Ptr info) { m_mojangDownloads = info; } + + void setHint(const QString& hint) { m_hint = hint; } + + /// Set the load rules + void setRules(QList rules) { m_rules = rules; } + + /// Returns true if the library should be loaded (or extracted, in case of natives) + bool isActive(const RuntimeContext& runtimeContext) const; + + /// Returns true if the library is contained in an instance and false if it is shared + bool isLocal() const; + + /// Returns true if the library is to always be checked for updates + bool isAlwaysStale() const; + + /// Return true if the library requires forge XZ hacks + bool isForge() const; + + // Get a list of downloads for this library + QList getDownloads(const RuntimeContext& runtimeContext, + class HttpMetaCache* cache, + QStringList& failedLocalFiles, + const QString& overridePath) const; + + QString getCompatibleNative(const RuntimeContext& runtimeContext) const; + + private: /* methods */ + /// the default storage prefix used by Prism Launcher + static QString defaultStoragePrefix(); + + /// Get the prefix - root of the storage to be used + QString storagePrefix() const; + + /// Get the relative file path where the library should be saved + QString storageSuffix(const RuntimeContext& runtimeContext) const; + + QString hint() const { return m_hint; } + + protected: /* data */ + /// the basic gradle dependency specifier. + GradleSpecifier m_name; + + /// DEPRECATED URL prefix of the maven repo where the file can be downloaded + QString m_repositoryURL; + + /// DEPRECATED: Prism Launcher-specific absolute URL. takes precedence over the implicit maven repo URL, if defined + QString m_absoluteURL; + + /// Prism Launcher extension - filename override + QString m_filename; + + /// DEPRECATED Prism Launcher extension - display name + QString m_displayname; + + /** + * Prism Launcher-specific type hint - modifies how the library is treated + */ + QString m_hint; + + /** + * storage - by default the local libraries folder in Prism Launcher, but could be elsewhere + * Prism Launcher specific, because of FTB. + */ + QString m_storagePrefix; + + /// true if the library had an extract/excludes section (even empty) + bool m_hasExcludes = false; + + /// a list of files that shouldn't be extracted from the library + QStringList m_extractExcludes; + + /// native suffixes per OS + QMap m_nativeClassifiers; + + /// true if the library had a rules section (even empty) + bool applyRules = false; + + /// rules associated with the library + QList m_rules; + + /// MOJANG: container with Mojang style download info + MojangLibraryDownloadInfo::Ptr m_mojangDownloads; +}; diff --git a/launcher/minecraft/Logging.cpp b/launcher/minecraft/Logging.cpp new file mode 100644 index 0000000..8b63042 --- /dev/null +++ b/launcher/minecraft/Logging.cpp @@ -0,0 +1,24 @@ + +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "minecraft/Logging.h" + +Q_LOGGING_CATEGORY(instanceProfileC, "launcher.instance.profile") +Q_LOGGING_CATEGORY(instanceProfileResolveC, "launcher.instance.profile.resolve") diff --git a/launcher/minecraft/Logging.h b/launcher/minecraft/Logging.h new file mode 100644 index 0000000..00d43f4 --- /dev/null +++ b/launcher/minecraft/Logging.h @@ -0,0 +1,26 @@ + +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#pragma once + +#include + +Q_DECLARE_LOGGING_CATEGORY(instanceProfileC) +Q_DECLARE_LOGGING_CATEGORY(instanceProfileResolveC) diff --git a/launcher/minecraft/MinecraftInstance.cpp b/launcher/minecraft/MinecraftInstance.cpp new file mode 100644 index 0000000..22697f9 --- /dev/null +++ b/launcher/minecraft/MinecraftInstance.cpp @@ -0,0 +1,1330 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2022 Jamie Mansfield + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "MinecraftInstance.h" +#include "Application.h" +#include "BuildConfig.h" +#include "Json.h" +#include "QObjectPtr.h" +#include "settings/Setting.h" +#include "settings/SettingsObject.h" + +#include "FileSystem.h" +#include "MMCTime.h" +#include "java/JavaVersion.h" + +#include "launch/LaunchTask.h" +#include "launch/TaskStepWrapper.h" +#include "launch/steps/CheckJava.h" +#include "launch/steps/LookupServerAddress.h" +#include "launch/steps/PostLaunchCommand.h" +#include "launch/steps/PreLaunchCommand.h" +#include "launch/steps/QuitAfterGameStop.h" +#include "launch/steps/TextPrint.h" + +#include "minecraft/launch/AutoInstallJava.h" +#include "minecraft/launch/ClaimAccount.h" +#include "minecraft/launch/CreateGameFolders.h" +#include "minecraft/launch/EnsureAvailableMemory.h" +#include "minecraft/launch/EnsureOfflineLibraries.h" +#include "minecraft/launch/ExtractNatives.h" +#include "minecraft/launch/LauncherPartLaunch.h" +#include "minecraft/launch/ModMinecraftJar.h" +#include "minecraft/launch/PrintInstanceInfo.h" +#include "minecraft/launch/ReconstructAssets.h" +#include "minecraft/launch/ScanModFolders.h" +#include "minecraft/launch/VerifyJavaInstall.h" + +#include "minecraft/update/AssetUpdateTask.h" +#include "minecraft/update/FoldersTask.h" +#include "minecraft/update/LegacyFMLLibrariesTask.h" +#include "minecraft/update/LibrariesTask.h" + +#include "java/JavaUtils.h" + +#include "icons/IconList.h" + +#include "mod/ModFolderModel.h" +#include "mod/ResourcePackFolderModel.h" +#include "mod/ShaderPackFolderModel.h" +#include "mod/TexturePackFolderModel.h" + +#include "WorldList.h" + +#include "AssetsUtils.h" +#include "MinecraftLoadAndCheck.h" +#include "PackProfile.h" + +#include "tools/BaseProfiler.h" + +#include +#include +#include +#include +#include + +#ifdef Q_OS_LINUX +#include "LibraryUtils.h" +#endif + +#ifdef WITH_QTDBUS +#include +#endif + +#define IBUS "@im=ibus" + +[[maybe_unused]] static bool switcherooSetupGPU(QProcessEnvironment& env) +{ +#ifdef WITH_QTDBUS + if (!QDBusConnection::systemBus().isConnected()) + return false; + + QDBusInterface switcheroo("net.hadess.SwitcherooControl", "/net/hadess/SwitcherooControl", "org.freedesktop.DBus.Properties", + QDBusConnection::systemBus()); + + if (!switcheroo.isValid()) + return false; + + QDBusReply reply = + switcheroo.call(QStringLiteral("Get"), QStringLiteral("net.hadess.SwitcherooControl"), QStringLiteral("GPUs")); + if (!reply.isValid()) + return false; + + QDBusArgument arg = qvariant_cast(reply.value().variant()); + QList gpus; + arg >> gpus; + + for (const auto& gpu : gpus) { + QString name = qvariant_cast(gpu[QStringLiteral("Name")]); + bool defaultGpu = qvariant_cast(gpu[QStringLiteral("Default")]); + if (!defaultGpu) { + QStringList envList = qvariant_cast(gpu[QStringLiteral("Environment")]); + for (int i = 0; i + 1 < envList.size(); i += 2) { + env.insert(envList[i], envList[i + 1]); + } + return true; + } + } +#endif + return false; +} + +// all of this because keeping things compatible with deprecated old settings +// if either of the settings {a, b} is true, this also resolves to true +class OrSetting : public Setting { + Q_OBJECT + public: + OrSetting(QString id, std::shared_ptr a, std::shared_ptr b) : Setting({ id }, false), m_a(a), m_b(b) {} + virtual QVariant get() const + { + bool a = m_a->get().toBool(); + bool b = m_b->get().toBool(); + return a || b; + } + virtual void reset() {} + virtual void set(QVariant value) {} + + private: + std::shared_ptr m_a; + std::shared_ptr m_b; +}; + +MinecraftInstance::MinecraftInstance(SettingsObject* globalSettings, std::unique_ptr settings, const QString& rootDir) + : BaseInstance(globalSettings, std::move(settings), rootDir) +{ + m_components.reset(new PackProfile(this)); +} + +MinecraftInstance::~MinecraftInstance() {} + +void MinecraftInstance::saveNow() +{ + m_components->saveNow(); +} + +void MinecraftInstance::loadSpecificSettings() +{ + if (isSpecificSettingsLoaded()) + return; + + // Java Settings + auto locationOverride = m_settings->registerSetting("OverrideJavaLocation", false); + auto argsOverride = m_settings->registerSetting("OverrideJavaArgs", false); + m_settings->registerSetting("AutomaticJava", false); + + if (auto global_settings = globalSettings()) { + m_settings->registerOverride(global_settings->getSetting("JavaPath"), locationOverride); + m_settings->registerOverride(global_settings->getSetting("JvmArgs"), argsOverride); + m_settings->registerOverride(global_settings->getSetting("IgnoreJavaCompatibility"), locationOverride); + + // special! + m_settings->registerPassthrough(global_settings->getSetting("JavaSignature"), locationOverride); + m_settings->registerPassthrough(global_settings->getSetting("JavaArchitecture"), locationOverride); + m_settings->registerPassthrough(global_settings->getSetting("JavaRealArchitecture"), locationOverride); + m_settings->registerPassthrough(global_settings->getSetting("JavaVersion"), locationOverride); + m_settings->registerPassthrough(global_settings->getSetting("JavaVendor"), locationOverride); + + // Window Size + auto windowSetting = m_settings->registerSetting("OverrideWindow", false); + m_settings->registerOverride(global_settings->getSetting("LaunchMaximized"), windowSetting); + m_settings->registerOverride(global_settings->getSetting("MinecraftWinWidth"), windowSetting); + m_settings->registerOverride(global_settings->getSetting("MinecraftWinHeight"), windowSetting); + + // Memory + auto memorySetting = m_settings->registerSetting("OverrideMemory", false); + m_settings->registerOverride(global_settings->getSetting("MinMemAlloc"), memorySetting); + m_settings->registerOverride(global_settings->getSetting("MaxMemAlloc"), memorySetting); + m_settings->registerOverride(global_settings->getSetting("PermGen"), memorySetting); + m_settings->registerOverride(global_settings->getSetting("LowMemWarning"), memorySetting); + + // Native library workarounds + auto nativeLibraryWorkaroundsOverride = m_settings->registerSetting("OverrideNativeWorkarounds", false); + m_settings->registerOverride(global_settings->getSetting("UseNativeOpenAL"), nativeLibraryWorkaroundsOverride); + m_settings->registerOverride(global_settings->getSetting("CustomOpenALPath"), nativeLibraryWorkaroundsOverride); + m_settings->registerOverride(global_settings->getSetting("UseNativeGLFW"), nativeLibraryWorkaroundsOverride); + m_settings->registerOverride(global_settings->getSetting("CustomGLFWPath"), nativeLibraryWorkaroundsOverride); + + // Performance related options + auto performanceOverride = m_settings->registerSetting("OverridePerformance", false); + m_settings->registerOverride(global_settings->getSetting("EnableFeralGamemode"), performanceOverride); + m_settings->registerOverride(global_settings->getSetting("EnableMangoHud"), performanceOverride); + m_settings->registerOverride(global_settings->getSetting("UseDiscreteGpu"), performanceOverride); + m_settings->registerOverride(global_settings->getSetting("UseZink"), performanceOverride); + + // Miscellaneous + auto miscellaneousOverride = m_settings->registerSetting("OverrideMiscellaneous", false); + m_settings->registerOverride(global_settings->getSetting("CloseAfterLaunch"), miscellaneousOverride); + m_settings->registerOverride(global_settings->getSetting("QuitAfterGameStop"), miscellaneousOverride); + + // Legacy-related options + auto legacySettings = m_settings->registerSetting("OverrideLegacySettings", false); + m_settings->registerOverride(global_settings->getSetting("OnlineFixes"), legacySettings); + + auto envSetting = m_settings->registerSetting("OverrideEnv", false); + m_settings->registerOverride(global_settings->getSetting("Env"), envSetting); + + m_settings->set("InstanceType", "OneSix"); + } + + // Join server on launch, this does not have a global override + m_settings->registerSetting("JoinServerOnLaunch", false); + m_settings->registerSetting("JoinServerOnLaunchAddress", ""); + m_settings->registerSetting("JoinWorldOnLaunch", ""); + + // Use account for instance, this does not have a global override + m_settings->registerSetting("UseAccountForInstance", false); + m_settings->registerSetting("InstanceAccountId", ""); + + m_settings->registerSetting("ExportName", ""); + m_settings->registerSetting("ExportVersion", "1.0.0"); + m_settings->registerSetting("ExportSummary", ""); + m_settings->registerSetting("ExportAuthor", ""); + m_settings->registerSetting("ExportOptionalFiles", true); + m_settings->registerSetting("ExportRecommendedRAM"); + + auto dataPacksEnabled = m_settings->registerSetting("GlobalDataPacksEnabled", false); + auto dataPacksPath = m_settings->registerSetting("GlobalDataPacksPath", ""); + + connect(dataPacksEnabled.get(), &Setting::SettingChanged, this, [this] { m_data_pack_list.reset(); }); + connect(dataPacksPath.get(), &Setting::SettingChanged, this, [this] { m_data_pack_list.reset(); }); + + // Join server on launch, this does not have a global override + m_settings->registerSetting("OverrideModDownloadLoaders", false); + m_settings->registerSetting("ModDownloadLoaders", "[]"); + + qDebug() << "Instance-type specific settings were loaded!"; + + setSpecificSettingsLoaded(true); + + updateRuntimeContext(); +} + +void MinecraftInstance::updateRuntimeContext() +{ + m_runtimeContext.updateFromInstanceSettings(m_settings.get()); + m_components->invalidateLaunchProfile(); +} + +QString MinecraftInstance::typeName() const +{ + return "Minecraft"; +} + +PackProfile* MinecraftInstance::getPackProfile() const +{ + return m_components.get(); +} + +QSet MinecraftInstance::traits() const +{ + auto components = getPackProfile(); + if (!components) { + return { "version-incomplete" }; + } + auto profile = components->getProfile(); + if (!profile) { + return { "version-incomplete" }; + } + return profile->getTraits(); +} + +// FIXME: move UI code out of MinecraftInstance +void MinecraftInstance::populateLaunchMenu(QMenu* menu) +{ + QAction* normalLaunch = menu->addAction(tr("&Launch")); + normalLaunch->setShortcut(QKeySequence::Open); + QAction* normalLaunchOffline = menu->addAction(tr("Launch &Offline")); + normalLaunchOffline->setShortcut(QKeySequence(tr("Ctrl+Shift+O"))); + QAction* normalLaunchDemo = menu->addAction(tr("Launch &Demo")); + normalLaunchDemo->setShortcut(QKeySequence(tr("Ctrl+Alt+O"))); + + normalLaunchDemo->setEnabled(supportsDemo()); + + connect(normalLaunch, &QAction::triggered, [this] { APPLICATION->launch(this); }); + connect(normalLaunchOffline, &QAction::triggered, [this] { APPLICATION->launch(this, LaunchMode::Offline); }); + connect(normalLaunchDemo, &QAction::triggered, [this] { APPLICATION->launch(this, LaunchMode::Demo); }); + + QString profilersTitle = tr("Profilers"); + menu->addSeparator()->setText(profilersTitle); + + auto profilers = new QActionGroup(menu); + profilers->setExclusive(true); + connect(profilers, &QActionGroup::triggered, [this](QAction* action) { + settings()->set("Profiler", action->data()); + emit profilerChanged(); + }); + + QAction* noProfilerAction = menu->addAction(tr("&No Profiler")); + noProfilerAction->setData(""); + noProfilerAction->setCheckable(true); + noProfilerAction->setChecked(true); + profilers->addAction(noProfilerAction); + + for (auto profiler = APPLICATION->profilers().begin(); profiler != APPLICATION->profilers().end(); profiler++) { + QAction* profilerAction = menu->addAction(profiler.value()->name()); + profilers->addAction(profilerAction); + profilerAction->setData(profiler.key()); + profilerAction->setCheckable(true); + profilerAction->setChecked(settings()->get("Profiler").toString() == profiler.key()); + + QString error; + profilerAction->setEnabled(profiler.value()->check(&error)); + } +} + +QString MinecraftInstance::gameRoot() const +{ + QFileInfo mcDir(FS::PathCombine(instanceRoot(), "minecraft")); + QFileInfo dotMCDir(FS::PathCombine(instanceRoot(), ".minecraft")); + + if (dotMCDir.exists() && !mcDir.exists()) + return dotMCDir.filePath(); + else + return mcDir.filePath(); +} + +QString MinecraftInstance::binRoot() const +{ + return FS::PathCombine(gameRoot(), "bin"); +} + +QString MinecraftInstance::getNativePath() const +{ + QDir natives_dir(FS::PathCombine(instanceRoot(), "natives/")); + return natives_dir.absolutePath(); +} + +QString MinecraftInstance::getLocalLibraryPath() const +{ + QDir libraries_dir(FS::PathCombine(instanceRoot(), "libraries/")); + return libraries_dir.absolutePath(); +} + +bool MinecraftInstance::supportsDemo() const +{ + Version instance_ver{ getPackProfile()->getComponentVersion("net.minecraft") }; + // Demo mode was introduced in 1.3.1: https://minecraft.wiki/w/Demo_mode#History + // FIXME: Due to Version constraints atm, this can't handle well non-release versions + return instance_ver >= Version("1.3.1"); +} + +QString MinecraftInstance::jarModsDir() const +{ + QDir jarmods_dir(FS::PathCombine(instanceRoot(), "jarmods/")); + return jarmods_dir.absolutePath(); +} + +QString MinecraftInstance::modsRoot() const +{ + return FS::PathCombine(gameRoot(), "mods"); +} + +QString MinecraftInstance::modsCacheLocation() const +{ + return FS::PathCombine(instanceRoot(), "mods.cache"); +} + +QString MinecraftInstance::coreModsDir() const +{ + return FS::PathCombine(gameRoot(), "coremods"); +} + +QString MinecraftInstance::nilModsDir() const +{ + return FS::PathCombine(gameRoot(), "nilmods"); +} + +QString MinecraftInstance::dataPacksDir() +{ + QString relativePath = settings()->get("GlobalDataPacksPath").toString(); + + if (relativePath.isEmpty()) + relativePath = "datapacks"; + + return QDir(gameRoot()).filePath(relativePath); +} + +QString MinecraftInstance::resourcePacksDir() const +{ + return FS::PathCombine(gameRoot(), "resourcepacks"); +} + +QString MinecraftInstance::texturePacksDir() const +{ + return FS::PathCombine(gameRoot(), "texturepacks"); +} + +QString MinecraftInstance::shaderPacksDir() const +{ + return FS::PathCombine(gameRoot(), "shaderpacks"); +} + +QString MinecraftInstance::instanceConfigFolder() const +{ + return FS::PathCombine(gameRoot(), "config"); +} + +QString MinecraftInstance::libDir() const +{ + return FS::PathCombine(gameRoot(), "lib"); +} + +QString MinecraftInstance::worldDir() const +{ + return FS::PathCombine(gameRoot(), "saves"); +} + +QString MinecraftInstance::resourcesDir() const +{ + return FS::PathCombine(gameRoot(), "resources"); +} + +QDir MinecraftInstance::librariesPath() const +{ + return QDir::current().absoluteFilePath("libraries"); +} + +QDir MinecraftInstance::jarmodsPath() const +{ + return QDir(jarModsDir()); +} + +QDir MinecraftInstance::versionsPath() const +{ + return QDir::current().absoluteFilePath("versions"); +} + +QStringList MinecraftInstance::getClassPath() +{ + QStringList jars, nativeJars; + auto profile = m_components->getProfile(); + profile->getLibraryFiles(runtimeContext(), jars, nativeJars, getLocalLibraryPath(), binRoot()); + return jars; +} + +QString MinecraftInstance::getMainClass() const +{ + auto profile = m_components->getProfile(); + return profile->getMainClass(); +} + +QStringList MinecraftInstance::getNativeJars() +{ + QStringList jars, nativeJars; + auto profile = m_components->getProfile(); + profile->getLibraryFiles(runtimeContext(), jars, nativeJars, getLocalLibraryPath(), binRoot()); + return nativeJars; +} + +static QString replaceTokensIn(const QString& text, const QMap& with) +{ + // TODO: does this still work?? + QString result; + static const QRegularExpression s_token_regexp("\\$\\{(.+)\\}", QRegularExpression::InvertedGreedinessOption); + QStringList list; + QRegularExpressionMatchIterator i = s_token_regexp.globalMatch(text); + int lastCapturedEnd = 0; + while (i.hasNext()) { + QRegularExpressionMatch match = i.next(); + result.append(text.mid(lastCapturedEnd, match.capturedStart())); + QString key = match.captured(1); + auto iter = with.find(key); + if (iter != with.end()) { + result.append(*iter); + } + lastCapturedEnd = match.capturedEnd(); + } + result.append(text.mid(lastCapturedEnd)); + return result; +} + +QStringList MinecraftInstance::extraArguments() +{ + auto list = BaseInstance::extraArguments(); + auto version = getPackProfile(); + if (!version) + return list; + auto jarMods = getJarMods(); + if (!jarMods.isEmpty()) { + list.append({ "-Dfml.ignoreInvalidMinecraftCertificates=true", "-Dfml.ignorePatchDiscrepancies=true" }); + } + auto addn = m_components->getProfile()->getAddnJvmArguments(); + if (!addn.isEmpty()) { + QMap tokenMapping = makeProfileVarMapping(m_components->getProfile()); + + for (const QString& item : addn) { + list.append(replaceTokensIn(item, tokenMapping)); + } + } + auto agents = m_components->getProfile()->getAgents(); + for (const auto& agent : agents) { + QStringList jar, temp1, temp2, temp3; + agent.library->getApplicableFiles(runtimeContext(), jar, temp1, temp2, temp3, getLocalLibraryPath()); + list.append("-javaagent:" + jar[0] + (agent.argument.isEmpty() ? "" : "=" + agent.argument)); + } + + { + QString openALPath; + QString glfwPath; + + if (settings()->get("UseNativeOpenAL").toBool()) { + openALPath = APPLICATION->m_detectedOpenALPath; + auto customPath = settings()->get("CustomOpenALPath").toString(); + if (!customPath.isEmpty()) + openALPath = customPath; + } + if (settings()->get("UseNativeGLFW").toBool()) { + glfwPath = APPLICATION->m_detectedGLFWPath; + auto customPath = settings()->get("CustomGLFWPath").toString(); + if (!customPath.isEmpty()) + glfwPath = customPath; + } + + QFileInfo openALInfo(openALPath); + QFileInfo glfwInfo(glfwPath); + + if (!openALPath.isEmpty() && openALInfo.exists()) + list.append("-Dorg.lwjgl.openal.libname=" + openALInfo.absoluteFilePath()); + if (!glfwPath.isEmpty() && glfwInfo.exists()) + list.append("-Dorg.lwjgl.glfw.libname=" + glfwInfo.absoluteFilePath()); + } + + return list; +} + +QStringList MinecraftInstance::javaArguments() +{ + QStringList args; + + args << "-Duser.language=en"; + + // custom args go first. we want to override them if we have our own here. + args.append(extraArguments()); + + // OSX dock icon and name +#ifdef Q_OS_MAC + args << "-Xdock:icon=icon.png"; + args << QString("-Xdock:name=\"%1\"").arg(windowTitle()); +#endif + auto traits_ = traits(); + // HACK: fix issues on macOS with 1.13 snapshots + // NOTE: Oracle Java option. if there are alternate jvm implementations, this would be the place to customize this for them +#ifdef Q_OS_MAC + if (traits_.contains("FirstThreadOnMacOS")) { + args << QString("-XstartOnFirstThread"); + } +#endif + + // HACK: Stupid hack for Intel drivers. See: https://mojang.atlassian.net/browse/MCL-767 +#ifdef Q_OS_WIN32 + args << QString( + "-XX:HeapDumpPath=MojangTricksIntelDriversForPerformance_javaw.exe_" + "minecraft.exe.heapdump"); +#endif + + // LWJGL2 reads `LWJGL_DISABLE_XRANDR` to force disable xrandr usage and fall back to xf86videomode. + // It *SHOULD* check for the executable to exist before trying to use it for queries but it doesnt, + // so WE can and force disable xrandr if it is not available. +#ifdef Q_OS_LINUX + // LWJGL2 is "org.lwjgl" LWJGL3 is "org.lwjgl3" + if (m_components->getComponent("org.lwjgl") != nullptr && QStandardPaths::findExecutable("xrandr").isEmpty()) { + args << QString("-DLWJGL_DISABLE_XRANDR=true"); + } +#endif + + int min = settings()->get("MinMemAlloc").toInt(); + int max = settings()->get("MaxMemAlloc").toInt(); + if (min < max) { + args << QString("-Xms%1m").arg(min); + args << QString("-Xmx%1m").arg(max); + } else { + args << QString("-Xms%1m").arg(max); + args << QString("-Xmx%1m").arg(min); + } + + // No PermGen in newer java. + JavaVersion javaVersion = getJavaVersion(); + if (javaVersion.requiresPermGen()) { + auto permgen = settings()->get("PermGen").toInt(); + if (permgen != 64) { + args << QString("-XX:PermSize=%1m").arg(permgen); + } + } + + if (javaVersion.isModular() && shouldApplyOnlineFixes()) + // allow reflective access to java.net - required by the skin fix + args << "--add-opens" << "java.base/java.net=ALL-UNNAMED"; + + return args; +} + +QString MinecraftInstance::getLauncher() +{ + // use legacy launcher if the traits are set + if (isLegacy()) + return "legacy"; + + return "standard"; +} + +bool MinecraftInstance::shouldApplyOnlineFixes() +{ + return traits().contains("legacyServices") && settings()->get("OnlineFixes").toBool(); +} + +QMap MinecraftInstance::getVariables() +{ + QMap out; + out.insert("INST_NAME", name()); + out.insert("INST_ID", id()); + out.insert("INST_DIR", QDir::toNativeSeparators(QDir(instanceRoot()).absolutePath())); + out.insert("INST_MC_DIR", QDir::toNativeSeparators(QDir(gameRoot()).absolutePath())); + out.insert("INST_JAVA", QDir::toNativeSeparators(QDir(settings()->get("JavaPath").toString()).absolutePath())); + out.insert("INST_JAVA_ARGS", javaArguments().join(' ')); + out.insert("NO_COLOR", "1"); +#ifdef Q_OS_MACOS + // get library for Steam overlay support + QString steamDyldInsertLibraries = qEnvironmentVariable("STEAM_DYLD_INSERT_LIBRARIES"); + if (!steamDyldInsertLibraries.isEmpty()) { + out.insert("DYLD_INSERT_LIBRARIES", steamDyldInsertLibraries); + } +#endif + return out; +} + +QProcessEnvironment MinecraftInstance::createEnvironment() +{ + // prepare the process environment + QProcessEnvironment env = CleanEnviroment(); + + // export some infos + auto variables = getVariables(); + for (auto it = variables.begin(); it != variables.end(); ++it) { + env.insert(it.key(), it.value()); + } + // custom env + + auto insertEnv = [&env](QString value) { + auto envMap = Json::toMap(value); + if (envMap.isEmpty()) + return; + + for (auto iter = envMap.begin(); iter != envMap.end(); iter++) + env.insert(iter.key(), iter.value().toString()); + }; + + insertEnv(settings()->get("Env").toString()); + return env; +} + +QProcessEnvironment MinecraftInstance::createLaunchEnvironment() +{ + // prepare the process environment + QProcessEnvironment env = createEnvironment(); + +#ifdef Q_OS_LINUX + if (settings()->get("EnableMangoHud").toBool() && APPLICATION->capabilities() & Application::SupportsMangoHud) { + QStringList preloadList; + if (auto value = env.value("LD_PRELOAD"); !value.isEmpty()) + preloadList = value.split(QLatin1String(":")); + + auto mangoHudLibString = LibraryUtils::findMangoHud(); + if (!mangoHudLibString.isEmpty()) { + QFileInfo mangoHudLib(mangoHudLibString); + QString libPath = mangoHudLib.absolutePath(); + auto appendLib = [libPath, &preloadList](QString fileName) { + if (QFileInfo(FS::PathCombine(libPath, fileName)).exists()) + preloadList << FS::PathCombine(libPath, fileName); + }; + + // dlsym variant is only needed for OpenGL and not included in the vulkan layer + appendLib("libMangoHud_dlsym.so"); + appendLib("libMangoHud_opengl.so"); + appendLib("libMangoHud_shim.so"); + preloadList << mangoHudLibString; + } + + env.insert("LD_PRELOAD", preloadList.join(QLatin1String(":"))); + env.insert("MANGOHUD", "1"); + } + + if (settings()->get("UseDiscreteGpu").toBool()) { + if (!switcherooSetupGPU(env)) { + // Open Source Drivers + env.insert("DRI_PRIME", "1"); + // Proprietary Nvidia Drivers + env.insert("__NV_PRIME_RENDER_OFFLOAD", "1"); + env.insert("__VK_LAYER_NV_optimus", "NVIDIA_only"); + env.insert("__GLX_VENDOR_LIBRARY_NAME", "nvidia"); + } + } + + if (settings()->get("UseZink").toBool()) { + // taken from https://wiki.archlinux.org/title/OpenGL#OpenGL_over_Vulkan_(Zink) + env.insert("__GLX_VENDOR_LIBRARY_NAME", "mesa"); + env.insert("MESA_LOADER_DRIVER_OVERRIDE", "zink"); + env.insert("GALLIUM_DRIVER", "zink"); + env.insert("LIBGL_KOPPER_DRI2", "1"); + } +#endif + return env; +} + +QStringList MinecraftInstance::processMinecraftArgs(AuthSessionPtr session, MinecraftTarget::Ptr targetToJoin) const +{ + auto profile = m_components->getProfile(); + auto args = profile->getMinecraftArguments().split(' ', Qt::SkipEmptyParts); + for (auto tweaker : profile->getTweakers()) { + args << "--tweakClass" << tweaker; + } + + if (targetToJoin) { + if (!targetToJoin->address.isEmpty()) { + if (profile->hasTrait("feature:is_quick_play_multiplayer")) { + args << "--quickPlayMultiplayer" << targetToJoin->address + ':' + QString::number(targetToJoin->port); + } else { + args << "--server" << targetToJoin->address; + args << "--port" << QString::number(targetToJoin->port); + } + } else if (!targetToJoin->world.isEmpty() && profile->hasTrait("feature:is_quick_play_singleplayer")) { + args << "--quickPlaySingleplayer" << targetToJoin->world; + } + } + + QMap tokenMapping = makeProfileVarMapping(profile); + + // yggdrasil! + if (session) { + // token_mapping["auth_username"] = session->username; + tokenMapping["auth_session"] = session->session; + tokenMapping["auth_access_token"] = session->access_token; + tokenMapping["auth_player_name"] = session->player_name; + tokenMapping["auth_uuid"] = session->uuid; + tokenMapping["user_properties"] = session->serializeUserProperties(); + tokenMapping["user_type"] = session->user_type; + + if (session->launchMode == LaunchMode::Demo) { + args << "--demo"; + } + } + + for (int i = 0; i < args.length(); i++) { + args[i] = replaceTokensIn(args[i], tokenMapping); + } + return args; +} + +QString MinecraftInstance::createLaunchScript(AuthSessionPtr session, MinecraftTarget::Ptr targetToJoin) +{ + QString launchScript; + + if (!m_components) + return QString(); + auto profile = m_components->getProfile(); + if (!profile) + return QString(); + + auto mainClass = getMainClass(); + if (!mainClass.isEmpty()) { + launchScript += "mainClass " + mainClass + "\n"; + } + auto appletClass = profile->getAppletClass(); + if (!appletClass.isEmpty()) { + launchScript += "appletClass " + appletClass + "\n"; + } + + if (targetToJoin) { + if (!targetToJoin->address.isEmpty()) { + launchScript += "serverAddress " + targetToJoin->address + "\n"; + launchScript += "serverPort " + QString::number(targetToJoin->port) + "\n"; + } else if (!targetToJoin->world.isEmpty()) { + launchScript += "worldName " + targetToJoin->world + "\n"; + } + } + + // generic minecraft params + for (auto param : processMinecraftArgs(session, nullptr /* When using a launch script, the server parameters are handled by it*/ + )) { + launchScript += "param " + param + "\n"; + } + + // window size, title and state, legacy + { + QString windowParams; + if (settings()->get("LaunchMaximized").toBool()) { + // FIXME doesn't support maximisation + if (!isLegacy()) { + auto screen = QGuiApplication::primaryScreen(); + auto screenGeometry = screen->availableSize(); + + // small hack to get the widow decorations + for (auto w : QApplication::topLevelWidgets()) { + auto mainWindow = qobject_cast(w); + if (mainWindow) { + auto m = mainWindow->windowHandle()->frameMargins(); + screenGeometry = screenGeometry.shrunkBy(m); + break; + } + } + + windowParams = QString("%1x%2").arg(screenGeometry.width()).arg(screenGeometry.height()); + } else { + windowParams = "maximized"; + } + } else { + windowParams = + QString("%1x%2").arg(settings()->get("MinecraftWinWidth").toInt()).arg(settings()->get("MinecraftWinHeight").toInt()); + } + launchScript += "windowTitle " + windowTitle() + "\n"; + launchScript += "windowParams " + windowParams + "\n"; + } + + // launcher info + { + launchScript += "launcherBrand " + BuildConfig.LAUNCHER_NAME + "\n"; + launchScript += "launcherVersion " + BuildConfig.printableVersionString() + "\n"; + } + + // instance info + { + launchScript += "instanceName " + name() + "\n"; + launchScript += "instanceIconKey " + name() + "\n"; + launchScript += "instanceIconPath icon.png\n"; // we already save a copy here + } + + // legacy auth + if (session) { + launchScript += "userName " + session->player_name + "\n"; + launchScript += "sessionId " + session->session + "\n"; + } + + for (auto trait : profile->getTraits()) { + launchScript += "traits " + trait + "\n"; + } + + if (shouldApplyOnlineFixes()) + launchScript += "onlineFixes true\n"; + + launchScript += "launcher " + getLauncher() + "\n"; + + // qDebug() << "Generated launch script:" << launchScript; + return launchScript; +} + +QStringList MinecraftInstance::verboseDescription(AuthSessionPtr session, MinecraftTarget::Ptr targetToJoin) +{ + constexpr auto indent = " "; + constexpr auto emptyLine = ""; + + QStringList out; + + out << "Launcher: " + getLauncher(); + out << "Main class: " + getMainClass() << emptyLine; + + auto profile = m_components->getProfile(); + + // mods and core mods + auto printModList = [&out](const QString& label, ModFolderModel& model) { + if (model.size()) { + out << QString("%1:").arg(label); + auto modList = model.allMods(); + std::sort(modList.begin(), modList.end(), [](auto a, auto b) { + auto aName = a->fileinfo().completeBaseName(); + auto bName = b->fileinfo().completeBaseName(); + return aName.localeAwareCompare(bName) < 0; + }); + for (auto mod : modList) { + if (mod->type() == ResourceType::FOLDER) { + out << u8" [🖿] " + mod->fileinfo().completeBaseName() + " (folder)"; + continue; + } + + if (mod->enabled()) { + out << u8" [✔] " + mod->fileinfo().completeBaseName(); + } else { + out << u8" [✘] " + mod->fileinfo().completeBaseName() + " (disabled)"; + } + } + out << emptyLine; + } + }; + + printModList("Mods", *loaderModList()); + printModList("Core Mods", *coreModList()); + + // jar mods + auto& jarMods = profile->getJarMods(); + if (jarMods.size()) { + out << "Jar Mods:"; + for (auto& jarmod : jarMods) { + auto displayname = jarmod->displayName(runtimeContext()); + auto realname = jarmod->filename(runtimeContext()); + if (displayname != realname) { + out << indent + displayname + " (" + realname + ")"; + } else { + out << indent + realname; + } + } + out << emptyLine; + } + + // traits + auto alltraits = traits(); + if (alltraits.size()) { + out << "Traits:"; + for (auto trait : alltraits) { + out << indent + trait; + } + out << emptyLine; + } + + // native libraries + auto settings = this->settings(); + bool nativeOpenAL = settings->get("UseNativeOpenAL").toBool(); + bool nativeGLFW = settings->get("UseNativeGLFW").toBool(); + if (nativeOpenAL || nativeGLFW) { + if (nativeOpenAL) + out << "Using system OpenAL."; + if (nativeGLFW) + out << "Using system GLFW."; + out << emptyLine; + } + + // libraries and class path. + { + out << "Libraries:"; + QStringList jars, nativeJars; + profile->getLibraryFiles(runtimeContext(), jars, nativeJars, getLocalLibraryPath(), binRoot()); + for (auto file : jars) { + out << indent + file; + } + out << emptyLine; + out << "Native libraries:"; + for (auto file : nativeJars) { + out << indent + file; + } + out << emptyLine; + } + + out << "Natives path:" << indent + getNativePath() << emptyLine; + + // minecraft arguments + auto params = processMinecraftArgs(nullptr, targetToJoin); + out << "Minecraft arguments:"; + out << indent + params.join(' '); + out << emptyLine; + + // window size + QString windowParams; + if (settings->get("LaunchMaximized").toBool()) { + out << "Window size: max (if available)"; + } else { + auto width = settings->get("MinecraftWinWidth").toInt(); + auto height = settings->get("MinecraftWinHeight").toInt(); + out << "Window size: " + QString::number(width) + " x " + QString::number(height); + } + out << emptyLine; + + // environment variables + const QString env = settings->get("Env").toString(); + if (auto envMap = Json::toMap(env); !envMap.isEmpty()) { + out << "Custom environment variables:"; + for (auto [key, value] : envMap.asKeyValueRange()) { + out << indent + key + "=" + value.toString(); + } + out << emptyLine; + } + + return out; +} + +QMap MinecraftInstance::createCensorFilterFromSession(AuthSessionPtr session) +{ + if (!session) { + return QMap(); + } + auto& sessionRef = *session.get(); + QMap filter; + auto addToFilter = [&filter](QString key, QString value) { + if (key.trimmed().size()) { + filter[key] = value; + } + }; + if (sessionRef.session != "-") { + addToFilter(sessionRef.session, tr("")); + } + if (sessionRef.access_token != "0") { + addToFilter(sessionRef.access_token, tr("")); + } + addToFilter(sessionRef.uuid, tr("")); + + return filter; +} + +QMap MinecraftInstance::makeProfileVarMapping(std::shared_ptr profile) const +{ + QMap result; + + result["profile_name"] = name(); + result["version_name"] = profile->getMinecraftVersion(); + result["version_type"] = profile->getMinecraftVersionType(); + + QString absRootDir = QDir(gameRoot()).absolutePath(); + result["game_directory"] = absRootDir; + QString absAssetsDir = QDir("assets/").absolutePath(); + auto assets = profile->getMinecraftAssets(); + result["game_assets"] = AssetsUtils::getAssetsDir(assets->id, resourcesDir()).absolutePath(); + + // 1.7.3+ assets tokens + result["assets_root"] = absAssetsDir; + result["assets_index_name"] = assets->id; + + result["library_directory"] = APPLICATION->metacache()->getBasePath("libraries"); + + return result; +} + +QStringList MinecraftInstance::getLogFileSearchPaths() +{ + return { FS::PathCombine(gameRoot(), "crash-reports"), FS::PathCombine(gameRoot(), "logs"), gameRoot() }; +} + +QString MinecraftInstance::getStatusbarDescription() +{ + QStringList traits; + if (hasVersionBroken()) { + traits.append(tr("broken")); + } + + QString mcVersion = m_components->getComponentVersion("net.minecraft"); + if (mcVersion.isEmpty()) { + // Load component info if needed + m_components->reload(Net::Mode::Offline); + mcVersion = m_components->getComponentVersion("net.minecraft"); + } + + QString description; + + if (m_settings->get("ShowGameTime").toBool() && lastTimePlayed() > 0 && lastLaunch() > 0) { + QDateTime lastLaunchTime = QDateTime::fromMSecsSinceEpoch(lastLaunch()); + description.append(tr("last played %1 — %2 min") + .arg(Time::relativePast(lastLaunchTime)) + .arg(lastTimePlayed() / 60)); + } + + if (hasCrashed()) { + if (!description.isEmpty()) + description.append(QStringLiteral(" — ")); + description.append(tr("crashed")); + } + return description; +} + +QList MinecraftInstance::createUpdateTask() +{ + return { + // create folders + makeShared(this), + // libraries download + makeShared(this), + // FML libraries download and copy into the instance + makeShared(this), + // assets update + makeShared(this), + }; +} + +LaunchTask* MinecraftInstance::createLaunchTask(AuthSessionPtr session, MinecraftTarget::Ptr targetToJoin) +{ + updateRuntimeContext(); + auto process = LaunchTask::create(this); + auto pptr = process.get(); + + APPLICATION->icons()->saveIcon(iconKey(), FS::PathCombine(gameRoot(), "icon.png"), "PNG"); + + // print a header + { + process->appendStep(makeShared(pptr, "Minecraft folder is:\n " + gameRoot() + "\n", MessageLevel::Launcher)); + } + + // create the .minecraft folder and server-resource-packs (workaround for Minecraft bug MCL-3732) + { + process->appendStep(makeShared(pptr)); + } + + if (!targetToJoin && settings()->get("JoinServerOnLaunch").toBool()) { + QString fullAddress = settings()->get("JoinServerOnLaunchAddress").toString(); + if (!fullAddress.isEmpty()) { + targetToJoin.reset(new MinecraftTarget(MinecraftTarget::parse(fullAddress, false))); + } else { + QString world = settings()->get("JoinWorldOnLaunch").toString(); + if (!world.isEmpty()) { + targetToJoin.reset(new MinecraftTarget(MinecraftTarget::parse(world, true))); + } + } + } + + if (targetToJoin && targetToJoin->port == 25565) { + // Resolve server address to join on launch + auto step = makeShared(pptr); + step->setLookupAddress(targetToJoin->address); + step->setOutputAddressPtr(targetToJoin); + process->appendStep(step); + } + + // load meta + { + auto mode = session->launchMode != LaunchMode::Offline ? Net::Mode::Online : Net::Mode::Offline; + process->appendStep(makeShared(pptr, makeShared(this, mode))); + } + + // check java + { + process->appendStep(makeShared(pptr)); + process->appendStep(makeShared(pptr)); + // verify that minimum Java requirements are met + process->appendStep(makeShared(pptr)); + } + + // run pre-launch command if that's needed + if (getPreLaunchCommand().size()) { + auto step = makeShared(pptr); + step->setWorkingDirectory(gameRoot()); + process->appendStep(step); + } + + // if we aren't in offline mode + if (session->launchMode != LaunchMode::Offline) { + process->appendStep(makeShared(pptr, session)); + for (auto t : createUpdateTask()) { + process->appendStep(makeShared(pptr, t)); + } + } else { + process->appendStep(makeShared(pptr, this)); + } + + // if there are any jar mods + { + process->appendStep(makeShared(pptr)); + } + + // Scan mods folders for mods + { + process->appendStep(makeShared(pptr)); + } + + // make sure we have enough RAM, warn the user if we don't + { + process->appendStep(makeShared(pptr, this)); + } + + // print some instance info here... + { + process->appendStep(makeShared(pptr, session, targetToJoin)); + } + + // extract native jars if needed + { + process->appendStep(makeShared(pptr)); + } + + // reconstruct assets if needed + { + process->appendStep(makeShared(pptr)); + } + + { + // actually launch the game + auto step = makeShared(pptr); + step->setWorkingDirectory(gameRoot()); + step->setAuthSession(session); + step->setTargetToJoin(targetToJoin); + process->appendStep(step); + } + + // run post-exit command if that's needed + if (getPostExitCommand().size()) { + auto step = makeShared(pptr); + step->setWorkingDirectory(gameRoot()); + process->appendStep(step); + } + if (session) { + process->setCensorFilter(createCensorFilterFromSession(session)); + } + if (m_settings->get("QuitAfterGameStop").toBool()) { + process->appendStep(makeShared(pptr)); + } + m_launchProcess = std::move(process); + emit launchTaskChanged(m_launchProcess.get()); + return m_launchProcess.get(); +} + +JavaVersion MinecraftInstance::getJavaVersion() +{ + return JavaVersion(settings()->get("JavaVersion").toString()); +} + +ModFolderModel* MinecraftInstance::loaderModList() +{ + if (!m_loader_mod_list) { + bool is_indexed = !APPLICATION->settings()->get("ModMetadataDisabled").toBool(); + m_loader_mod_list.reset(new ModFolderModel(modsRoot(), this, is_indexed, true)); + } + return m_loader_mod_list.get(); +} + +ModFolderModel* MinecraftInstance::coreModList() +{ + if (!m_core_mod_list) { + bool is_indexed = !APPLICATION->settings()->get("ModMetadataDisabled").toBool(); + m_core_mod_list.reset(new ModFolderModel(coreModsDir(), this, is_indexed, true)); + } + return m_core_mod_list.get(); +} + +ModFolderModel* MinecraftInstance::nilModList() +{ + if (!m_nil_mod_list) { + bool is_indexed = !APPLICATION->settings()->get("ModMetadataDisabled").toBool(); + m_nil_mod_list.reset(new ModFolderModel(nilModsDir(), this, is_indexed, false)); + } + return m_nil_mod_list.get(); +} + +ResourcePackFolderModel* MinecraftInstance::resourcePackList() +{ + if (!m_resource_pack_list) { + bool is_indexed = !APPLICATION->settings()->get("ModMetadataDisabled").toBool(); + m_resource_pack_list.reset(new ResourcePackFolderModel(resourcePacksDir(), this, is_indexed, true)); + } + return m_resource_pack_list.get(); +} + +TexturePackFolderModel* MinecraftInstance::texturePackList() +{ + if (!m_texture_pack_list) { + bool is_indexed = !APPLICATION->settings()->get("ModMetadataDisabled").toBool(); + m_texture_pack_list.reset(new TexturePackFolderModel(texturePacksDir(), this, is_indexed, true)); + } + return m_texture_pack_list.get(); +} + +ShaderPackFolderModel* MinecraftInstance::shaderPackList() +{ + if (!m_shader_pack_list) { + bool is_indexed = !APPLICATION->settings()->get("ModMetadataDisabled").toBool(); + m_shader_pack_list.reset(new ShaderPackFolderModel(shaderPacksDir(), this, is_indexed, true)); + } + return m_shader_pack_list.get(); +} + +DataPackFolderModel* MinecraftInstance::dataPackList() +{ + if (!m_data_pack_list && settings()->get("GlobalDataPacksEnabled").toBool()) { + bool isIndexed = !APPLICATION->settings()->get("ModMetadataDisabled").toBool(); + m_data_pack_list.reset(new DataPackFolderModel(dataPacksDir(), this, isIndexed, true)); + } + return m_data_pack_list.get(); +} + +QList MinecraftInstance::resourceLists() +{ + return { loaderModList(), coreModList(), nilModList(), resourcePackList(), texturePackList(), shaderPackList(), dataPackList() }; +} + +WorldList* MinecraftInstance::worldList() +{ + if (!m_world_list) { + m_world_list.reset(new WorldList(worldDir(), this)); + } + return m_world_list.get(); +} + +QList MinecraftInstance::getJarMods() const +{ + auto profile = m_components->getProfile(); + QList mods; + for (auto jarmod : profile->getJarMods()) { + QStringList jar, temp1, temp2, temp3; + jarmod->getApplicableFiles(runtimeContext(), jar, temp1, temp2, temp3, jarmodsPath().absolutePath()); + // QString filePath = jarmodsPath().absoluteFilePath(jarmod->filename(currentSystem)); + mods.push_back(new Mod(QFileInfo(jar[0]))); + } + return mods; +} + +#include "MinecraftInstance.moc" diff --git a/launcher/minecraft/MinecraftInstance.h b/launcher/minecraft/MinecraftInstance.h new file mode 100644 index 0000000..909962d --- /dev/null +++ b/launcher/minecraft/MinecraftInstance.h @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once +#include +#include +#include +#include +#include "BaseInstance.h" +#include "minecraft/launch/MinecraftTarget.h" +#include "minecraft/mod/Mod.h" + +class ModFolderModel; +class ResourceFolderModel; +class ResourcePackFolderModel; +class ShaderPackFolderModel; +class TexturePackFolderModel; +class WorldList; +class LaunchStep; +class LaunchProfile; +class PackProfile; + +class MinecraftInstance : public BaseInstance { + Q_OBJECT + public: + MinecraftInstance(SettingsObject* globalSettings, std::unique_ptr settings, const QString& rootDir); + virtual ~MinecraftInstance(); + virtual void saveNow() override; + + void loadSpecificSettings() override; + + // FIXME: remove + QString typeName() const override; + // FIXME: remove + QSet traits() const override; + + bool canEdit() const override { return true; } + + bool canExport() const override { return true; } + + void populateLaunchMenu(QMenu* menu) override; + + ////// Directories and files ////// + QString jarModsDir() const; + QString resourcePacksDir() const; + QString texturePacksDir() const; + QString shaderPacksDir() const; + QString modsRoot() const override; + QString coreModsDir() const; + QString nilModsDir() const; + QString dataPacksDir(); + QString modsCacheLocation() const; + QString libDir() const; + QString worldDir() const; + QString resourcesDir() const; + QDir jarmodsPath() const; + QDir librariesPath() const; + QDir versionsPath() const; + QString instanceConfigFolder() const override; + + // Path to the instance's minecraft directory. + QString gameRoot() const override; + + // Path to the instance's minecraft bin directory. + QString binRoot() const; + + // where to put the natives during/before launch + QString getNativePath() const; + + // where the instance-local libraries should be + QString getLocalLibraryPath() const; + + /** Returns whether the instance, with its version, has support for demo mode. */ + bool supportsDemo() const; + + void updateRuntimeContext() override; + + ////// Profile management ////// + PackProfile* getPackProfile() const; + + ////// Mod Lists ////// + ModFolderModel* loaderModList(); + ModFolderModel* coreModList(); + ModFolderModel* nilModList(); + ResourcePackFolderModel* resourcePackList(); + TexturePackFolderModel* texturePackList(); + ShaderPackFolderModel* shaderPackList(); + DataPackFolderModel* dataPackList(); + QList resourceLists(); + WorldList* worldList(); + + ////// Launch stuff ////// + QList createUpdateTask() override; + LaunchTask* createLaunchTask(AuthSessionPtr account, MinecraftTarget::Ptr targetToJoin) override; + QStringList extraArguments() override; + QStringList verboseDescription(AuthSessionPtr session, MinecraftTarget::Ptr targetToJoin) override; + QList getJarMods() const; + QString createLaunchScript(AuthSessionPtr session, MinecraftTarget::Ptr targetToJoin); + /// get arguments passed to java + QStringList javaArguments(); + QString getLauncher(); + bool shouldApplyOnlineFixes(); + + /// get variables for launch command variable substitution/environment + QMap getVariables() override; + + /// create an environment for launching processes + QProcessEnvironment createEnvironment() override; + QProcessEnvironment createLaunchEnvironment() override; + + QStringList getLogFileSearchPaths() override; + + QString getStatusbarDescription() override; + + // FIXME: remove + virtual QStringList getClassPath(); + // FIXME: remove + virtual QStringList getNativeJars(); + // FIXME: remove + virtual QString getMainClass() const; + + // FIXME: remove + virtual QStringList processMinecraftArgs(AuthSessionPtr account, MinecraftTarget::Ptr targetToJoin) const; + + virtual JavaVersion getJavaVersion(); + + protected: + QMap createCensorFilterFromSession(AuthSessionPtr session); + QMap makeProfileVarMapping(std::shared_ptr profile) const; + + protected: // data + std::unique_ptr m_components; + std::unique_ptr m_loader_mod_list; + std::unique_ptr m_core_mod_list; + std::unique_ptr m_nil_mod_list; + std::unique_ptr m_resource_pack_list; + std::unique_ptr m_shader_pack_list; + std::unique_ptr m_texture_pack_list; + std::unique_ptr m_data_pack_list; + std::unique_ptr m_world_list; +}; diff --git a/launcher/minecraft/MinecraftLoadAndCheck.cpp b/launcher/minecraft/MinecraftLoadAndCheck.cpp new file mode 100644 index 0000000..c26fb8b --- /dev/null +++ b/launcher/minecraft/MinecraftLoadAndCheck.cpp @@ -0,0 +1,41 @@ +#include "MinecraftLoadAndCheck.h" +#include "MinecraftInstance.h" +#include "PackProfile.h" + +MinecraftLoadAndCheck::MinecraftLoadAndCheck(MinecraftInstance* inst, Net::Mode netmode) : m_inst(inst), m_netmode(netmode) {} + +void MinecraftLoadAndCheck::executeTask() +{ + // add offline metadata load task + auto components = m_inst->getPackProfile(); + if (auto result = components->reload(m_netmode); !result) { + emitFailed(result.error); + return; + } + m_task = components->getCurrentTask(); + + if (!m_task) { + emitSucceeded(); + return; + } + connect(m_task.get(), &Task::succeeded, this, &MinecraftLoadAndCheck::emitSucceeded); + connect(m_task.get(), &Task::failed, this, &MinecraftLoadAndCheck::emitFailed); + connect(m_task.get(), &Task::aborted, this, &MinecraftLoadAndCheck::emitAborted); + propagateFromOther(m_task.get()); +} + +bool MinecraftLoadAndCheck::canAbort() const +{ + if (m_task) { + return m_task->canAbort(); + } + return true; +} + +bool MinecraftLoadAndCheck::abort() +{ + if (m_task && m_task->canAbort()) { + return m_task->abort(); + } + return Task::abort(); +} diff --git a/launcher/minecraft/MinecraftLoadAndCheck.h b/launcher/minecraft/MinecraftLoadAndCheck.h new file mode 100644 index 0000000..c05698b --- /dev/null +++ b/launcher/minecraft/MinecraftLoadAndCheck.h @@ -0,0 +1,38 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "net/Mode.h" +#include "tasks/Task.h" + +class MinecraftInstance; + +class MinecraftLoadAndCheck : public Task { + Q_OBJECT + public: + explicit MinecraftLoadAndCheck(MinecraftInstance* inst, Net::Mode netmode); + virtual ~MinecraftLoadAndCheck() = default; + void executeTask() override; + + bool canAbort() const override; + public slots: + bool abort() override; + + private: + MinecraftInstance* m_inst = nullptr; + Task::Ptr m_task; + Net::Mode m_netmode; +}; diff --git a/launcher/minecraft/MojangDownloadInfo.h b/launcher/minecraft/MojangDownloadInfo.h new file mode 100644 index 0000000..eb64f95 --- /dev/null +++ b/launcher/minecraft/MojangDownloadInfo.h @@ -0,0 +1,70 @@ +#pragma once +#include +#include +#include + +struct MojangDownloadInfo { + // types + using Ptr = std::shared_ptr; + + // data + /// Local filesystem path. WARNING: not used, only here so we can pass through mojang files unmolested! + QString path; + /// absolute URL of this file + QString url; + /// sha-1 checksum of the file + QString sha1; + /// size of the file in bytes + int size; +}; + +struct MojangLibraryDownloadInfo { + MojangLibraryDownloadInfo(MojangDownloadInfo::Ptr artifact_) : artifact(artifact_) {} + MojangLibraryDownloadInfo() {} + + // types + using Ptr = std::shared_ptr; + + // methods + MojangDownloadInfo* getDownloadInfo(QString classifier) + { + if (classifier.isNull()) { + return artifact.get(); + } + + return classifiers[classifier].get(); + } + + // data + MojangDownloadInfo::Ptr artifact; + QMap classifiers; +}; + +struct MojangAssetIndexInfo : public MojangDownloadInfo { + // types + using Ptr = std::shared_ptr; + + // methods + MojangAssetIndexInfo() {} + + MojangAssetIndexInfo(QString id_) + { + this->id = id_; + // HACK: ignore assets from other version files than Minecraft + // workaround for stupid assets issue caused by amazon: + // https://www.theregister.co.uk/2017/02/28/aws_is_awol_as_s3_goes_haywire/ + if (id_ == "legacy") { + url = "https://piston-meta.mojang.com/mc/assets/legacy/c0fd82e8ce9fbc93119e40d96d5a4e62cfa3f729/legacy.json"; + } + // HACK + else { + url = "https://s3.amazonaws.com/Minecraft.Download/indexes/" + id_ + ".json"; + } + known = false; + } + + // data + int totalSize; + QString id; + bool known = true; +}; diff --git a/launcher/minecraft/MojangVersionFormat.cpp b/launcher/minecraft/MojangVersionFormat.cpp new file mode 100644 index 0000000..42730b1 --- /dev/null +++ b/launcher/minecraft/MojangVersionFormat.cpp @@ -0,0 +1,372 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "MojangVersionFormat.h" +#include "MojangDownloadInfo.h" +#include "OneSixVersionFormat.h" + +#include "Json.h" +using namespace Json; +#include +#include "ParseUtils.h" + +static const int CURRENT_MINIMUM_LAUNCHER_VERSION = 18; + +static MojangAssetIndexInfo::Ptr assetIndexFromJson(const QJsonObject& obj); +static MojangDownloadInfo::Ptr downloadInfoFromJson(const QJsonObject& obj); +static MojangLibraryDownloadInfo::Ptr libDownloadInfoFromJson(const QJsonObject& libObj); +static QJsonObject assetIndexToJson(MojangAssetIndexInfo::Ptr assetidxinfo); +static QJsonObject libDownloadInfoToJson(MojangLibraryDownloadInfo::Ptr libinfo); +static QJsonObject downloadInfoToJson(MojangDownloadInfo::Ptr info); + +namespace Bits { +static void readString(const QJsonObject& root, const QString& key, QString& variable) +{ + if (root.contains(key)) { + variable = requireString(root.value(key)); + } +} + +static void readDownloadInfo(MojangDownloadInfo::Ptr out, const QJsonObject& obj) +{ + // optional, not used + readString(obj, "path", out->path); + // required! + out->sha1 = requireString(obj, "sha1"); + out->url = requireString(obj, "url"); + out->size = requireInteger(obj, "size"); +} + +static void readAssetIndex(MojangAssetIndexInfo::Ptr out, const QJsonObject& obj) +{ + out->totalSize = requireInteger(obj, "totalSize"); + out->id = requireString(obj, "id"); + // out->known = true; +} +} // namespace Bits + +MojangDownloadInfo::Ptr downloadInfoFromJson(const QJsonObject& obj) +{ + auto out = std::make_shared(); + Bits::readDownloadInfo(out, obj); + return out; +} + +MojangAssetIndexInfo::Ptr assetIndexFromJson(const QJsonObject& obj) +{ + auto out = std::make_shared(); + Bits::readDownloadInfo(out, obj); + Bits::readAssetIndex(out, obj); + return out; +} + +QJsonObject downloadInfoToJson(MojangDownloadInfo::Ptr info) +{ + QJsonObject out; + if (!info->path.isNull()) { + out.insert("path", info->path); + } + out.insert("sha1", info->sha1); + out.insert("size", info->size); + out.insert("url", info->url); + return out; +} + +MojangLibraryDownloadInfo::Ptr libDownloadInfoFromJson(const QJsonObject& libObj) +{ + auto out = std::make_shared(); + auto dlObj = requireObject(libObj.value("downloads")); + if (dlObj.contains("artifact")) { + out->artifact = downloadInfoFromJson(requireObject(dlObj, "artifact")); + } + if (dlObj.contains("classifiers")) { + auto classifiersObj = requireObject(dlObj, "classifiers"); + for (auto iter = classifiersObj.begin(); iter != classifiersObj.end(); iter++) { + auto classifier = iter.key(); + auto classifierObj = requireObject(iter.value()); + out->classifiers[classifier] = downloadInfoFromJson(classifierObj); + } + } + return out; +} + +QJsonObject libDownloadInfoToJson(MojangLibraryDownloadInfo::Ptr libinfo) +{ + QJsonObject out; + if (libinfo->artifact) { + out.insert("artifact", downloadInfoToJson(libinfo->artifact)); + } + if (!libinfo->classifiers.isEmpty()) { + QJsonObject classifiersOut; + for (auto iter = libinfo->classifiers.begin(); iter != libinfo->classifiers.end(); iter++) { + classifiersOut.insert(iter.key(), downloadInfoToJson(iter.value())); + } + out.insert("classifiers", classifiersOut); + } + return out; +} + +QJsonObject assetIndexToJson(MojangAssetIndexInfo::Ptr info) +{ + QJsonObject out; + if (!info->path.isNull()) { + out.insert("path", info->path); + } + out.insert("sha1", info->sha1); + out.insert("size", info->size); + out.insert("url", info->url); + out.insert("totalSize", info->totalSize); + out.insert("id", info->id); + return out; +} + +void MojangVersionFormat::readVersionProperties(const QJsonObject& in, VersionFile* out) +{ + Bits::readString(in, "id", out->minecraftVersion); + Bits::readString(in, "mainClass", out->mainClass); + Bits::readString(in, "minecraftArguments", out->minecraftArguments); + Bits::readString(in, "type", out->type); + + Bits::readString(in, "assets", out->assets); + if (in.contains("assetIndex")) { + out->mojangAssetIndex = assetIndexFromJson(requireObject(in, "assetIndex")); + } else if (!out->assets.isNull()) { + out->mojangAssetIndex = std::make_shared(out->assets); + } + + out->releaseTime = timeFromS3Time(in.value("releaseTime").toString("")); + out->updateTime = timeFromS3Time(in.value("time").toString("")); + + if (in.contains("minimumLauncherVersion")) { + out->minimumLauncherVersion = requireInteger(in.value("minimumLauncherVersion")); + if (out->minimumLauncherVersion > CURRENT_MINIMUM_LAUNCHER_VERSION) { + out->addProblem(ProblemSeverity::Warning, QObject::tr("The 'minimumLauncherVersion' value of this version (%1) is higher than " + "supported by %3 (%2). It might not work properly!") + .arg(out->minimumLauncherVersion) + .arg(CURRENT_MINIMUM_LAUNCHER_VERSION) + .arg(BuildConfig.LAUNCHER_DISPLAYNAME)); + } + } + + if (in.contains("compatibleJavaMajors")) { + for (auto compatible : requireArray(in.value("compatibleJavaMajors"))) { + out->compatibleJavaMajors.append(requireInteger(compatible)); + } + } + if (in.contains("compatibleJavaName")) { + out->compatibleJavaName = requireString(in.value("compatibleJavaName")); + } + + if (in.contains("downloads")) { + auto downloadsObj = requireObject(in, "downloads"); + for (auto iter = downloadsObj.begin(); iter != downloadsObj.end(); iter++) { + auto classifier = iter.key(); + auto classifierObj = requireObject(iter.value()); + out->mojangDownloads[classifier] = downloadInfoFromJson(classifierObj); + } + } +} + +VersionFilePtr MojangVersionFormat::versionFileFromJson(const QJsonDocument& doc, const QString& filename) +{ + VersionFilePtr out(new VersionFile()); + if (doc.isEmpty() || doc.isNull()) { + throw JSONValidationError(filename + " is empty or null"); + } + if (!doc.isObject()) { + throw JSONValidationError(filename + " is not an object"); + } + + QJsonObject root = doc.object(); + + readVersionProperties(root, out.get()); + + out->name = "Minecraft"; + out->uid = "net.minecraft"; + out->version = out->minecraftVersion; + // out->filename = filename; + + if (root.contains("libraries")) { + for (auto libVal : requireArray(root.value("libraries"))) { + auto libObj = requireObject(libVal); + + auto lib = MojangVersionFormat::libraryFromJson(*out, libObj, filename); + out->libraries.append(lib); + } + } + return out; +} + +void MojangVersionFormat::writeVersionProperties(const VersionFile* in, QJsonObject& out) +{ + writeString(out, "id", in->minecraftVersion); + writeString(out, "mainClass", in->mainClass); + writeString(out, "minecraftArguments", in->minecraftArguments); + writeString(out, "type", in->type); + if (!in->releaseTime.isNull()) { + writeString(out, "releaseTime", timeToS3Time(in->releaseTime)); + } + if (!in->updateTime.isNull()) { + writeString(out, "time", timeToS3Time(in->updateTime)); + } + if (in->minimumLauncherVersion != -1) { + out.insert("minimumLauncherVersion", in->minimumLauncherVersion); + } + writeString(out, "assets", in->assets); + if (in->mojangAssetIndex && in->mojangAssetIndex->known) { + out.insert("assetIndex", assetIndexToJson(in->mojangAssetIndex)); + } + if (!in->mojangDownloads.isEmpty()) { + QJsonObject downloadsOut; + for (auto iter = in->mojangDownloads.begin(); iter != in->mojangDownloads.end(); iter++) { + downloadsOut.insert(iter.key(), downloadInfoToJson(iter.value())); + } + out.insert("downloads", downloadsOut); + } + if (!in->compatibleJavaMajors.isEmpty()) { + QJsonArray compatibleJavaMajorsOut; + for (auto compatibleJavaMajor : in->compatibleJavaMajors) { + compatibleJavaMajorsOut.append(compatibleJavaMajor); + } + out.insert("compatibleJavaMajors", compatibleJavaMajorsOut); + } + if (!in->compatibleJavaName.isEmpty()) { + writeString(out, "compatibleJavaName", in->compatibleJavaName); + } +} + +QJsonDocument MojangVersionFormat::versionFileToJson(const VersionFilePtr& patch) +{ + QJsonObject root; + writeVersionProperties(patch.get(), root); + if (!patch->libraries.isEmpty()) { + QJsonArray array; + for (auto value : patch->libraries) { + array.append(MojangVersionFormat::libraryToJson(value.get())); + } + root.insert("libraries", array); + } + + // write the contents to a json document. + { + QJsonDocument out; + out.setObject(root); + return out; + } +} + +LibraryPtr MojangVersionFormat::libraryFromJson(ProblemContainer& problems, const QJsonObject& libObj, const QString& filename) +{ + LibraryPtr out(new Library()); + if (!libObj.contains("name")) { + throw JSONValidationError(filename + "contains a library that doesn't have a 'name' field"); + } + auto rawName = libObj.value("name").toString(); + out->m_name = rawName; + if (!out->m_name.valid()) { + problems.addProblem(ProblemSeverity::Error, QObject::tr("Library %1 name is broken and cannot be processed.").arg(rawName)); + } + + Bits::readString(libObj, "url", out->m_repositoryURL); + if (libObj.contains("extract")) { + out->m_hasExcludes = true; + auto extractObj = requireObject(libObj.value("extract")); + for (auto excludeVal : requireArray(extractObj.value("exclude"))) { + out->m_extractExcludes.append(requireString(excludeVal)); + } + } + if (libObj.contains("natives")) { + QJsonObject nativesObj = requireObject(libObj.value("natives")); + for (auto it = nativesObj.begin(); it != nativesObj.end(); ++it) { + if (!it.value().isString()) { + qWarning() << filename << "contains an invalid native (skipping)"; + } + // FIXME: Skip unknown platforms + out->m_nativeClassifiers[it.key()] = it.value().toString(); + } + } + if (libObj.contains("rules")) { + out->applyRules = true; + + QJsonArray rulesArray = requireArray(libObj.value("rules")); + for (auto rule : rulesArray) { + out->m_rules.append(Rule::fromJson(requireObject(rule))); + } + } + if (libObj.contains("downloads")) { + out->m_mojangDownloads = libDownloadInfoFromJson(libObj); + } + return out; +} + +QJsonObject MojangVersionFormat::libraryToJson(Library* library) +{ + QJsonObject libRoot; + libRoot.insert("name", library->m_name.serialize()); + if (!library->m_repositoryURL.isEmpty()) { + libRoot.insert("url", library->m_repositoryURL); + } + if (library->isNative()) { + QJsonObject nativeList; + auto iter = library->m_nativeClassifiers.begin(); + while (iter != library->m_nativeClassifiers.end()) { + nativeList.insert(iter.key(), iter.value()); + iter++; + } + libRoot.insert("natives", nativeList); + if (!library->m_extractExcludes.isEmpty()) { + QJsonArray excludes; + QJsonObject extract; + for (auto exclude : library->m_extractExcludes) { + excludes.append(exclude); + } + extract.insert("exclude", excludes); + libRoot.insert("extract", extract); + } + } + if (!library->m_rules.isEmpty()) { + QJsonArray allRules; + for (auto& rule : library->m_rules) { + QJsonObject ruleObj = rule.toJson(); + allRules.append(ruleObj); + } + libRoot.insert("rules", allRules); + } + if (library->m_mojangDownloads) { + auto downloadsObj = libDownloadInfoToJson(library->m_mojangDownloads); + libRoot.insert("downloads", downloadsObj); + } + return libRoot; +} diff --git a/launcher/minecraft/MojangVersionFormat.h b/launcher/minecraft/MojangVersionFormat.h new file mode 100644 index 0000000..88d8a20 --- /dev/null +++ b/launcher/minecraft/MojangVersionFormat.h @@ -0,0 +1,25 @@ +#pragma once + +#include +#include +#include +#include + +class MojangVersionFormat { + friend class OneSixVersionFormat; + + protected: + // does not include libraries + static void readVersionProperties(const QJsonObject& in, VersionFile* out); + // does not include libraries + static void writeVersionProperties(const VersionFile* in, QJsonObject& out); + + public: + // version files / profile patches + static VersionFilePtr versionFileFromJson(const QJsonDocument& doc, const QString& filename); + static QJsonDocument versionFileToJson(const VersionFilePtr& patch); + + // libraries + static LibraryPtr libraryFromJson(ProblemContainer& problems, const QJsonObject& libObj, const QString& filename); + static QJsonObject libraryToJson(Library* library); +}; diff --git a/launcher/minecraft/OneSixVersionFormat.cpp b/launcher/minecraft/OneSixVersionFormat.cpp new file mode 100644 index 0000000..95f4c5e --- /dev/null +++ b/launcher/minecraft/OneSixVersionFormat.cpp @@ -0,0 +1,412 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "OneSixVersionFormat.h" +#include +#include +#include +#include "java/JavaMetadata.h" +#include "minecraft/Agent.h" +#include "minecraft/ParseUtils.h" + +#include + +using namespace Json; + +static void readString(const QJsonObject& root, const QString& key, QString& variable) +{ + if (root.contains(key)) { + variable = requireString(root.value(key)); + } +} + +LibraryPtr OneSixVersionFormat::libraryFromJson(ProblemContainer& problems, const QJsonObject& libObj, const QString& filename) +{ + LibraryPtr out = MojangVersionFormat::libraryFromJson(problems, libObj, filename); + readString(libObj, "MMC-hint", out->m_hint); + readString(libObj, "MMC-absulute_url", out->m_absoluteURL); + readString(libObj, "MMC-absoluteUrl", out->m_absoluteURL); + readString(libObj, "MMC-filename", out->m_filename); + readString(libObj, "MMC-displayname", out->m_displayname); + return out; +} + +QJsonObject OneSixVersionFormat::libraryToJson(Library* library) +{ + QJsonObject libRoot = MojangVersionFormat::libraryToJson(library); + if (!library->m_absoluteURL.isEmpty()) + libRoot.insert("MMC-absoluteUrl", library->m_absoluteURL); + if (!library->m_hint.isEmpty()) + libRoot.insert("MMC-hint", library->m_hint); + if (!library->m_filename.isEmpty()) + libRoot.insert("MMC-filename", library->m_filename); + if (!library->m_displayname.isEmpty()) + libRoot.insert("MMC-displayname", library->m_displayname); + return libRoot; +} + +VersionFilePtr OneSixVersionFormat::versionFileFromJson(const QJsonDocument& doc, const QString& filename, const bool requireOrder) +{ + VersionFilePtr out(new VersionFile()); + if (doc.isEmpty() || doc.isNull()) { + throw JSONValidationError(filename + " is empty or null"); + } + if (!doc.isObject()) { + throw JSONValidationError(filename + " is not an object"); + } + + QJsonObject root = doc.object(); + + Meta::MetadataVersion formatVersion = Meta::parseFormatVersion(root, false); + switch (formatVersion) { + case Meta::MetadataVersion::InitialRelease: + break; + case Meta::MetadataVersion::Invalid: + throw JSONValidationError(filename + " does not contain a recognizable version of the metadata format."); + } + + if (requireOrder) { + if (root.contains("order")) { + out->order = requireInteger(root.value("order")); + } else { + // FIXME: evaluate if we don't want to throw exceptions here instead + qCritical() << filename << "doesn't contain an order field"; + } + } + + out->name = root.value("name").toString(); + + if (root.contains("uid")) { + out->uid = root.value("uid").toString(); + } else { + out->uid = root.value("fileId").toString(); + } + + static const QRegularExpression s_validUidRegex{ QRegularExpression::anchoredPattern( + QStringLiteral(R"([a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]+)*)")) }; + if (!s_validUidRegex.match(out->uid).hasMatch()) { + qCritical() << "The component's 'uid' contains illegal characters! UID:" << out->uid; + out->addProblem(ProblemSeverity::Error, + QObject::tr("The component's 'uid' contains illegal characters! This can cause security issues.")); + } + + out->version = root.value("version").toString(); + + MojangVersionFormat::readVersionProperties(root, out.get()); + + // added for legacy Minecraft window embedding, TODO: remove + readString(root, "appletClass", out->appletClass); + + if (root.contains("+tweakers")) { + for (auto tweakerVal : requireArray(root.value("+tweakers"))) { + out->addTweakers.append(requireString(tweakerVal)); + } + } + + if (root.contains("+traits")) { + for (auto tweakerVal : requireArray(root.value("+traits"))) { + out->traits.insert(requireString(tweakerVal)); + } + } + + if (root.contains("+jvmArgs")) { + for (auto arg : requireArray(root.value("+jvmArgs"))) { + out->addnJvmArguments.append(requireString(arg)); + } + } + + if (root.contains("jarMods")) { + for (auto libVal : requireArray(root.value("jarMods"))) { + QJsonObject libObj = requireObject(libVal); + // parse the jarmod + auto lib = OneSixVersionFormat::jarModFromJson(*out, libObj, filename); + // and add to jar mods + out->jarMods.append(lib); + } + } else if (root.contains("+jarMods")) // DEPRECATED: old style '+jarMods' are only here for backwards compatibility + { + for (auto libVal : requireArray(root.value("+jarMods"))) { + QJsonObject libObj = requireObject(libVal); + // parse the jarmod + auto lib = OneSixVersionFormat::plusJarModFromJson(*out, libObj, filename, out->name); + // and add to jar mods + out->jarMods.append(lib); + } + } + + if (root.contains("mods")) { + for (auto libVal : requireArray(root.value("mods"))) { + QJsonObject libObj = requireObject(libVal); + // parse the jarmod + auto lib = OneSixVersionFormat::modFromJson(*out, libObj, filename); + // and add to jar mods + out->mods.append(lib); + } + } + + auto readLibs = [&root, &out, &filename](const char* which, QList& outList) { + for (auto libVal : requireArray(root.value(which))) { + QJsonObject libObj = requireObject(libVal); + // parse the library + auto lib = libraryFromJson(*out, libObj, filename); + outList.append(lib); + } + }; + bool hasPlusLibs = root.contains("+libraries"); + bool hasLibs = root.contains("libraries"); + if (hasPlusLibs && hasLibs) { + out->addProblem(ProblemSeverity::Warning, + QObject::tr("Version file has both '+libraries' and 'libraries'. This is no longer supported.")); + readLibs("libraries", out->libraries); + readLibs("+libraries", out->libraries); + } else if (hasLibs) { + readLibs("libraries", out->libraries); + } else if (hasPlusLibs) { + readLibs("+libraries", out->libraries); + } + + if (root.contains("mavenFiles")) { + readLibs("mavenFiles", out->mavenFiles); + } + + if (root.contains("+agents")) { + for (auto agentVal : requireArray(root.value("+agents"))) { + QJsonObject agentObj = requireObject(agentVal); + auto lib = libraryFromJson(*out, agentObj, filename); + + QString arg = ""; + readString(agentObj, "argument", arg); + + out->agents.append(Agent{ lib, arg }); + } + } + + // if we have mainJar, just use it + if (root.contains("mainJar")) { + QJsonObject libObj = requireObject(root, "mainJar"); + out->mainJar = libraryFromJson(*out, libObj, filename); + } + // else reconstruct it from downloads and id ... if that's available + else if (!out->minecraftVersion.isEmpty()) { + auto lib = std::make_shared(); + lib->setRawName(GradleSpecifier(QString("com.mojang:minecraft:%1:client").arg(out->minecraftVersion))); + // we have a reliable client download, use it. + if (out->mojangDownloads.contains("client")) { + auto LibDLInfo = std::make_shared(); + LibDLInfo->artifact = out->mojangDownloads["client"]; + lib->setMojangDownloadInfo(LibDLInfo); + } + // we got nothing... + else { + out->addProblem( + ProblemSeverity::Error, + QObject::tr("URL for the main jar could not be determined - Mojang removed the server that we used as fallback.")); + } + out->mainJar = lib; + } + + if (root.contains("requires")) { + Meta::parseRequires(root, &out->m_requires); + } + QString dependsOnMinecraftVersion = root.value("mcVersion").toString(); + if (!dependsOnMinecraftVersion.isEmpty()) { + Meta::Require mcReq; + mcReq.uid = "net.minecraft"; + mcReq.equalsVersion = dependsOnMinecraftVersion; + if (out->m_requires.count(mcReq) == 0) { + out->m_requires.insert(mcReq); + } + } + if (root.contains("conflicts")) { + Meta::parseRequires(root, &out->conflicts); + } + if (root.contains("volatile")) { + out->m_volatile = requireBoolean(root, "volatile"); + } + + if (root.contains("runtimes")) { + out->runtimes = {}; + for (auto runtime : root["runtimes"].toArray()) { + out->runtimes.append(Java::parseJavaMeta(runtime.toObject())); + } + } + + /* removed features that shouldn't be used */ + if (root.contains("tweakers")) { + out->addProblem(ProblemSeverity::Error, QObject::tr("Version file contains unsupported element 'tweakers'")); + } + if (root.contains("-libraries")) { + out->addProblem(ProblemSeverity::Error, QObject::tr("Version file contains unsupported element '-libraries'")); + } + if (root.contains("-tweakers")) { + out->addProblem(ProblemSeverity::Error, QObject::tr("Version file contains unsupported element '-tweakers'")); + } + if (root.contains("-minecraftArguments")) { + out->addProblem(ProblemSeverity::Error, QObject::tr("Version file contains unsupported element '-minecraftArguments'")); + } + if (root.contains("+minecraftArguments")) { + out->addProblem(ProblemSeverity::Error, QObject::tr("Version file contains unsupported element '+minecraftArguments'")); + } + return out; +} + +QJsonDocument OneSixVersionFormat::versionFileToJson(const VersionFilePtr& patch) +{ + QJsonObject root; + writeString(root, "name", patch->name); + + writeString(root, "uid", patch->uid); + + writeString(root, "version", patch->version); + + Meta::serializeFormatVersion(root, Meta::MetadataVersion::InitialRelease); + + MojangVersionFormat::writeVersionProperties(patch.get(), root); + + if (patch->mainJar) { + root.insert("mainJar", libraryToJson(patch->mainJar.get())); + } + writeString(root, "appletClass", patch->appletClass); + writeStringList(root, "+tweakers", patch->addTweakers); + writeStringList(root, "+traits", patch->traits.values()); + writeStringList(root, "+jvmArgs", patch->addnJvmArguments); + if (!patch->agents.isEmpty()) { + QJsonArray array; + for (const auto& value : patch->agents) { + QJsonObject agentOut = OneSixVersionFormat::libraryToJson(value.library.get()); + if (!value.argument.isEmpty()) + agentOut.insert("argument", value.argument); + + array.append(agentOut); + } + root.insert("+agents", array); + } + if (!patch->libraries.isEmpty()) { + QJsonArray array; + for (auto value : patch->libraries) { + array.append(OneSixVersionFormat::libraryToJson(value.get())); + } + root.insert("libraries", array); + } + if (!patch->mavenFiles.isEmpty()) { + QJsonArray array; + for (auto value : patch->mavenFiles) { + array.append(OneSixVersionFormat::libraryToJson(value.get())); + } + root.insert("mavenFiles", array); + } + if (!patch->jarMods.isEmpty()) { + QJsonArray array; + for (auto value : patch->jarMods) { + array.append(OneSixVersionFormat::jarModtoJson(value.get())); + } + root.insert("jarMods", array); + } + if (!patch->mods.isEmpty()) { + QJsonArray array; + for (auto value : patch->jarMods) { + array.append(OneSixVersionFormat::modtoJson(value.get())); + } + root.insert("mods", array); + } + if (!patch->m_requires.empty()) { + Meta::serializeRequires(root, &patch->m_requires, "requires"); + } + if (!patch->conflicts.empty()) { + Meta::serializeRequires(root, &patch->conflicts, "conflicts"); + } + if (patch->m_volatile) { + root.insert("volatile", true); + } + // write the contents to a json document. + { + QJsonDocument out; + out.setObject(root); + return out; + } +} + +LibraryPtr OneSixVersionFormat::plusJarModFromJson([[maybe_unused]] ProblemContainer& problems, + const QJsonObject& libObj, + const QString& filename, + const QString& originalName) +{ + LibraryPtr out(new Library()); + if (!libObj.contains("name")) { + throw JSONValidationError(filename + "contains a jarmod that doesn't have a 'name' field"); + } + + // just make up something unique on the spot for the library name. + QString id = QUuid::createUuid().toString(QUuid::WithoutBraces); + out->setRawName(GradleSpecifier("org.multimc.jarmods:" + id + ":1")); + + // filename override is the old name + out->setFilename(libObj.value("name").toString()); + + // it needs to be local, it is stored in the instance jarmods folder + out->setHint("local"); + + // read the original name if present - some versions did not set it + // it is the original jar mod filename before it got renamed at the point of addition + auto displayName = libObj.value("originalName").toString(); + if (displayName.isEmpty()) { + auto fixed = originalName; + fixed.remove(" (jar mod)"); + out->setDisplayName(fixed); + } else { + out->setDisplayName(displayName); + } + return out; +} + +LibraryPtr OneSixVersionFormat::jarModFromJson(ProblemContainer& problems, const QJsonObject& libObj, const QString& filename) +{ + return libraryFromJson(problems, libObj, filename); +} + +QJsonObject OneSixVersionFormat::jarModtoJson(Library* jarmod) +{ + return libraryToJson(jarmod); +} + +LibraryPtr OneSixVersionFormat::modFromJson(ProblemContainer& problems, const QJsonObject& libObj, const QString& filename) +{ + return libraryFromJson(problems, libObj, filename); +} + +QJsonObject OneSixVersionFormat::modtoJson(Library* jarmod) +{ + return libraryToJson(jarmod); +} diff --git a/launcher/minecraft/OneSixVersionFormat.h b/launcher/minecraft/OneSixVersionFormat.h new file mode 100644 index 0000000..9024d41 --- /dev/null +++ b/launcher/minecraft/OneSixVersionFormat.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include +#include +#include +#include + +class OneSixVersionFormat { + public: + // version files / profile patches + static VersionFilePtr versionFileFromJson(const QJsonDocument& doc, const QString& filename, bool requireOrder); + static QJsonDocument versionFileToJson(const VersionFilePtr& patch); + + // libraries + static LibraryPtr libraryFromJson(ProblemContainer& problems, const QJsonObject& libObj, const QString& filename); + static QJsonObject libraryToJson(Library* library); + + // DEPRECATED: old 'plus' jar mods generated by the application + static LibraryPtr plusJarModFromJson(ProblemContainer& problems, + const QJsonObject& libObj, + const QString& filename, + const QString& originalName); + + // new jar mods derived from libraries + static LibraryPtr jarModFromJson(ProblemContainer& problems, const QJsonObject& libObj, const QString& filename); + static QJsonObject jarModtoJson(Library* jarmod); + + // mods, also derived from libraries + static LibraryPtr modFromJson(ProblemContainer& problems, const QJsonObject& libObj, const QString& filename); + static QJsonObject modtoJson(Library* jarmod); +}; diff --git a/launcher/minecraft/PackProfile.cpp b/launcher/minecraft/PackProfile.cpp new file mode 100644 index 0000000..f0cff7f --- /dev/null +++ b/launcher/minecraft/PackProfile.cpp @@ -0,0 +1,1073 @@ +// SPDX-FileCopyrightText: 2022-2023 Sefa Eyeoglu +// +// SPDX-License-Identifier: GPL-3.0-only AND Apache-2.0 + +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022-2023 Sefa Eyeoglu + * Copyright (C) 2022 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Application.h" +#include "Exception.h" +#include "FileSystem.h" +#include "Json.h" +#include "meta/Index.h" +#include "meta/JsonFormat.h" +#include "minecraft/Component.h" +#include "minecraft/MinecraftInstance.h" +#include "minecraft/OneSixVersionFormat.h" +#include "minecraft/ProfileUtils.h" + +#include "ComponentUpdateTask.h" +#include "PackProfile.h" +#include "PackProfile_p.h" +#include "modplatform/ModIndex.h" + +#include "minecraft/Logging.h" + +#include "ui/dialogs/CustomMessageBox.h" + +PackProfile::PackProfile(MinecraftInstance* instance) : QAbstractListModel() +{ + d.reset(new PackProfileData); + d->m_instance = instance; + d->m_saveTimer.setSingleShot(true); + d->m_saveTimer.setInterval(5000); + d->interactionDisabled = instance->isRunning(); + connect(d->m_instance, &BaseInstance::runningStatusChanged, this, &PackProfile::disableInteraction); + connect(&d->m_saveTimer, &QTimer::timeout, this, &PackProfile::save_internal); +} + +PackProfile::~PackProfile() +{ + saveNow(); +} + +// BEGIN: component file format + +static const int currentComponentsFileVersion = 1; + +static QJsonObject componentToJsonV1(ComponentPtr component) +{ + QJsonObject obj; + // critical + obj.insert("uid", component->m_uid); + if (!component->m_version.isEmpty()) { + obj.insert("version", component->m_version); + } + if (component->m_dependencyOnly) { + obj.insert("dependencyOnly", true); + } + if (component->m_important) { + obj.insert("important", true); + } + if (component->m_disabled) { + obj.insert("disabled", true); + } + + // cached + if (!component->m_cachedVersion.isEmpty()) { + obj.insert("cachedVersion", component->m_cachedVersion); + } + if (!component->m_cachedName.isEmpty()) { + obj.insert("cachedName", component->m_cachedName); + } + Meta::serializeRequires(obj, &component->m_cachedRequires, "cachedRequires"); + Meta::serializeRequires(obj, &component->m_cachedConflicts, "cachedConflicts"); + if (component->m_cachedVolatile) { + obj.insert("cachedVolatile", true); + } + return obj; +} + +static ComponentPtr componentFromJsonV1(PackProfile* parent, const QString& componentJsonPattern, const QJsonObject& obj) +{ + // critical + auto uid = Json::requireString(obj.value("uid")); + auto filePath = componentJsonPattern.arg(uid); + auto component = makeShared(parent, uid); + component->m_version = obj.value("version").toString(); + component->m_dependencyOnly = obj.value("dependencyOnly").toBool(); + component->m_important = obj.value("important").toBool(); + + // cached + // TODO @RESILIENCE: ignore invalid values/structure here? + component->m_cachedVersion = obj.value("cachedVersion").toString(); + component->m_cachedName = obj.value("cachedName").toString(); + Meta::parseRequires(obj, &component->m_cachedRequires, "cachedRequires"); + Meta::parseRequires(obj, &component->m_cachedConflicts, "cachedConflicts"); + component->m_cachedVolatile = obj.value("volatile").toBool(); + bool disabled = obj.value("disabled").toBool(); + component->setEnabled(!disabled); + return component; +} + +// Save the given component container data to a file +static bool savePackProfile(const QString& filename, const ComponentContainer& container) +{ + QJsonObject obj; + obj.insert("formatVersion", currentComponentsFileVersion); + QJsonArray orderArray; + for (auto component : container) { + orderArray.append(componentToJsonV1(component)); + } + obj.insert("components", orderArray); + QSaveFile outFile(filename); + if (!outFile.open(QFile::WriteOnly)) { + qCCritical(instanceProfileC) << "Couldn't open" << outFile.fileName() << "for writing:" << outFile.errorString(); + return false; + } + auto data = QJsonDocument(obj).toJson(QJsonDocument::Indented); + if (outFile.write(data) != data.size()) { + qCCritical(instanceProfileC) << "Couldn't write all the data into" << outFile.fileName() << "because:" << outFile.errorString(); + return false; + } + if (!outFile.commit()) { + qCCritical(instanceProfileC) << "Couldn't save" << outFile.fileName() << "because:" << outFile.errorString(); + return false; + } + return true; +} + +// Read the given file into component containers +static PackProfile::Result loadPackProfile(PackProfile* parent, + const QString& filename, + const QString& componentJsonPattern, + ComponentContainer& container) +{ + QFile componentsFile(filename); + if (!componentsFile.exists()) { + auto message = QObject::tr("Components file %1 doesn't exist. This should never happen.").arg(filename); + qCWarning(instanceProfileC) << message; + return PackProfile::Result::Error(message); + } + if (!componentsFile.open(QFile::ReadOnly)) { + auto message = QObject::tr("Couldn't open %1 for reading: %2").arg(componentsFile.fileName(), componentsFile.errorString()); + qCCritical(instanceProfileC) << message; + qCWarning(instanceProfileC) << "Ignoring overridden order"; + return PackProfile::Result::Error(message); + } + + // and it's valid JSON + QJsonParseError error; + QJsonDocument doc = QJsonDocument::fromJson(componentsFile.readAll(), &error); + if (error.error != QJsonParseError::NoError) { + auto message = QObject::tr("Couldn't parse %1 as json: %2").arg(componentsFile.fileName(), error.errorString()); + qCCritical(instanceProfileC) << message; + qCWarning(instanceProfileC) << "Ignoring overridden order"; + return PackProfile::Result::Error(message); + } + + // and then read it and process it if all above is true. + try { + auto obj = Json::requireObject(doc); + // check order file version. + auto version = Json::requireInteger(obj.value("formatVersion")); + if (version != currentComponentsFileVersion) { + throw JSONValidationError(QObject::tr("Invalid component file version, expected %1").arg(currentComponentsFileVersion)); + } + auto orderArray = Json::requireArray(obj.value("components")); + for (auto item : orderArray) { + auto comp_obj = Json::requireObject(item, "Component must be an object."); + container.append(componentFromJsonV1(parent, componentJsonPattern, comp_obj)); + } + } catch ([[maybe_unused]] const JSONValidationError& err) { + auto message = QObject::tr("Couldn't parse %1 : bad file format").arg(componentsFile.fileName()); + qCCritical(instanceProfileC) << message; + qCWarning(instanceProfileC) << "error:" << err.what(); + container.clear(); + return PackProfile::Result::Error(message); + } + return PackProfile::Result::Success(); +} + +// END: component file format + +// BEGIN: save/load logic + +void PackProfile::saveNow() +{ + if (saveIsScheduled() && save_internal()) { + d->m_saveTimer.stop(); + } +} + +bool PackProfile::saveIsScheduled() const +{ + return d->dirty; +} + +void PackProfile::buildingFromScratch() +{ + d->loaded = true; + d->dirty = true; +} + +void PackProfile::scheduleSave() +{ + if (!d->loaded) { + qDebug() << d->m_instance->name() << "|" << "Component list should never save if it didn't successfully load"; + return; + } + if (!d->dirty) { + d->dirty = true; + qDebug() << d->m_instance->name() << "|" << "Component list save is scheduled"; + } + d->m_saveTimer.start(); +} + +RuntimeContext PackProfile::runtimeContext() +{ + return d->m_instance->runtimeContext(); +} + +QString PackProfile::componentsFilePath() const +{ + return FS::PathCombine(d->m_instance->instanceRoot(), "mmc-pack.json"); +} + +QString PackProfile::patchesPattern() const +{ + return FS::PathCombine(d->m_instance->instanceRoot(), "patches", "%1.json"); +} + +QString PackProfile::patchFilePathForUid(const QString& uid) const +{ + return patchesPattern().arg(uid); +} + +bool PackProfile::save_internal() +{ + qDebug() << d->m_instance->name() << "|" << "Component list save performed now"; + auto filename = componentsFilePath(); + if (savePackProfile(filename, d->components)) { + d->dirty = false; + return true; + } + return false; +} + +PackProfile::Result PackProfile::load() +{ + auto filename = componentsFilePath(); + + // load the new component list and swap it with the current one... + ComponentContainer newComponents; + if (auto result = loadPackProfile(this, filename, patchesPattern(), newComponents); !result) { + qCritical() << d->m_instance->name() << "|" << "Failed to load the component config"; + return result; + } + // FIXME: actually use fine-grained updates, not this... + beginResetModel(); + // disconnect all the old components + for (auto component : d->components) { + disconnect(component.get(), &Component::dataChanged, this, &PackProfile::componentDataChanged); + } + d->components.clear(); + d->componentIndex.clear(); + for (auto component : newComponents) { + if (d->componentIndex.contains(component->m_uid)) { + qWarning() << d->m_instance->name() << "|" << "Ignoring duplicate component entry" << component->m_uid; + continue; + } + connect(component.get(), &Component::dataChanged, this, &PackProfile::componentDataChanged); + d->components.append(component); + d->componentIndex[component->m_uid] = component; + } + endResetModel(); + d->loaded = true; + return Result::Success(); +} + +PackProfile::Result PackProfile::reload(Net::Mode netmode) +{ + // Do not reload when the update/resolve task is running. It is in control. + if (d->m_updateTask) { + if (d->m_updateTask->netMode() == netmode) { + return Result::Success(); + } + + // https://github.com/PrismLauncher/PrismLauncher/issues/5209 + // FIXME: HACK HACK HACK + disconnect(d->m_updateTask.get(), &ComponentUpdateTask::aborted, nullptr, nullptr); + d->m_updateTask->abort(); + d->m_updateTask.reset(); + } + + // flush any scheduled saves to not lose state + saveNow(); + + // FIXME: differentiate when a reapply is required by propagating state from components + invalidateLaunchProfile(); + + if (auto result = load(); !result) { + return result; + } + resolve(netmode); + return Result::Success(); +} + +Task::Ptr PackProfile::getCurrentTask() +{ + return d->m_updateTask; +} + +void PackProfile::resolve(Net::Mode netmode) +{ + auto updateTask = new ComponentUpdateTask(ComponentUpdateTask::Mode::Resolution, netmode, this); + d->m_updateTask.reset(updateTask); + connect(updateTask, &ComponentUpdateTask::succeeded, this, &PackProfile::updateSucceeded); + connect(updateTask, &ComponentUpdateTask::failed, this, &PackProfile::updateFailed); + connect(updateTask, &ComponentUpdateTask::aborted, this, [this] { updateFailed(tr("Aborted")); }); + d->m_updateTask->start(); +} + +void PackProfile::updateSucceeded() +{ + qCDebug(instanceProfileC) << d->m_instance->name() << "|" << "Component list update/resolve task succeeded"; + d->m_updateTask.reset(); + invalidateLaunchProfile(); +} + +void PackProfile::updateFailed(const QString& error) +{ + qCDebug(instanceProfileC) << d->m_instance->name() << "|" << "Component list update/resolve task failed. Reason:" << error; + d->m_updateTask.reset(); + invalidateLaunchProfile(); +} + +// END: save/load + +void PackProfile::appendComponent(ComponentPtr component) +{ + insertComponent(d->components.size(), component); +} + +void PackProfile::insertComponent(size_t index, ComponentPtr component) +{ + auto id = component->getID(); + if (id.isEmpty()) { + qCWarning(instanceProfileC) << d->m_instance->name() << "|" << "Attempt to add a component with empty ID!"; + return; + } + if (d->componentIndex.contains(id)) { + qCWarning(instanceProfileC) << d->m_instance->name() << "|" << "Attempt to add a component that is already present!"; + return; + } + beginInsertRows(QModelIndex(), static_cast(index), static_cast(index)); + d->components.insert(index, component); + d->componentIndex[id] = component; + endInsertRows(); + connect(component.get(), &Component::dataChanged, this, &PackProfile::componentDataChanged); + scheduleSave(); +} + +void PackProfile::componentDataChanged() +{ + auto objPtr = qobject_cast(sender()); + if (!objPtr) { + qCWarning(instanceProfileC) << d->m_instance->name() << "|" << "PackProfile got dataChanged signal from a non-Component!"; + return; + } + if (objPtr->getID() == "net.minecraft") { + emit minecraftChanged(); + } + // figure out which one is it... in a seriously dumb way. + int index = 0; + for (auto component : d->components) { + if (component.get() == objPtr) { + emit dataChanged(createIndex(index, 0), createIndex(index, columnCount(QModelIndex()) - 1)); + scheduleSave(); + return; + } + index++; + } + qCWarning(instanceProfileC) << d->m_instance->name() << "|" + << "PackProfile got dataChanged signal from a Component which does not belong to it!"; +} + +bool PackProfile::remove(const int index) +{ + auto patch = getComponent(index); + if (!patch->isRemovable()) { + qCWarning(instanceProfileC) << d->m_instance->name() << "|" << "Patch" << patch->getID() << "is non-removable"; + return false; + } + + if (!removeComponent_internal(patch)) { + qCCritical(instanceProfileC) << d->m_instance->name() << "|" << "Patch" << patch->getID() << "could not be removed"; + return false; + } + + beginRemoveRows(QModelIndex(), index, index); + d->components.removeAt(index); + d->componentIndex.remove(patch->getID()); + endRemoveRows(); + invalidateLaunchProfile(); + scheduleSave(); + return true; +} + +bool PackProfile::remove(const QString& id) +{ + int i = 0; + for (auto patch : d->components) { + if (patch->getID() == id) { + return remove(i); + } + i++; + } + return false; +} + +bool PackProfile::customize(int index) +{ + auto patch = getComponent(index); + if (!patch->isCustomizable()) { + qCDebug(instanceProfileC) << d->m_instance->name() << "|" << "Patch" << patch->getID() << "is not customizable"; + return false; + } + if (!patch->customize()) { + qCCritical(instanceProfileC) << d->m_instance->name() << "|" << "Patch" << patch->getID() << "could not be customized"; + return false; + } + invalidateLaunchProfile(); + scheduleSave(); + return true; +} + +bool PackProfile::revertToBase(int index) +{ + auto patch = getComponent(index); + if (!patch->isRevertible()) { + qCDebug(instanceProfileC) << d->m_instance->name() << "|" << "Patch" << patch->getID() << "is not revertible"; + return false; + } + if (!patch->revert()) { + qCCritical(instanceProfileC) << d->m_instance->name() << "|" << "Patch" << patch->getID() << "could not be reverted"; + return false; + } + invalidateLaunchProfile(); + scheduleSave(); + return true; +} + +ComponentPtr PackProfile::getComponent(const QString& id) +{ + auto iter = d->componentIndex.find(id); + if (iter == d->componentIndex.end()) { + return nullptr; + } + return (*iter); +} + +ComponentPtr PackProfile::getComponent(size_t index) +{ + if (index >= static_cast(d->components.size())) { + return nullptr; + } + return d->components[index]; +} + +QVariant PackProfile::data(const QModelIndex& index, int role) const +{ + if (!index.isValid()) + return QVariant(); + + int row = index.row(); + int column = index.column(); + + if (row < 0 || row >= d->components.size()) + return QVariant(); + + auto patch = d->components.at(row); + + switch (role) { + case Qt::CheckStateRole: { + if (column == NameColumn) + return patch->isEnabled() ? Qt::Checked : Qt::Unchecked; + return QVariant(); + } + case Qt::DisplayRole: { + switch (column) { + case NameColumn: + return patch->getName(); + case VersionColumn: { + if (patch->isCustom()) { + return QString("%1 (Custom)").arg(patch->getVersion()); + } else { + return patch->getVersion(); + } + } + default: + return QVariant(); + } + } + case Qt::DecorationRole: { + if (column == NameColumn) { + auto severity = patch->getProblemSeverity(); + switch (severity) { + case ProblemSeverity::Warning: + return "warning"; + case ProblemSeverity::Error: + return "error"; + default: + return QVariant(); + } + } + return QVariant(); + } + } + return QVariant(); +} + +bool PackProfile::setData(const QModelIndex& index, [[maybe_unused]] const QVariant& value, int role) +{ + if (!index.isValid() || index.row() < 0 || index.row() >= rowCount(index.parent())) { + return false; + } + + if (role == Qt::CheckStateRole) { + auto component = d->components[index.row()]; + if (component->setEnabled(!component->isEnabled())) { + return true; + } + } + return false; +} + +QVariant PackProfile::headerData(int section, Qt::Orientation orientation, int role) const +{ + if (orientation == Qt::Horizontal) { + if (role == Qt::DisplayRole) { + switch (section) { + case NameColumn: + return tr("Name"); + case VersionColumn: + return tr("Version"); + default: + return QVariant(); + } + } + } + return QVariant(); +} + +// FIXME: zero precision mess +Qt::ItemFlags PackProfile::flags(const QModelIndex& index) const +{ + if (!index.isValid()) { + return Qt::NoItemFlags; + } + + Qt::ItemFlags outFlags = Qt::ItemIsSelectable | Qt::ItemIsEnabled; + + int row = index.row(); + + if (row < 0 || row >= d->components.size()) { + return Qt::NoItemFlags; + } + + auto patch = d->components.at(row); + // TODO: this will need fine-tuning later... + if (patch->canBeDisabled() && !d->interactionDisabled) { + outFlags |= Qt::ItemIsUserCheckable; + } + return outFlags; +} + +int PackProfile::rowCount(const QModelIndex& parent) const +{ + return parent.isValid() ? 0 : d->components.size(); +} + +int PackProfile::columnCount(const QModelIndex& parent) const +{ + return parent.isValid() ? 0 : NUM_COLUMNS; +} + +void PackProfile::move(const int index, const MoveDirection direction) +{ + int theirIndex; + if (direction == MoveUp) { + theirIndex = index - 1; + } else { + theirIndex = index + 1; + } + + if (index < 0 || index >= d->components.size()) + return; + if (theirIndex >= rowCount()) + theirIndex = rowCount() - 1; + if (theirIndex == -1) + theirIndex = rowCount() - 1; + if (index == theirIndex) + return; + int togap = theirIndex > index ? theirIndex + 1 : theirIndex; + + auto from = getComponent(index); + auto to = getComponent(theirIndex); + + if (!from || !to || !to->isMoveable() || !from->isMoveable()) { + return; + } + beginMoveRows(QModelIndex(), index, index, QModelIndex(), togap); + d->components.swapItemsAt(index, theirIndex); + endMoveRows(); + invalidateLaunchProfile(); + scheduleSave(); +} + +void PackProfile::invalidateLaunchProfile() +{ + d->m_profile.reset(); +} + +void PackProfile::installJarMods(QStringList selectedFiles) +{ + // FIXME: get rid of _internal + installJarMods_internal(selectedFiles); +} + +void PackProfile::installCustomJar(QString selectedFile) +{ + // FIXME: get rid of _internal + installCustomJar_internal(selectedFile); +} + +bool PackProfile::installComponents(QStringList selectedFiles) +{ + const QString patchDir = FS::PathCombine(d->m_instance->instanceRoot(), "patches"); + if (!FS::ensureFolderPathExists(patchDir)) + return false; + + bool result = true; + for (const QString& source : selectedFiles) { + const QFileInfo sourceInfo(source); + + auto versionFile = ProfileUtils::parseJsonFile(sourceInfo, false); + const QString target = FS::PathCombine(patchDir, versionFile->uid + ".json"); + + if (!QFile::copy(source, target)) { + qCWarning(instanceProfileC) << d->m_instance->name() << "|" << "Component" << source << "could not be copied to target" + << target; + result = false; + continue; + } + + appendComponent(makeShared(this, versionFile->uid, versionFile)); + } + + scheduleSave(); + invalidateLaunchProfile(); + + return result; +} + +void PackProfile::installAgents(QStringList selectedFiles) +{ + // FIXME: get rid of _internal + installAgents_internal(selectedFiles); +} + +bool PackProfile::installEmpty(const QString& uid, const QString& name) +{ + QString patchDir = FS::PathCombine(d->m_instance->instanceRoot(), "patches"); + if (!FS::ensureFolderPathExists(patchDir)) { + return false; + } + auto f = std::make_shared(); + f->name = name; + f->uid = uid; + f->version = "1"; + QString patchFileName = FS::PathCombine(patchDir, uid + ".json"); + QFile file(patchFileName); + if (!file.open(QFile::WriteOnly)) { + qCCritical(instanceProfileC) << d->m_instance->name() << "|" << "Error opening" << file.fileName() + << "for reading:" << file.errorString(); + return false; + } + file.write(OneSixVersionFormat::versionFileToJson(f).toJson()); + file.close(); + + appendComponent(makeShared(this, f->uid, f)); + scheduleSave(); + invalidateLaunchProfile(); + return true; +} + +bool PackProfile::removeComponent_internal(ComponentPtr patch) +{ + bool ok = true; + // first, remove the patch file. this ensures it's not used anymore + auto fileName = patch->getFilename(); + if (fileName.size()) { + QFile patchFile(fileName); + if (patchFile.exists() && !patchFile.remove()) { + qCCritical(instanceProfileC) << d->m_instance->name() << "|" << "File" << fileName + << "could not be removed because:" << patchFile.errorString(); + return false; + } + } + + // FIXME: we need a generic way of removing local resources, not just jar mods... + auto preRemoveJarMod = [this](LibraryPtr jarMod) -> bool { + if (!jarMod->isLocal()) { + return true; + } + QStringList jar, temp1, temp2, temp3; + jarMod->getApplicableFiles(d->m_instance->runtimeContext(), jar, temp1, temp2, temp3, d->m_instance->jarmodsPath().absolutePath()); + QFileInfo finfo(jar[0]); + if (finfo.exists()) { + QFile jarModFile(jar[0]); + if (!jarModFile.remove()) { + qCCritical(instanceProfileC) << d->m_instance->name() << "|" << "File" << jar[0] + << "could not be removed because:" << jarModFile.errorString(); + return false; + } + return true; + } + return true; + }; + + auto vFile = patch->getVersionFile(); + if (vFile) { + auto& jarMods = vFile->jarMods; + for (auto& jarmod : jarMods) { + ok &= preRemoveJarMod(jarmod); + } + } + return ok; +} + +bool PackProfile::installJarMods_internal(QStringList filepaths) +{ + QString patchDir = FS::PathCombine(d->m_instance->instanceRoot(), "patches"); + if (!FS::ensureFolderPathExists(patchDir)) { + return false; + } + + if (!FS::ensureFolderPathExists(d->m_instance->jarModsDir())) { + return false; + } + + for (auto filepath : filepaths) { + QFileInfo sourceInfo(filepath); + QString id = QUuid::createUuid().toString(QUuid::WithoutBraces); + QString target_filename = id + ".jar"; + QString target_id = "custom.jarmod." + id; + QString target_name = sourceInfo.completeBaseName() + " (jar mod)"; + QString finalPath = FS::PathCombine(d->m_instance->jarModsDir(), target_filename); + + QFileInfo targetInfo(finalPath); + Q_ASSERT(!targetInfo.exists()); + + if (!QFile::copy(sourceInfo.absoluteFilePath(), QFileInfo(finalPath).absoluteFilePath())) { + return false; + } + + auto f = std::make_shared(); + auto jarMod = std::make_shared(); + jarMod->setRawName(GradleSpecifier("custom.jarmods:" + id + ":1")); + jarMod->setFilename(target_filename); + jarMod->setDisplayName(sourceInfo.completeBaseName()); + jarMod->setHint("local"); + f->jarMods.append(jarMod); + f->name = target_name; + f->uid = target_id; + QString patchFileName = FS::PathCombine(patchDir, target_id + ".json"); + + QFile file(patchFileName); + if (!file.open(QFile::WriteOnly)) { + qCCritical(instanceProfileC) << d->m_instance->name() << "|" << "Error opening" << file.fileName() + << "for reading:" << file.errorString(); + return false; + } + file.write(OneSixVersionFormat::versionFileToJson(f).toJson()); + file.close(); + + appendComponent(makeShared(this, f->uid, f)); + } + scheduleSave(); + invalidateLaunchProfile(); + return true; +} + +bool PackProfile::installCustomJar_internal(QString filepath) +{ + QString patchDir = FS::PathCombine(d->m_instance->instanceRoot(), "patches"); + if (!FS::ensureFolderPathExists(patchDir)) { + return false; + } + + QString libDir = d->m_instance->getLocalLibraryPath(); + if (!FS::ensureFolderPathExists(libDir)) { + return false; + } + + auto specifier = GradleSpecifier("custom:customjar:1"); + QFileInfo sourceInfo(filepath); + QString target_filename = specifier.getFileName(); + QString target_id = specifier.artifactId(); + QString target_name = sourceInfo.completeBaseName() + " (custom jar)"; + QString finalPath = FS::PathCombine(libDir, target_filename); + + QFileInfo jarInfo(finalPath); + if (jarInfo.exists()) { + if (!FS::deletePath(finalPath)) { + return false; + } + } + if (!QFile::copy(filepath, finalPath)) { + return false; + } + + auto f = std::make_shared(); + auto jarMod = std::make_shared(); + jarMod->setRawName(specifier); + jarMod->setDisplayName(sourceInfo.completeBaseName()); + jarMod->setHint("local"); + f->mainJar = jarMod; + f->name = target_name; + f->uid = target_id; + QString patchFileName = FS::PathCombine(patchDir, target_id + ".json"); + + QFile file(patchFileName); + if (!file.open(QFile::WriteOnly)) { + qCCritical(instanceProfileC) << d->m_instance->name() << "|" << "Error opening" << file.fileName() + << "for reading:" << file.errorString(); + return false; + } + file.write(OneSixVersionFormat::versionFileToJson(f).toJson()); + file.close(); + + appendComponent(makeShared(this, f->uid, f)); + + scheduleSave(); + invalidateLaunchProfile(); + return true; +} + +bool PackProfile::installAgents_internal(QStringList filepaths) +{ + // FIXME code duplication + const QString patchDir = FS::PathCombine(d->m_instance->instanceRoot(), "patches"); + if (!FS::ensureFolderPathExists(patchDir)) + return false; + + const QString libDir = d->m_instance->getLocalLibraryPath(); + if (!FS::ensureFolderPathExists(libDir)) + return false; + + for (const QString& source : filepaths) { + const QFileInfo sourceInfo(source); + const QString id = QUuid::createUuid().toString(QUuid::WithoutBraces); + const QString targetBaseName = id + ".jar"; + const QString targetId = "custom.agent." + id; + const QString targetName = sourceInfo.completeBaseName() + " (agent)"; + const QString target = FS::PathCombine(d->m_instance->getLocalLibraryPath(), targetBaseName); + + const QFileInfo targetInfo(target); + Q_ASSERT(!targetInfo.exists()); + + if (!QFile::copy(source, target)) + return false; + + auto versionFile = std::make_shared(); + + auto agent = std::make_shared(); + + agent->setRawName("custom.agents:" + id + ":1"); + agent->setFilename(targetBaseName); + agent->setDisplayName(sourceInfo.completeBaseName()); + agent->setHint("local"); + + versionFile->agents.append(Agent{agent, QString()}); + + versionFile->name = targetName; + versionFile->uid = targetId; + + QFile patchFile(FS::PathCombine(patchDir, targetId + ".json")); + + if (!patchFile.open(QFile::WriteOnly)) { + qCCritical(instanceProfileC) << d->m_instance->name() << "|" << "Error opening" << patchFile.fileName() + << "for reading:" << patchFile.errorString(); + return false; + } + + patchFile.write(OneSixVersionFormat::versionFileToJson(versionFile).toJson()); + patchFile.close(); + + appendComponent(makeShared(this, versionFile->uid, versionFile)); + } + + scheduleSave(); + invalidateLaunchProfile(); + + return true; +} + +std::shared_ptr PackProfile::getProfile() const +{ + if (!d->m_profile) { + try { + auto profile = std::make_shared(); + for (auto file : d->components) { + qCDebug(instanceProfileC) << d->m_instance->name() << "|" << "Applying" << file->getID() + << (file->getProblemSeverity() == ProblemSeverity::Error ? "ERROR" : "GOOD"); + file->applyTo(profile.get()); + } + d->m_profile = profile; + } catch (const Exception& error) { + qCWarning(instanceProfileC) << d->m_instance->name() << "|" << "Couldn't apply profile patches because:" << error.cause(); + } + } + return d->m_profile; +} + +bool PackProfile::setComponentVersion(const QString& uid, const QString& version, bool important) +{ + auto iter = d->componentIndex.find(uid); + if (iter != d->componentIndex.end()) { + ComponentPtr component = *iter; + // set existing + if (component->revert()) { + // set new version + auto oldVersion = component->getVersion(); + component->setVersion(version); + component->setImportant(important); + + if (important) { + component->setUpdateAction(UpdateAction{ UpdateActionImportantChanged{ oldVersion } }); + resolve(Net::Mode::Online); + } + + return true; + } + return false; + } else { + // add new + auto component = makeShared(this, uid); + component->m_version = version; + component->m_important = important; + appendComponent(component); + return true; + } +} + +QString PackProfile::getComponentVersion(const QString& uid) const +{ + const auto iter = d->componentIndex.find(uid); + if (iter != d->componentIndex.end()) { + return (*iter)->getVersion(); + } + return QString(); +} + +void PackProfile::disableInteraction(bool disable) +{ + if (d->interactionDisabled != disable) { + d->interactionDisabled = disable; + auto size = d->components.size(); + if (size) { + emit dataChanged(index(0), index(size - 1)); + } + } +} + +std::optional PackProfile::getModLoaders() +{ + ModPlatform::ModLoaderTypes result; + bool has_any_loader = false; + + QMapIterator i(Component::KNOWN_MODLOADERS); + + while (i.hasNext()) { + i.next(); + if (auto c = getComponent(i.key()); c != nullptr && c->isEnabled()) { + result |= i.value().type; + has_any_loader = true; + } + } + + if (!has_any_loader) + return {}; + return result; +} + +std::optional PackProfile::getSupportedModLoaders() +{ + auto loadersOpt = getModLoaders(); + if (!loadersOpt.has_value()) + return loadersOpt; + auto loaders = loadersOpt.value(); + // TODO: remove this or add version condition once Quilt drops official Fabric support + if (loaders & ModPlatform::Quilt) + loaders |= ModPlatform::Fabric; + if (getComponentVersion("net.minecraft") == "1.20.1" && (loaders & ModPlatform::NeoForge)) + loaders |= ModPlatform::Forge; + return loaders; +} + +QList PackProfile::getModLoadersList() +{ + QList result; + for (auto c : d->components) { + if (c->isEnabled() && Component::KNOWN_MODLOADERS.contains(c->getID())) { + result.append(Component::KNOWN_MODLOADERS[c->getID()].type); + } + } + + // TODO: remove this or add version condition once Quilt drops official Fabric support + if (result.contains(ModPlatform::Quilt) && !result.contains(ModPlatform::Fabric)) { + result.append(ModPlatform::Fabric); + } + if (getComponentVersion("net.minecraft") == "1.20.1" && result.contains(ModPlatform::NeoForge) && + !result.contains(ModPlatform::Forge)) { + result.append(ModPlatform::Forge); + } + return result; +} diff --git a/launcher/minecraft/PackProfile.h b/launcher/minecraft/PackProfile.h new file mode 100644 index 0000000..70dd045 --- /dev/null +++ b/launcher/minecraft/PackProfile.h @@ -0,0 +1,193 @@ +// SPDX-FileCopyrightText: 2022-2023 Sefa Eyeoglu +// +// SPDX-License-Identifier: GPL-3.0-only AND Apache-2.0 + +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022-2023 Sefa Eyeoglu + * Copyright (C) 2022 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +#include +#include +#include +#include + +#include "Component.h" +#include "LaunchProfile.h" +#include "modplatform/ModIndex.h" +#include "net/Mode.h" + +class MinecraftInstance; +struct PackProfileData; +class ComponentUpdateTask; + +class PackProfile : public QAbstractListModel { + Q_OBJECT + friend ComponentUpdateTask; + + public: + enum Columns { NameColumn = 0, VersionColumn, NUM_COLUMNS }; + + struct Result { + bool success; + QString error; + + // Implicit conversion to bool + operator bool() const { return success; } + + // Factory methods for convenience + static Result Success() { return { true, "" }; } + + static Result Error(const QString& errorMessage) { return { false, errorMessage }; } + }; + + explicit PackProfile(MinecraftInstance* instance); + virtual ~PackProfile(); + + virtual QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + virtual bool setData(const QModelIndex& index, const QVariant& value, int role = Qt::EditRole) override; + virtual QVariant headerData(int section, Qt::Orientation orientation, int role) const override; + virtual int rowCount(const QModelIndex& parent = QModelIndex()) const override; + virtual int columnCount(const QModelIndex& parent) const override; + virtual Qt::ItemFlags flags(const QModelIndex& index) const override; + + /// call this to explicitly mark the component list as loaded - this is used to build a new component list from scratch. + void buildingFromScratch(); + + /// install more jar mods + void installJarMods(QStringList selectedFiles); + + /// install a jar/zip as a replacement for the main jar + void installCustomJar(QString selectedFile); + + /// install MMC/Prism component files + bool installComponents(QStringList selectedFiles); + + /// install Java agent files + void installAgents(QStringList selectedFiles); + + enum MoveDirection { MoveUp, MoveDown }; + /// move component file # up or down the list + void move(int index, MoveDirection direction); + + /// remove component file # - including files/records + bool remove(int index); + + /// remove component file by id - including files/records + bool remove(const QString& id); + + bool customize(int index); + + bool revertToBase(int index); + + /// reload the list, reload all components, resolve dependencies + Result reload(Net::Mode netmode); + + // reload all components, resolve dependencies + void resolve(Net::Mode netmode); + + /// get current running task... + Task::Ptr getCurrentTask(); + + std::shared_ptr getProfile() const; + + // NOTE: used ONLY by MinecraftInstance to provide legacy version mappings from instance config + void setOldConfigVersion(const QString& uid, const QString& version); + + QString getComponentVersion(const QString& uid) const; + + bool setComponentVersion(const QString& uid, const QString& version, bool important = false); + + bool installEmpty(const QString& uid, const QString& name); + + QString patchFilePathForUid(const QString& uid) const; + + /// if there is a save scheduled, do it now. + void saveNow(); + + /// helper method, returns RuntimeContext of instance + RuntimeContext runtimeContext(); + + signals: + void minecraftChanged(); + + public: + /// get the profile component by id + ComponentPtr getComponent(const QString& id); + + /// get the profile component by index + ComponentPtr getComponent(size_t index); + + /// Add the component to the internal list of patches + // todo(merged): is this the best approach + void appendComponent(ComponentPtr component); + + std::optional getModLoaders(); + // this returns aditional loaders(Quilt supports fabric and NeoForge supports Forge) + std::optional getSupportedModLoaders(); + QList getModLoadersList(); + + /// apply the component patches. Catches all the errors and returns true/false for success/failure + void invalidateLaunchProfile(); + + private: + void scheduleSave(); + bool saveIsScheduled() const; + + /// insert component so that its index is ideally the specified one (returns real index) + void insertComponent(size_t index, ComponentPtr component); + + QString componentsFilePath() const; + QString patchesPattern() const; + + private slots: + bool save_internal(); + void updateSucceeded(); + void updateFailed(const QString& error); + void componentDataChanged(); + void disableInteraction(bool disable); + + private: + Result load(); + bool installJarMods_internal(QStringList filepaths); + bool installCustomJar_internal(QString filepath); + bool installAgents_internal(QStringList filepaths); + bool removeComponent_internal(ComponentPtr patch); + + private: /* data */ + std::unique_ptr d; +}; diff --git a/launcher/minecraft/PackProfile_p.h b/launcher/minecraft/PackProfile_p.h new file mode 100644 index 0000000..feb8259 --- /dev/null +++ b/launcher/minecraft/PackProfile_p.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include +#include +#include "Component.h" +#include "tasks/Task.h" + +class MinecraftInstance; +using ComponentContainer = QList; +using ComponentIndex = QMap; + +struct PackProfileData { + // the instance this belongs to + MinecraftInstance* m_instance; + + // the launch profile (volatile, temporary thing created on demand) + std::shared_ptr m_profile; + + // persistent list of components and related machinery + ComponentContainer components; + ComponentIndex componentIndex; + bool dirty = false; + QTimer m_saveTimer; + shared_qobject_ptr m_updateTask; + bool loaded = false; + bool interactionDisabled = true; +}; diff --git a/launcher/minecraft/ParseUtils.cpp b/launcher/minecraft/ParseUtils.cpp new file mode 100644 index 0000000..fabc57a --- /dev/null +++ b/launcher/minecraft/ParseUtils.cpp @@ -0,0 +1,34 @@ +#include "ParseUtils.h" +#include +#include +#include +#include + +QDateTime timeFromS3Time(QString str) +{ + return QDateTime::fromString(str, Qt::ISODate); +} + +QString timeToS3Time(QDateTime time) +{ + // this all because Qt can't format timestamps right. + int offsetRaw = time.offsetFromUtc(); + bool negative = offsetRaw < 0; + int offsetAbs = std::abs(offsetRaw); + + int offsetSeconds = offsetAbs % 60; + offsetAbs -= offsetSeconds; + + int offsetMinutes = offsetAbs % 3600; + offsetAbs -= offsetMinutes; + offsetMinutes /= 60; + + int offsetHours = offsetAbs / 3600; + + QString raw = time.toString("yyyy-MM-ddTHH:mm:ss"); + raw += (negative ? QChar('-') : QChar('+')); + raw += QString("%1").arg(offsetHours, 2, 10, QChar('0')); + raw += ":"; + raw += QString("%1").arg(offsetMinutes, 2, 10, QChar('0')); + return raw; +} diff --git a/launcher/minecraft/ParseUtils.h b/launcher/minecraft/ParseUtils.h new file mode 100644 index 0000000..c81d8f8 --- /dev/null +++ b/launcher/minecraft/ParseUtils.h @@ -0,0 +1,9 @@ +#pragma once +#include +#include + +/// take the timestamp used by S3 and turn it into QDateTime +QDateTime timeFromS3Time(QString str); + +/// take a timestamp and convert it into an S3 timestamp +QString timeToS3Time(QDateTime); diff --git a/launcher/minecraft/ProfileUtils.cpp b/launcher/minecraft/ProfileUtils.cpp new file mode 100644 index 0000000..ae63269 --- /dev/null +++ b/launcher/minecraft/ProfileUtils.cpp @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ProfileUtils.h" +#include +#include "Json.h" +#include "minecraft/OneSixVersionFormat.h" +#include "minecraft/VersionFilterData.h" + +#include +#include +#include + +namespace ProfileUtils { + +static const int currentOrderFileVersion = 1; + +bool readOverrideOrders(QString path, PatchOrder& order) +{ + QFile orderFile(path); + if (!orderFile.exists()) { + qWarning() << "Order file doesn't exist. Ignoring."; + return false; + } + if (!orderFile.open(QFile::ReadOnly)) { + qCritical() << "Couldn't open" << orderFile.fileName() << "for reading:" << orderFile.errorString(); + qWarning() << "Ignoring overridden order"; + return false; + } + + // and it's valid JSON + QJsonParseError error; + QJsonDocument doc = QJsonDocument::fromJson(orderFile.readAll(), &error); + if (error.error != QJsonParseError::NoError) { + qCritical() << "Couldn't parse" << orderFile.fileName() << ":" << error.errorString(); + qWarning() << "Ignoring overridden order"; + return false; + } + + // and then read it and process it if all above is true. + try { + auto obj = Json::requireObject(doc); + // check order file version. + auto version = Json::requireInteger(obj.value("version")); + if (version != currentOrderFileVersion) { + throw JSONValidationError(QObject::tr("Invalid order file version, expected %1").arg(currentOrderFileVersion)); + } + auto orderArray = Json::requireArray(obj.value("order")); + for (auto item : orderArray) { + order.append(Json::requireString(item)); + } + } catch ([[maybe_unused]] const JSONValidationError& err) { + qCritical() << "Couldn't parse" << orderFile.fileName() << ": bad file format"; + qWarning() << "Ignoring overridden order"; + order.clear(); + return false; + } + return true; +} + +static VersionFilePtr createErrorVersionFile(QString fileId, QString filepath, QString error) +{ + auto outError = std::make_shared(); + outError->uid = outError->name = fileId; + // outError->filename = filepath; + outError->addProblem(ProblemSeverity::Error, error); + return outError; +} + +static VersionFilePtr guardedParseJson(const QJsonDocument& doc, const QString& fileId, const QString& filepath, const bool& requireOrder) +{ + try { + return OneSixVersionFormat::versionFileFromJson(doc, filepath, requireOrder); + } catch (const Exception& e) { + return createErrorVersionFile(fileId, filepath, e.cause()); + } +} + +VersionFilePtr parseJsonFile(const QFileInfo& fileInfo, const bool requireOrder) +{ + QFile file(fileInfo.absoluteFilePath()); + if (!file.open(QFile::ReadOnly)) { + auto errorStr = QObject::tr("Unable to open the version file %1: %2.").arg(fileInfo.fileName(), file.errorString()); + return createErrorVersionFile(fileInfo.completeBaseName(), fileInfo.absoluteFilePath(), errorStr); + } + QJsonParseError error; + auto data = file.readAll(); + QJsonDocument doc = QJsonDocument::fromJson(data, &error); + file.close(); + if (error.error != QJsonParseError::NoError) { + int line = 1; + int column = 0; + for (int i = 0; i < error.offset; i++) { + if (data[i] == '\n') { + line++; + column = 0; + continue; + } + column++; + } + auto errorStr = QObject::tr("Unable to process the version file %1: %2 at line %3 column %4.") + .arg(fileInfo.fileName(), error.errorString()) + .arg(line) + .arg(column); + return createErrorVersionFile(fileInfo.completeBaseName(), fileInfo.absoluteFilePath(), errorStr); + } + return guardedParseJson(doc, fileInfo.completeBaseName(), fileInfo.absoluteFilePath(), requireOrder); +} + +bool saveJsonFile(const QJsonDocument& doc, const QString& filename) +{ + auto data = doc.toJson(); + QSaveFile jsonFile(filename); + if (!jsonFile.open(QIODevice::WriteOnly)) { + qWarning() << "Couldn't open" << filename << "for writing:" << jsonFile.errorString(); + jsonFile.cancelWriting(); + return false; + } + jsonFile.write(data); + if (!jsonFile.commit()) { + qWarning() << "Couldn't save" << filename << "error:" << jsonFile.errorString(); + return false; + } + return true; +} + +void removeLwjglFromPatch(VersionFilePtr patch) +{ + auto filter = [](QList& libs) { + QList filteredLibs; + for (auto lib : libs) { + if (!g_VersionFilterData.lwjglWhitelist.contains(lib->artifactPrefix())) { + filteredLibs.append(lib); + } + } + libs = filteredLibs; + }; + filter(patch->libraries); +} +} // namespace ProfileUtils diff --git a/launcher/minecraft/ProfileUtils.h b/launcher/minecraft/ProfileUtils.h new file mode 100644 index 0000000..11e4448 --- /dev/null +++ b/launcher/minecraft/ProfileUtils.h @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once +#include "Library.h" +#include "VersionFile.h" + +namespace ProfileUtils { +using PatchOrder = QStringList; + +/// Read and parse a OneSix format order file +bool readOverrideOrders(QString path, PatchOrder& order); + +/// Write a OneSix format order file +bool writeOverrideOrders(QString path, const PatchOrder& order); + +/// Parse a version file in JSON format +VersionFilePtr parseJsonFile(const QFileInfo& fileInfo, bool requireOrder); + +/// Save a JSON file (in any format) +bool saveJsonFile(const QJsonDocument& doc, const QString& filename); + +/// Remove LWJGL from a patch file. This is applied to all Mojang-like profile files. +void removeLwjglFromPatch(VersionFilePtr patch); + +} // namespace ProfileUtils diff --git a/launcher/minecraft/Rule.cpp b/launcher/minecraft/Rule.cpp new file mode 100644 index 0000000..606776e --- /dev/null +++ b/launcher/minecraft/Rule.cpp @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2025 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include + +#include "Rule.h" + +Rule Rule::fromJson(const QJsonObject& object) +{ + Rule result; + + if (object["action"] == "allow") + result.m_action = Allow; + else if (object["action"] == "disallow") + result.m_action = Disallow; + + if (auto os = object["os"]; os.isObject()) { + if (auto name = os["name"].toString(); !name.isNull()) { + result.m_os = OS{ + name, + os["version"].toString(), + }; + } + } + + return result; +} + +QJsonObject Rule::toJson() +{ + QJsonObject result; + + if (m_action == Allow) + result["action"] = "allow"; + else if (m_action == Disallow) + result["action"] = "disallow"; + + if (m_os.has_value()) { + QJsonObject os; + + os["name"] = m_os->name; + + if (!m_os->version.isEmpty()) + os["version"] = m_os->version; + + result["os"] = os; + } + + return result; +} + +Rule::Action Rule::apply(const RuntimeContext& runtimeContext) +{ + if (m_os.has_value() && !runtimeContext.classifierMatches(m_os->name)) + return Defer; + + return m_action; +} diff --git a/launcher/minecraft/Rule.h b/launcher/minecraft/Rule.h new file mode 100644 index 0000000..b0b689f --- /dev/null +++ b/launcher/minecraft/Rule.h @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2025 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include "RuntimeContext.h" + +class Library; + +class Rule { + public: + enum Action { Allow, Disallow, Defer }; + + static Rule fromJson(const QJsonObject& json); + QJsonObject toJson(); + + Action apply(const RuntimeContext& runtimeContext); + + private: + struct OS { + QString name; + // FIXME: unsupported + // retained to avoid information being lost from files + QString version; + }; + + Action m_action = Defer; + std::optional m_os; +}; diff --git a/launcher/minecraft/ShortcutUtils.cpp b/launcher/minecraft/ShortcutUtils.cpp new file mode 100644 index 0000000..b719e31 --- /dev/null +++ b/launcher/minecraft/ShortcutUtils.cpp @@ -0,0 +1,248 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2023 TheKodeToad + * Copyright (C) 2025 Yihe Li + * + * parent program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * parent program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with parent program. If not, see . + * + * parent file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use parent file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ShortcutUtils.h" + +#include "FileSystem.h" + +#include +#include + +#include +#include +#include + +namespace ShortcutUtils { + +bool createInstanceShortcut(const Shortcut& shortcut, const QString& filePath) +{ + if (!shortcut.instance) + return false; + + QString appPath = QApplication::applicationFilePath(); + auto icon = APPLICATION->icons()->icon(shortcut.iconKey.isEmpty() ? shortcut.instance->iconKey() : shortcut.iconKey); + if (icon == nullptr) { + icon = APPLICATION->icons()->icon("grass"); + } + QString iconPath; + QStringList args; +#if defined(Q_OS_MACOS) + if (appPath.startsWith("/private/var/")) { + QMessageBox::critical(shortcut.parent, QObject::tr("Create Shortcut"), + QObject::tr("The launcher is in the folder it was extracted from, therefore it cannot create shortcuts.")); + return false; + } + + iconPath = FS::PathCombine(shortcut.instance->instanceRoot(), "Icon.icns"); + + QFile iconFile(iconPath); + if (!iconFile.open(QFile::WriteOnly)) { + QMessageBox::critical(shortcut.parent, QObject::tr("Create Shortcut"), QObject::tr("Failed to create icon for application: %1").arg(iconFile.errorString())); + return false; + } + + QIcon iconObj = icon->icon(); + bool success = iconObj.pixmap(1024, 1024).save(iconPath, "ICNS"); + iconFile.close(); + + if (!success) { + iconFile.remove(); + QMessageBox::critical(shortcut.parent, QObject::tr("Create Shortcut"), QObject::tr("Failed to create icon for application.")); + return false; + } +#elif defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) || defined(Q_OS_OPENBSD) + if (appPath.startsWith("/tmp/.mount_")) { + // AppImage! + appPath = QProcessEnvironment::systemEnvironment().value(QStringLiteral("APPIMAGE")); + if (appPath.isEmpty()) { + QMessageBox::critical( + shortcut.parent, QObject::tr("Create Shortcut"), + QObject::tr("Launcher is running as misconfigured AppImage? ($APPIMAGE environment variable is missing)")); + } else if (appPath.endsWith("/")) { + appPath.chop(1); + } + } + + iconPath = FS::PathCombine(shortcut.instance->instanceRoot(), "icon.png"); + + QFile iconFile(iconPath); + if (!iconFile.open(QFile::WriteOnly)) { + QMessageBox::critical(shortcut.parent, QObject::tr("Create Shortcut"), QObject::tr("Failed to create icon for shortcut: %1").arg(iconFile.errorString())); + return false; + } + bool success = icon->icon().pixmap(64, 64).save(&iconFile, "PNG"); + iconFile.close(); + + if (!success) { + iconFile.remove(); + QMessageBox::critical(shortcut.parent, QObject::tr("Create Shortcut"), QObject::tr("Failed to create icon for shortcut.")); + return false; + } + + if (DesktopServices::isFlatpak()) { + appPath = "flatpak"; + args.append({ "run", BuildConfig.LAUNCHER_APPID }); + } + +#elif defined(Q_OS_WIN) + iconPath = FS::PathCombine(shortcut.instance->instanceRoot(), "icon.ico"); + + // part of fix for weird bug involving the window icon being replaced + // dunno why it happens, but parent 2-line fix seems to be enough, so w/e + auto appIcon = APPLICATION->logo(); + + QFile iconFile(iconPath); + if (!iconFile.open(QFile::WriteOnly)) { + QMessageBox::critical(shortcut.parent, QObject::tr("Create Shortcut"), QObject::tr("Failed to create icon for shortcut: %1").arg(iconFile.errorString())); + return false; + } + bool success = icon->icon().pixmap(64, 64).save(&iconFile, "ICO"); + iconFile.close(); + + // restore original window icon + QGuiApplication::setWindowIcon(appIcon); + + if (!success) { + iconFile.remove(); + QMessageBox::critical(shortcut.parent, QObject::tr("Create Shortcut"), QObject::tr("Failed to create icon for shortcut.")); + return false; + } + +#else + QMessageBox::critical(shortcut.parent, QObject::tr("Create Shortcut"), QObject::tr("Not supported on your platform!")); + return false; +#endif + args.append({ "--launch", shortcut.instance->id() }); + args.append(shortcut.extraArgs); + + QString shortcutPath = FS::createShortcut(filePath, appPath, args, shortcut.name, iconPath); + if (shortcutPath.isEmpty()) { +#if not defined(Q_OS_MACOS) + iconFile.remove(); +#endif + QMessageBox::critical(shortcut.parent, QObject::tr("Create Shortcut"), + QObject::tr("Failed to create %1 shortcut!").arg(shortcut.targetString)); + return false; + } + + shortcut.instance->registerShortcut({ shortcut.name, shortcutPath, shortcut.target }); + return true; +} + +bool createInstanceShortcutOnDesktop(const Shortcut& shortcut) +{ + if (!shortcut.instance) + return false; + + QString desktopDir = FS::getDesktopDir(); + if (desktopDir.isEmpty()) { + QMessageBox::critical(shortcut.parent, QObject::tr("Create Shortcut"), QObject::tr("Couldn't find desktop?!")); + return false; + } + + QString shortcutFilePath = FS::PathCombine(desktopDir, FS::RemoveInvalidFilenameChars(shortcut.name)); + if (!createInstanceShortcut(shortcut, shortcutFilePath)) + return false; + QMessageBox::information(shortcut.parent, QObject::tr("Create Shortcut"), + QObject::tr("Created a shortcut to this %1 on your desktop!").arg(shortcut.targetString)); + return true; +} + +bool createInstanceShortcutInApplications(const Shortcut& shortcut) +{ + if (!shortcut.instance) + return false; + + QString applicationsDir = FS::getApplicationsDir(); + if (applicationsDir.isEmpty()) { + QMessageBox::critical(shortcut.parent, QObject::tr("Create Shortcut"), QObject::tr("Couldn't find applications folder?!")); + return false; + } + +#if defined(Q_OS_MACOS) || defined(Q_OS_WIN) + applicationsDir = FS::PathCombine(applicationsDir, BuildConfig.LAUNCHER_DISPLAYNAME + " Instances"); + + QDir applicationsDirQ(applicationsDir); + if (!applicationsDirQ.mkpath(".")) { + QMessageBox::critical(shortcut.parent, QObject::tr("Create Shortcut"), + QObject::tr("Failed to create instances folder in applications folder!")); + return false; + } +#endif + + QString shortcutFilePath = FS::PathCombine(applicationsDir, FS::RemoveInvalidFilenameChars(shortcut.name)); + if (!createInstanceShortcut(shortcut, shortcutFilePath)) + return false; + QMessageBox::information(shortcut.parent, QObject::tr("Create Shortcut"), + QObject::tr("Created a shortcut to this %1 in your applications folder!").arg(shortcut.targetString)); + return true; +} + +bool createInstanceShortcutInOther(const Shortcut& shortcut) +{ + if (!shortcut.instance) + return false; + + QString defaultedDir = FS::getDesktopDir(); +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) || defined(Q_OS_OPENBSD) + QString extension = ".desktop"; +#elif defined(Q_OS_WINDOWS) + QString extension = ".lnk"; +#else + QString extension = ""; +#endif + + QString shortcutFilePath = FS::PathCombine(defaultedDir, FS::RemoveInvalidFilenameChars(shortcut.name) + extension); + QFileDialog fileDialog; + // workaround to make sure the portal file dialog opens in the desktop directory + fileDialog.setDirectoryUrl(defaultedDir); + + shortcutFilePath = fileDialog.getSaveFileName(shortcut.parent, QObject::tr("Create Shortcut"), shortcutFilePath, + QObject::tr("Desktop Entries") + " (*" + extension + ")"); + if (shortcutFilePath.isEmpty()) + return false; // file dialog canceled by user + + if (shortcutFilePath.endsWith(extension)) + shortcutFilePath = shortcutFilePath.mid(0, shortcutFilePath.length() - extension.length()); + if (!createInstanceShortcut(shortcut, shortcutFilePath)) + return false; + QMessageBox::information(shortcut.parent, QObject::tr("Create Shortcut"), + QObject::tr("Created a shortcut to this %1!").arg(shortcut.targetString)); + return true; +} + +} // namespace ShortcutUtils diff --git a/launcher/minecraft/ShortcutUtils.h b/launcher/minecraft/ShortcutUtils.h new file mode 100644 index 0000000..5cf31f9 --- /dev/null +++ b/launcher/minecraft/ShortcutUtils.h @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2023 TheKodeToad + * Copyright (C) 2025 Yihe Li + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once +#include "Application.h" +#include "BaseInstance.h" + +#include +#include + +namespace ShortcutUtils { +/// A struct to hold parameters for creating a shortcut +struct Shortcut { + BaseInstance* instance; + QString name; + QString targetString; + QWidget* parent = nullptr; + QStringList extraArgs = {}; + QString iconKey = ""; + ShortcutTarget target; +}; + +/// Create an instance shortcut on the specified file path +bool createInstanceShortcut(const Shortcut& shortcut, const QString& filePath); + +/// Create an instance shortcut on the desktop +bool createInstanceShortcutOnDesktop(const Shortcut& shortcut); + +/// Create an instance shortcut in the Applications directory +bool createInstanceShortcutInApplications(const Shortcut& shortcut); + +/// Create an instance shortcut in other directories +bool createInstanceShortcutInOther(const Shortcut& shortcut); + +} // namespace ShortcutUtils diff --git a/launcher/minecraft/VanillaInstanceCreationTask.cpp b/launcher/minecraft/VanillaInstanceCreationTask.cpp new file mode 100644 index 0000000..017f850 --- /dev/null +++ b/launcher/minecraft/VanillaInstanceCreationTask.cpp @@ -0,0 +1,36 @@ +#include "VanillaInstanceCreationTask.h" + +#include + +#include "FileSystem.h" +#include "minecraft/MinecraftInstance.h" +#include "minecraft/PackProfile.h" +#include "settings/INISettingsObject.h" + +VanillaCreationTask::VanillaCreationTask(BaseVersion::Ptr version, QString loader, BaseVersion::Ptr loader_version) + : InstanceCreationTask() + , m_version(std::move(version)) + , m_using_loader(true) + , m_loader(std::move(loader)) + , m_loader_version(std::move(loader_version)) +{} + +std::unique_ptr VanillaCreationTask::createInstance() +{ + setStatus(tr("Creating instance from version %1").arg(m_version->name())); + + auto inst = std::make_unique(m_globalSettings, std::make_unique(FS::PathCombine(m_stagingPath, "instance.cfg")), + m_stagingPath); + SettingsObject::Lock lock(inst->settings()); + + auto components = inst->getPackProfile(); + components->buildingFromScratch(); + components->setComponentVersion("net.minecraft", m_version->descriptor(), true); + if (m_using_loader) + components->setComponentVersion(m_loader, m_loader_version->descriptor()); + + inst->setName(name()); + inst->setIconKey(m_instIcon); + + return inst; +} diff --git a/launcher/minecraft/VanillaInstanceCreationTask.h b/launcher/minecraft/VanillaInstanceCreationTask.h new file mode 100644 index 0000000..7015a4f --- /dev/null +++ b/launcher/minecraft/VanillaInstanceCreationTask.h @@ -0,0 +1,22 @@ +#pragma once + +#include "InstanceCreationTask.h" + +#include + +class VanillaCreationTask final : public InstanceCreationTask { + Q_OBJECT + public: + VanillaCreationTask(BaseVersion::Ptr version) : InstanceCreationTask(), m_version(std::move(version)) {} + VanillaCreationTask(BaseVersion::Ptr version, QString loader, BaseVersion::Ptr loader_version); + + std::unique_ptr createInstance() override; + + private: + // Version to update to / create of the instance. + BaseVersion::Ptr m_version; + + bool m_using_loader = false; + QString m_loader; + BaseVersion::Ptr m_loader_version; +}; diff --git a/launcher/minecraft/VersionFile.cpp b/launcher/minecraft/VersionFile.cpp new file mode 100644 index 0000000..8ee6112 --- /dev/null +++ b/launcher/minecraft/VersionFile.cpp @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include + +#include + +#include "ParseUtils.h" +#include "minecraft/Library.h" +#include "minecraft/PackProfile.h" +#include "minecraft/VersionFile.h" + +#include + +static bool isMinecraftVersion(const QString& uid) +{ + return uid == "net.minecraft"; +} + +void VersionFile::applyTo(LaunchProfile* profile, const RuntimeContext& runtimeContext) +{ + // Only real Minecraft can set those. Don't let anything override them. + if (isMinecraftVersion(uid)) { + profile->applyMinecraftVersion(version); + profile->applyMinecraftVersionType(type); + // HACK: ignore assets from other version files than Minecraft + // workaround for stupid assets issue caused by amazon: + // https://www.theregister.co.uk/2017/02/28/aws_is_awol_as_s3_goes_haywire/ + profile->applyMinecraftAssets(mojangAssetIndex); + } + + profile->applyMainJar(mainJar); + profile->applyMainClass(mainClass); + profile->applyAppletClass(appletClass); + profile->applyMinecraftArguments(minecraftArguments); + profile->applyAddnJvmArguments(addnJvmArguments); + profile->applyTweakers(addTweakers); + profile->applyJarMods(jarMods); + profile->applyMods(mods); + profile->applyTraits(traits); + profile->applyCompatibleJavaMajors(compatibleJavaMajors); + profile->applyCompatibleJavaName(compatibleJavaName); + + for (auto library : libraries) { + profile->applyLibrary(library, runtimeContext); + } + for (auto mavenFile : mavenFiles) { + profile->applyMavenFile(mavenFile, runtimeContext); + } + for (auto agent : agents) { + profile->applyAgent(agent, runtimeContext); + } + profile->applyProblemSeverity(getProblemSeverity()); +} diff --git a/launcher/minecraft/VersionFile.h b/launcher/minecraft/VersionFile.h new file mode 100644 index 0000000..32a7504 --- /dev/null +++ b/launcher/minecraft/VersionFile.h @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include +#include "Agent.h" +#include "Library.h" +#include "ProblemProvider.h" +#include "java/JavaMetadata.h" +#include "minecraft/Rule.h" + +class PackProfile; +class VersionFile; +class LaunchProfile; +struct MojangDownloadInfo; +struct MojangAssetIndexInfo; + +using VersionFilePtr = std::shared_ptr; +class VersionFile : public ProblemContainer { + friend class MojangVersionFormat; + friend class OneSixVersionFormat; + + public: /* methods */ + void applyTo(LaunchProfile* profile, const RuntimeContext& runtimeContext); + + public: /* data */ + /// Prism Launcher: order hint for this version file if no explicit order is set + int order = 0; + + /// Prism Launcher: human readable name of this package + QString name; + + /// Prism Launcher: package ID of this package + QString uid; + + /// Prism Launcher: version of this package + QString version; + + /// Prism Launcher: DEPRECATED dependency on a Minecraft version + QString dependsOnMinecraftVersion; + + /// Mojang: DEPRECATED used to version the Mojang version format + int minimumLauncherVersion = -1; + + /// Mojang: DEPRECATED version of Minecraft this is + QString minecraftVersion; + + /// Mojang: class to launch Minecraft with + QString mainClass; + + /// Prism Launcher: class to launch legacy Minecraft with (embed in a custom window) + QString appletClass; + + /// Mojang: Minecraft launch arguments (may contain placeholders for variable substitution) + QString minecraftArguments; + + /// Prism Launcher: Additional JVM launch arguments + QStringList addnJvmArguments; + + /// Mojang: list of compatible java majors + QList compatibleJavaMajors; + + /// Mojang: the name of recommended java version + QString compatibleJavaName; + + /// Mojang: type of the Minecraft version + QString type; + + /// Mojang: the time this version was actually released by Mojang + QDateTime releaseTime; + + /// Mojang: DEPRECATED the time this version was last updated by Mojang + QDateTime updateTime; + + /// Mojang: DEPRECATED asset group to be used with Minecraft + QString assets; + + /// Prism Launcher: list of tweaker mod arguments for launchwrapper + QStringList addTweakers; + + /// Mojang: list of libraries to add to the version + QList libraries; + + /// Prism Launcher: list of maven files to put in the libraries folder, but not in classpath + QList mavenFiles; + + /// Prism Launcher: list of agents to add to JVM arguments + QList agents; + + /// The main jar (Minecraft version library, normally) + LibraryPtr mainJar; + + /// Prism Launcher: list of attached traits of this version file - used to enable features + QSet traits; + + /// Prism Launcher: list of jar mods added to this version + QList jarMods; + + /// Prism Launcher: list of mods added to this version + QList mods; + + /** + * Prism Launcher: set of packages this depends on + * NOTE: this is shared with the meta format!!! + */ + Meta::RequireSet m_requires; + + /** + * Prism Launcher: set of packages this conflicts with + * NOTE: this is shared with the meta format!!! + */ + Meta::RequireSet conflicts; + + /// is volatile -- may be removed as soon as it is no longer needed by something else + bool m_volatile = false; + + QList runtimes; + + public: + // Mojang: DEPRECATED list of 'downloads' - client jar, server jar, windows server exe, maybe more. + QMap> mojangDownloads; + + // Mojang: extended asset index download information + std::shared_ptr mojangAssetIndex; +}; diff --git a/launcher/minecraft/VersionFilterData.cpp b/launcher/minecraft/VersionFilterData.cpp new file mode 100644 index 0000000..3924e59 --- /dev/null +++ b/launcher/minecraft/VersionFilterData.cpp @@ -0,0 +1,65 @@ +#include "VersionFilterData.h" +#include "ParseUtils.h" + +VersionFilterData g_VersionFilterData = VersionFilterData(); + +VersionFilterData::VersionFilterData() +{ + // 1.3.* + auto libs13 = QList{ { "argo-2.25.jar", "bb672829fde76cb163004752b86b0484bd0a7f4b" }, + { "guava-12.0.1.jar", "b8e78b9af7bf45900e14c6f958486b6ca682195f" }, + { "asm-all-4.0.jar", "98308890597acb64047f7e896638e0d98753ae82" } }; + + fmlLibsMapping["1.3.2"] = libs13; + + // 1.4.* + auto libs14 = QList{ { "argo-2.25.jar", "bb672829fde76cb163004752b86b0484bd0a7f4b" }, + { "guava-12.0.1.jar", "b8e78b9af7bf45900e14c6f958486b6ca682195f" }, + { "asm-all-4.0.jar", "98308890597acb64047f7e896638e0d98753ae82" }, + { "bcprov-jdk15on-147.jar", "b6f5d9926b0afbde9f4dbe3db88c5247be7794bb" } }; + + fmlLibsMapping["1.4"] = libs14; + fmlLibsMapping["1.4.1"] = libs14; + fmlLibsMapping["1.4.2"] = libs14; + fmlLibsMapping["1.4.3"] = libs14; + fmlLibsMapping["1.4.4"] = libs14; + fmlLibsMapping["1.4.5"] = libs14; + fmlLibsMapping["1.4.6"] = libs14; + fmlLibsMapping["1.4.7"] = libs14; + + // 1.5 + fmlLibsMapping["1.5"] = QList{ { "argo-small-3.2.jar", "58912ea2858d168c50781f956fa5b59f0f7c6b51" }, + { "guava-14.0-rc3.jar", "931ae21fa8014c3ce686aaa621eae565fefb1a6a" }, + { "asm-all-4.1.jar", "054986e962b88d8660ae4566475658469595ef58" }, + { "bcprov-jdk15on-148.jar", "960dea7c9181ba0b17e8bab0c06a43f0a5f04e65" }, + { "deobfuscation_data_1.5.zip", "5f7c142d53776f16304c0bbe10542014abad6af8" }, + { "scala-library.jar", "458d046151ad179c85429ed7420ffb1eaf6ddf85" } }; + + // 1.5.1 + fmlLibsMapping["1.5.1"] = QList{ { "argo-small-3.2.jar", "58912ea2858d168c50781f956fa5b59f0f7c6b51" }, + { "guava-14.0-rc3.jar", "931ae21fa8014c3ce686aaa621eae565fefb1a6a" }, + { "asm-all-4.1.jar", "054986e962b88d8660ae4566475658469595ef58" }, + { "bcprov-jdk15on-148.jar", "960dea7c9181ba0b17e8bab0c06a43f0a5f04e65" }, + { "deobfuscation_data_1.5.1.zip", "22e221a0d89516c1f721d6cab056a7e37471d0a6" }, + { "scala-library.jar", "458d046151ad179c85429ed7420ffb1eaf6ddf85" } }; + + // 1.5.2 + fmlLibsMapping["1.5.2"] = QList{ { "argo-small-3.2.jar", "58912ea2858d168c50781f956fa5b59f0f7c6b51" }, + { "guava-14.0-rc3.jar", "931ae21fa8014c3ce686aaa621eae565fefb1a6a" }, + { "asm-all-4.1.jar", "054986e962b88d8660ae4566475658469595ef58" }, + { "bcprov-jdk15on-148.jar", "960dea7c9181ba0b17e8bab0c06a43f0a5f04e65" }, + { "deobfuscation_data_1.5.2.zip", "446e55cd986582c70fcf12cb27bc00114c5adfd9" }, + { "scala-library.jar", "458d046151ad179c85429ed7420ffb1eaf6ddf85" } }; + + // don't use installers for those. + forgeInstallerBlacklist = QSet({ "1.5.2" }); + + // FIXME: remove, used for deciding when core mods should display + legacyCutoffDate = timeFromS3Time("2013-06-25T15:08:56+02:00"); + lwjglWhitelist = QSet{ "net.java.jinput:jinput", "net.java.jinput:jinput-platform", "net.java.jutils:jutils", + "org.lwjgl.lwjgl:lwjgl", "org.lwjgl.lwjgl:lwjgl_util", "org.lwjgl.lwjgl:lwjgl-platform" }; + + java8BeginsDate = timeFromS3Time("2017-03-30T09:32:19+00:00"); + java16BeginsDate = timeFromS3Time("2021-05-12T11:19:15+00:00"); + java17BeginsDate = timeFromS3Time("2021-11-16T17:04:48+00:00"); +} diff --git a/launcher/minecraft/VersionFilterData.h b/launcher/minecraft/VersionFilterData.h new file mode 100644 index 0000000..bcd329b --- /dev/null +++ b/launcher/minecraft/VersionFilterData.h @@ -0,0 +1,29 @@ +#pragma once +#include +#include +#include +#include + +struct FMLlib { + QString filename; + QString checksum; +}; + +struct VersionFilterData { + VersionFilterData(); + // mapping between minecraft versions and FML libraries required + QMap> fmlLibsMapping; + // set of minecraft versions for which using forge installers is blacklisted + QSet forgeInstallerBlacklist; + // no new versions below this date will be accepted from Mojang servers + QDateTime legacyCutoffDate; + // Libraries that belong to LWJGL + QSet lwjglWhitelist; + // release date of first version to require Java 8 (17w13a) + QDateTime java8BeginsDate; + // release data of first version to require Java 16 (21w19a) + QDateTime java16BeginsDate; + // release data of first version to require Java 17 (1.18 Pre Release 2) + QDateTime java17BeginsDate; +}; +extern VersionFilterData g_VersionFilterData; diff --git a/launcher/minecraft/World.cpp b/launcher/minecraft/World.cpp new file mode 100644 index 0000000..0deecb0 --- /dev/null +++ b/launcher/minecraft/World.cpp @@ -0,0 +1,547 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2022 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "World.h" +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include "GZip.h" + +#include + +#include + +#include "FileSystem.h" +#include "PSaveFile.h" +#include "archive/ArchiveReader.h" + +using std::nullopt; +using std::optional; + +GameType::GameType(std::optional original) : original(original) +{ + if (!original) { + return; + } + switch (*original) { + case 0: + type = GameType::Survival; + break; + case 1: + type = GameType::Creative; + break; + case 2: + type = GameType::Adventure; + break; + case 3: + type = GameType::Spectator; + break; + default: + break; + } +} + +QString GameType::toTranslatedString() const +{ + switch (type) { + case GameType::Survival: + return QCoreApplication::translate("GameType", "Survival"); + case GameType::Creative: + return QCoreApplication::translate("GameType", "Creative"); + case GameType::Adventure: + return QCoreApplication::translate("GameType", "Adventure"); + case GameType::Spectator: + return QCoreApplication::translate("GameType", "Spectator"); + default: + break; + } + if (original) { + return QCoreApplication::translate("GameType", "Unknown (%1)").arg(*original); + } + return QCoreApplication::translate("GameType", "Undefined"); +} + +QString GameType::toLogString() const +{ + switch (type) { + case GameType::Survival: + return "Survival"; + case GameType::Creative: + return "Creative"; + case GameType::Adventure: + return "Adventure"; + case GameType::Spectator: + return "Spectator"; + default: + break; + } + if (original) { + return QString("Unknown (%1)").arg(*original); + } + return "Undefined"; +} + +std::unique_ptr parseLevelDat(QByteArray data) +{ + QByteArray output; + if (!GZip::unzip(data, output)) { + return nullptr; + } + std::istringstream foo(std::string(output.constData(), output.size())); + try { + auto pair = nbt::io::read_compound(foo); + + if (pair.first != "") + return nullptr; + + if (pair.second == nullptr) + return nullptr; + + return std::move(pair.second); + } catch (const nbt::io::input_error& e) { + qWarning() << "Unable to parse level.dat:" << e.what(); + return nullptr; + } +} + +QByteArray serializeLevelDat(nbt::tag_compound* levelInfo) +{ + std::ostringstream s; + nbt::io::write_tag("", *levelInfo, s); + QByteArray val(s.str().data(), (int)s.str().size()); + return val; +} + +QString getDatFromFS(const QFileInfo& root, QString file) +{ + QDir worldDir(root.filePath()); + if (!root.isDir() || !worldDir.exists(file)) { + return QString(); + } + return worldDir.absoluteFilePath(file); +} + +QString getLevelDatFromFS(const QFileInfo& file) +{ + return getDatFromFS(file, "level.dat"); +} + +QByteArray getDatDataFromFS(const QFileInfo& root, QString file) +{ + auto fullFilePath = getDatFromFS(root, file); + if (fullFilePath.isNull()) { + return QByteArray(); + } + QFile f(fullFilePath); + if (!f.open(QIODevice::ReadOnly)) { + return QByteArray(); + } + return f.readAll(); +} + +QByteArray getLevelDatDataFromFS(const QFileInfo& file) +{ + return getDatDataFromFS(file, "level.dat"); +} + +QByteArray getWorldGenDataFromFS(const QFileInfo& file) +{ + return getDatDataFromFS(file, "data/minecraft/world_gen_settings.dat"); +} + +bool putLevelDatDataToFS(const QFileInfo& file, QByteArray& data) +{ + auto fullFilePath = getLevelDatFromFS(file); + if (fullFilePath.isNull()) { + return false; + } + PSaveFile f(fullFilePath); + if (!f.open(QIODevice::WriteOnly)) { + return false; + } + QByteArray compressed; + if (!GZip::zip(data, compressed)) { + return false; + } + if (f.write(compressed) != compressed.size()) { + f.cancelWriting(); + return false; + } + return f.commit(); +} + +World::World(const QFileInfo& file) +{ + repath(file); +} + +void World::repath(const QFileInfo& file) +{ + m_containerFile = file; + m_folderName = file.fileName(); + if (file.isFile() && file.suffix() == "zip") { + m_iconFile = QString(); + readFromZip(file); + } else if (file.isDir()) { + QFileInfo assumedIconPath(file.absoluteFilePath() + "/icon.png"); + if (assumedIconPath.exists()) { + m_iconFile = assumedIconPath.absoluteFilePath(); + } + readFromFS(file); + } +} + +bool World::resetIcon() +{ + if (m_iconFile.isNull()) { + return false; + } + if (QFile(m_iconFile).remove()) { + m_iconFile = QString(); + return true; + } + return false; +} + +int64_t loadSeed(QByteArray data); + +void World::readFromFS(const QFileInfo& file) +{ + auto bytes = getLevelDatDataFromFS(file); + if (bytes.isEmpty()) { + m_isValid = false; + return; + } + loadFromLevelDat(bytes); + m_levelDatTime = file.lastModified(); + if (m_randomSeed == 0) { + bytes = getWorldGenDataFromFS(file); + if (!bytes.isEmpty()) { + m_randomSeed = loadSeed(bytes); + } + } +} + +void World::readFromZip(const QFileInfo& file) +{ + MMCZip::ArchiveReader r(file.absoluteFilePath()); + + m_isValid = false; + r.parse([this](MMCZip::ArchiveReader::File* file, bool& stop) { + const QString levelDat = "level.dat"; + auto filePath = file->filename(); + QFileInfo fi(filePath); + if (fi.fileName().compare(levelDat, Qt::CaseInsensitive) == 0) { + m_containerOffsetPath = filePath.chopped(levelDat.length()); + m_levelDatTime = file->dateTime(); + loadFromLevelDat(file->readAll()); + m_isValid = true; + stop = true; + } + return true; + }); +} + +bool World::install(const QString& to, const QString& name) +{ + auto finalPath = FS::PathCombine(to, FS::DirNameFromString(m_actualName, to)); + if (!FS::ensureFolderPathExists(finalPath)) { + return false; + } + bool ok = false; + if (m_containerFile.isFile()) { + MMCZip::ArchiveReader zip(m_containerFile.absoluteFilePath()); + ok = !MMCZip::extractSubDir(&zip, m_containerOffsetPath, finalPath); + } else if (m_containerFile.isDir()) { + QString from = m_containerFile.filePath(); + ok = FS::copy(from, finalPath)(); + } + + if (ok && !name.isEmpty() && m_actualName != name) { + QFileInfo finalPathInfo(finalPath); + World newWorld(finalPathInfo); + if (newWorld.isValid()) { + newWorld.rename(name); + } + } + return ok; +} + +bool World::rename(const QString& newName) +{ + if (m_containerFile.isFile()) { + return false; + } + + auto data = getLevelDatDataFromFS(m_containerFile); + if (data.isEmpty()) { + return false; + } + + auto worldData = parseLevelDat(data); + if (!worldData) { + return false; + } + auto& val = worldData->at("Data"); + if (val.get_type() != nbt::tag_type::Compound) { + return false; + } + auto& dataCompound = val.as(); + dataCompound.put("LevelName", nbt::value_initializer(newName.toUtf8().data())); + data = serializeLevelDat(worldData.get()); + + putLevelDatDataToFS(m_containerFile, data); + + m_actualName = newName; + + QDir parentDir(m_containerFile.absoluteFilePath()); + parentDir.cdUp(); + QFile container(m_containerFile.absoluteFilePath()); + auto dirName = FS::DirNameFromString(m_actualName, parentDir.absolutePath()); + container.rename(parentDir.absoluteFilePath(dirName)); + + return true; +} + +namespace { + +optional read_string(nbt::value& parent, const char* name) +{ + try { + auto& namedValue = parent.at(name); + if (namedValue.get_type() != nbt::tag_type::String) { + return nullopt; + } + auto& tag_str = namedValue.as(); + return QString::fromUtf8(tag_str.get()); + } catch ([[maybe_unused]] const std::out_of_range& e) { + // fallback for old world formats + qWarning() << "String NBT tag" << name << "could not be found."; + return nullopt; + } catch ([[maybe_unused]] const std::bad_cast& e) { + // type mismatch + qWarning() << "NBT tag" << name << "could not be converted to string."; + return nullopt; + } +} + +optional read_long(nbt::value& parent, const char* name) +{ + try { + auto& namedValue = parent.at(name); + if (namedValue.get_type() != nbt::tag_type::Long) { + return nullopt; + } + auto& tag_str = namedValue.as(); + return tag_str.get(); + } catch ([[maybe_unused]] const std::out_of_range& e) { + // fallback for old world formats + qWarning() << "Long NBT tag" << name << "could not be found."; + return nullopt; + } catch ([[maybe_unused]] const std::bad_cast& e) { + // type mismatch + qWarning() << "NBT tag" << name << "could not be converted to long."; + return nullopt; + } +} + +optional read_int(nbt::value& parent, const char* name) +{ + try { + auto& namedValue = parent.at(name); + if (namedValue.get_type() != nbt::tag_type::Int) { + return nullopt; + } + auto& tag_str = namedValue.as(); + return tag_str.get(); + } catch ([[maybe_unused]] const std::out_of_range& e) { + // fallback for old world formats + qWarning() << "Int NBT tag" << name << "could not be found."; + return nullopt; + } catch ([[maybe_unused]] const std::bad_cast& e) { + // type mismatch + qWarning() << "NBT tag" << name << "could not be converted to int."; + return nullopt; + } +} + +GameType read_gametype(nbt::value& parent, const char* name) +{ + return GameType(read_int(parent, name)); +} + +} // namespace + +int64_t loadSeed(QByteArray data) +{ + auto levelData = parseLevelDat(data); + if (!levelData) { + return 0; + } + + nbt::value* valPtr = nullptr; + try { + valPtr = &levelData->at("data"); + } catch (const std::out_of_range&) { + return 0; + } + nbt::value& val = *valPtr; + + try { + return read_long(val, "seed").value_or(0); + } catch (const std::out_of_range&) { + } + return 0; +} + +void World::loadFromLevelDat(QByteArray data) +{ + auto levelData = parseLevelDat(data); + if (!levelData) { + m_isValid = false; + return; + } + + nbt::value* valPtr = nullptr; + try { + valPtr = &levelData->at("Data"); + } catch (const std::out_of_range& e) { + qWarning().nospace() << "Unable to read NBT tags from " << m_folderName << ": " << e.what(); + m_isValid = false; + return; + } + nbt::value& val = *valPtr; + + m_isValid = val.get_type() == nbt::tag_type::Compound; + if (!m_isValid) + return; + + auto name = read_string(val, "LevelName"); + m_actualName = name ? *name : m_folderName; + + auto timestamp = read_long(val, "LastPlayed"); + m_lastPlayed = timestamp ? QDateTime::fromMSecsSinceEpoch(*timestamp) : m_levelDatTime; + + m_gameType = read_gametype(val, "GameType"); + + optional randomSeed; + try { + auto& WorldGen_val = val.at("WorldGenSettings"); + randomSeed = read_long(WorldGen_val, "seed"); + } catch (const std::out_of_range&) { + } + if (!randomSeed) { + randomSeed = read_long(val, "RandomSeed"); + } + m_randomSeed = randomSeed ? *randomSeed : 0; + + qDebug() << "World Name:" << m_actualName; + qDebug() << "Last Played:" << m_lastPlayed.toString(); + if (randomSeed) { + qDebug() << "Seed:" << *randomSeed; + } + qDebug() << "Size:" << m_size; + qDebug() << "GameType:" << m_gameType.toLogString(); +} + +bool World::replace(World& with) +{ + if (!destroy()) + return false; + bool success = FS::copy(with.m_containerFile.filePath(), m_containerFile.path())(); + if (success) { + m_folderName = with.m_folderName; + m_containerFile.refresh(); + } + return success; +} + +bool World::destroy() +{ + if (!m_isValid) + return false; + + if (FS::trash(m_containerFile.filePath())) + return true; + + if (m_containerFile.isDir()) { + QDir d(m_containerFile.filePath()); + return d.removeRecursively(); + } else if (m_containerFile.isFile()) { + QFile file(m_containerFile.absoluteFilePath()); + return file.remove(); + } + return true; +} + +bool World::operator==(const World& other) const +{ + return m_isValid == other.m_isValid && folderName() == other.folderName(); +} + +bool World::isSymLinkUnder(const QString& instPath) const +{ + if (isSymLink()) + return true; + + auto instDir = QDir(instPath); + + auto relAbsPath = instDir.relativeFilePath(m_containerFile.absoluteFilePath()); + auto relCanonPath = instDir.relativeFilePath(m_containerFile.canonicalFilePath()); + + return relAbsPath != relCanonPath; +} + +bool World::isMoreThanOneHardLink() const +{ + if (m_containerFile.isDir()) { + return FS::hardLinkCount(QDir(m_containerFile.absoluteFilePath()).filePath("level.dat")) > 1; + } + return FS::hardLinkCount(m_containerFile.absoluteFilePath()) > 1; +} + +void World::setSize(int64_t size) +{ + m_size = size; +} diff --git a/launcher/minecraft/World.h b/launcher/minecraft/World.h new file mode 100644 index 0000000..bb4baa3 --- /dev/null +++ b/launcher/minecraft/World.h @@ -0,0 +1,94 @@ +/* Copyright 2015-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once +#include +#include +#include + +struct GameType { + GameType() = default; + GameType(std::optional original); + + QString toTranslatedString() const; + QString toLogString() const; + + enum { Unknown = -1, Survival, Creative, Adventure, Spectator } type = Unknown; + std::optional original; +}; + +class World { + public: + World(const QFileInfo& file); + QString folderName() const { return m_folderName; } + QString name() const { return m_actualName; } + QString iconFile() const { return m_iconFile; } + int64_t bytes() const { return m_size; } + QDateTime lastPlayed() const { return m_lastPlayed; } + GameType gameType() const { return m_gameType; } + int64_t seed() const { return m_randomSeed; } + bool isValid() const { return m_isValid; } + bool isOnFS() const { return m_containerFile.isDir(); } + QFileInfo container() const { return m_containerFile; } + // delete all the files of this world + bool destroy(); + // replace this world with a copy of the other + bool replace(World& with); + // change the world's filesystem path (used by world lists for *MAGIC* purposes) + void repath(const QFileInfo& file); + // remove the icon file, if any + bool resetIcon(); + + bool rename(const QString& to); + bool install(const QString& to, const QString& name = QString()); + + void setSize(int64_t size); + + // WEAK compare operator - used for replacing worlds + bool operator==(const World& other) const; + + auto isSymLink() const -> bool { return m_containerFile.isSymLink(); } + + /** + * @brief Take a instance path, checks if the file pointed to by the resource is a symlink or under a symlink in that instance + * + * @param instPath path to an instance directory + * @return true + * @return false + */ + bool isSymLinkUnder(const QString& instPath) const; + + bool isMoreThanOneHardLink() const; + + QString canonicalFilePath() const { return m_containerFile.canonicalFilePath(); } + + private: + void readFromZip(const QFileInfo& file); + void readFromFS(const QFileInfo& file); + void loadFromLevelDat(QByteArray data); + + protected: + QFileInfo m_containerFile; + QString m_containerOffsetPath; + QString m_folderName; + QString m_actualName; + QString m_iconFile; + QDateTime m_levelDatTime; + QDateTime m_lastPlayed; + int64_t m_size = 0; + int64_t m_randomSeed = 0; + GameType m_gameType; + bool m_isValid = false; +}; diff --git a/launcher/minecraft/WorldList.cpp b/launcher/minecraft/WorldList.cpp new file mode 100644 index 0000000..4aa0f75 --- /dev/null +++ b/launcher/minecraft/WorldList.cpp @@ -0,0 +1,436 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "WorldList.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +WorldList::WorldList(const QString& dir, BaseInstance* instance) : QAbstractListModel(), m_instance(instance), m_dir(dir) +{ + FS::ensureFolderPathExists(m_dir.absolutePath()); + m_dir.setFilter(QDir::Readable | QDir::NoDotAndDotDot | QDir::Files | QDir::Dirs); + m_dir.setSorting(QDir::Name | QDir::IgnoreCase | QDir::LocaleAware); + m_watcher = new QFileSystemWatcher(this); + m_isWatching = false; + connect(m_watcher, &QFileSystemWatcher::directoryChanged, this, &WorldList::directoryChanged); +} + +void WorldList::startWatching() +{ + if (m_isWatching) { + return; + } + update(); + m_isWatching = m_watcher->addPath(m_dir.absolutePath()); + if (m_isWatching) { + qDebug() << "Started watching" << m_dir.absolutePath(); + } else { + qDebug() << "Failed to start watching" << m_dir.absolutePath(); + } +} + +void WorldList::stopWatching() +{ + if (!m_isWatching) { + return; + } + m_isWatching = !m_watcher->removePath(m_dir.absolutePath()); + if (!m_isWatching) { + qDebug() << "Stopped watching" << m_dir.absolutePath(); + } else { + qDebug() << "Failed to stop watching" << m_dir.absolutePath(); + } +} + +bool WorldList::update() +{ + if (!isValid()) + return false; + + QList newWorlds; + m_dir.refresh(); + auto folderContents = m_dir.entryInfoList(); + // if there are any untracked files... + for (QFileInfo entry : folderContents) { + if (!entry.isDir()) + continue; + + World w(entry); + if (w.isValid()) { + newWorlds.append(w); + } + } + beginResetModel(); + m_worlds.swap(newWorlds); + endResetModel(); + loadWorldsAsync(); + return true; +} + +void WorldList::directoryChanged(QString) +{ + update(); +} + +bool WorldList::isValid() +{ + return m_dir.exists() && m_dir.isReadable(); +} + +QString WorldList::instDirPath() const +{ + return QFileInfo(m_instance->instanceRoot()).absoluteFilePath(); +} + +bool WorldList::deleteWorld(int index) +{ + if (index >= m_worlds.size() || index < 0) + return false; + World& m = m_worlds[index]; + if (m.destroy()) { + beginRemoveRows(QModelIndex(), index, index); + m_worlds.removeAt(index); + endRemoveRows(); + emit changed(); + return true; + } + return false; +} + +bool WorldList::deleteWorlds(int first, int last) +{ + for (int i = first; i <= last; i++) { + World& m = m_worlds[i]; + m.destroy(); + } + beginRemoveRows(QModelIndex(), first, last); + m_worlds.erase(m_worlds.begin() + first, m_worlds.begin() + last + 1); + endRemoveRows(); + emit changed(); + return true; +} + +bool WorldList::resetIcon(int row) +{ + if (row >= m_worlds.size() || row < 0) + return false; + World& m = m_worlds[row]; + if (m.resetIcon()) { + emit dataChanged(index(row), index(row), { WorldList::IconFileRole }); + return true; + } + return false; +} + +int WorldList::columnCount(const QModelIndex& parent) const +{ + return parent.isValid() ? 0 : 5; +} + +QVariant WorldList::data(const QModelIndex& index, int role) const +{ + if (!index.isValid()) + return QVariant(); + + int row = index.row(); + int column = index.column(); + + if (row < 0 || row >= m_worlds.size()) + return QVariant(); + + QLocale locale; + + auto& world = m_worlds[row]; + switch (role) { + case Qt::DisplayRole: + switch (column) { + case NameColumn: + return world.name(); + + case GameModeColumn: + return world.gameType().toTranslatedString(); + + case LastPlayedColumn: + return world.lastPlayed(); + + case SizeColumn: + return locale.formattedDataSize(world.bytes()); + + case InfoColumn: + if (world.isSymLinkUnder(instDirPath())) { + return tr("This world is symbolically linked from elsewhere."); + } + if (world.isMoreThanOneHardLink()) { + return tr("\nThis world is hard linked elsewhere."); + } + return ""; + default: + return QVariant(); + } + + case Qt::UserRole: + if (column == SizeColumn) + return QVariant::fromValue(world.bytes()); + return data(index, Qt::DisplayRole); + + case Qt::ToolTipRole: { + if (column == InfoColumn) { + if (world.isSymLinkUnder(instDirPath())) { + return tr("Warning: This world is symbolically linked from elsewhere. Editing it will also change the original." + "\nCanonical Path: %1") + .arg(world.canonicalFilePath()); + } + if (world.isMoreThanOneHardLink()) { + return tr("Warning: This world is hard linked elsewhere. Editing it will also change the original."); + } + } + return world.folderName(); + } + case ObjectRole: { + return QVariant::fromValue((void*)&world); + } + case FolderRole: { + return QDir::toNativeSeparators(dir().absoluteFilePath(world.folderName())); + } + case SeedRole: { + return QVariant::fromValue(world.seed()); + } + case NameRole: { + return world.name(); + } + case LastPlayedRole: { + return world.lastPlayed(); + } + case SizeRole: { + return QVariant::fromValue(world.bytes()); + } + case IconFileRole: { + return world.iconFile(); + } + default: + return QVariant(); + } +} + +QVariant WorldList::headerData(int section, [[maybe_unused]] Qt::Orientation orientation, int role) const +{ + switch (role) { + case Qt::DisplayRole: + switch (section) { + case NameColumn: + return tr("Name"); + case GameModeColumn: + return tr("Game Mode"); + case LastPlayedColumn: + return tr("Last Played"); + case SizeColumn: + //: World size on disk + return tr("Size"); + case InfoColumn: + //: special warnings? + return tr("Info"); + default: + return QVariant(); + } + + case Qt::ToolTipRole: + switch (section) { + case NameColumn: + return tr("The name of the world."); + case GameModeColumn: + return tr("Game mode of the world."); + case LastPlayedColumn: + return tr("Date and time the world was last played."); + case SizeColumn: + return tr("Size of the world on disk."); + case InfoColumn: + return tr("Information and warnings about the world."); + default: + return QVariant(); + } + default: + return QVariant(); + } +} + +QStringList WorldList::mimeTypes() const +{ + QStringList types; + types << "text/uri-list"; + return types; +} + +QMimeData* WorldList::mimeData(const QModelIndexList& indexes) const +{ + QList urls; + + for (auto idx : indexes) { + if (idx.column() != 0) + continue; + + int row = idx.row(); + if (row < 0 || row >= this->m_worlds.size()) + continue; + + const World& world = m_worlds[row]; + + if (!world.isValid() || !world.isOnFS()) + continue; + + QString worldPath = world.container().absoluteFilePath(); + qDebug() << worldPath; + urls.append(QUrl::fromLocalFile(worldPath)); + } + + auto result = new QMimeData(); + result->setUrls(urls); + return result; +} + +Qt::ItemFlags WorldList::flags(const QModelIndex& index) const +{ + Qt::ItemFlags defaultFlags = QAbstractListModel::flags(index); + if (index.isValid()) + return Qt::ItemIsUserCheckable | Qt::ItemIsDragEnabled | Qt::ItemIsDropEnabled | defaultFlags; + else + return Qt::ItemIsDropEnabled | defaultFlags; +} + +Qt::DropActions WorldList::supportedDragActions() const +{ + // move to other mod lists or VOID + return Qt::MoveAction; +} + +Qt::DropActions WorldList::supportedDropActions() const +{ + // copy from outside, move from within and other mod lists + return Qt::CopyAction | Qt::MoveAction; +} + +void WorldList::installWorld(QFileInfo filename) +{ + qDebug() << "installing:" << filename.absoluteFilePath(); + World w(filename); + if (!w.isValid()) { + return; + } + w.install(m_dir.absolutePath()); +} + +bool WorldList::dropMimeData(const QMimeData* data, + Qt::DropAction action, + [[maybe_unused]] int row, + [[maybe_unused]] int column, + [[maybe_unused]] const QModelIndex& parent) +{ + if (action == Qt::IgnoreAction) + return true; + // check if the action is supported + if (!data || !(action & supportedDropActions())) + return false; + // files dropped from outside? + if (data->hasUrls()) { + bool was_watching = m_isWatching; + if (was_watching) + stopWatching(); + auto urls = data->urls(); + for (auto url : urls) { + // only local files may be dropped... + if (!url.isLocalFile()) + continue; + QString filename = url.toLocalFile(); + + QFileInfo worldInfo(filename); + + if (!m_dir.entryInfoList().contains(worldInfo)) { + installWorld(worldInfo); + } + } + if (was_watching) + startWatching(); + return true; + } + return false; +} + +int64_t calculateWorldSize(const QFileInfo& file) +{ + if (file.isFile() && file.suffix() == "zip") { + return file.size(); + } else if (file.isDir()) { + QDirIterator it(file.absoluteFilePath(), QDir::Files, QDirIterator::Subdirectories); + int64_t total = 0; + while (it.hasNext()) { + it.next(); + total += it.fileInfo().size(); + } + return total; + } + return -1; +} + +void WorldList::loadWorldsAsync() +{ + for (int i = 0; i < m_worlds.size(); ++i) { + auto file = m_worlds.at(i).container(); + int row = i; + QThreadPool::globalInstance()->start([this, file, row]() mutable { + auto size = calculateWorldSize(file); + + QMetaObject::invokeMethod( + this, + [this, size, row, file]() { + if (row < m_worlds.size() && m_worlds[row].container() == file) { + m_worlds[row].setSize(size); + + // Notify views + QModelIndex modelIndex = index(row); + emit dataChanged(modelIndex, modelIndex, { SizeRole }); + } + }, + Qt::QueuedConnection); + }); + } +} diff --git a/launcher/minecraft/WorldList.h b/launcher/minecraft/WorldList.h new file mode 100644 index 0000000..93fecf1 --- /dev/null +++ b/launcher/minecraft/WorldList.h @@ -0,0 +1,100 @@ +/* Copyright 2015-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include "BaseInstance.h" +#include "minecraft/World.h" + +class QFileSystemWatcher; + +class WorldList : public QAbstractListModel { + Q_OBJECT + public: + enum Columns { NameColumn, GameModeColumn, LastPlayedColumn, SizeColumn, InfoColumn }; + + enum Roles { ObjectRole = Qt::UserRole + 1, FolderRole, SeedRole, NameRole, GameModeRole, LastPlayedRole, SizeRole, IconFileRole }; + + WorldList(const QString& dir, BaseInstance* instance); + + virtual QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const; + + virtual int rowCount(const QModelIndex& parent = QModelIndex()) const { return parent.isValid() ? 0 : static_cast(size()); }; + virtual QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const; + virtual int columnCount(const QModelIndex& parent) const; + + size_t size() const { return m_worlds.size(); }; + bool empty() const { return size() == 0; } + World& operator[](size_t index) { return m_worlds[index]; } + + /// Reloads the mod list and returns true if the list changed. + virtual bool update(); + + /// Install a world from location + void installWorld(QFileInfo filename); + + /// Deletes the mod at the given index. + virtual bool deleteWorld(int index); + + /// Removes the world icon, if any + virtual bool resetIcon(int index); + + /// Deletes all the selected mods + virtual bool deleteWorlds(int first, int last); + + /// flags, mostly to support drag&drop + virtual Qt::ItemFlags flags(const QModelIndex& index) const; + /// get data for drag action + virtual QMimeData* mimeData(const QModelIndexList& indexes) const; + /// get the supported mime types + virtual QStringList mimeTypes() const; + /// process data from drop action + virtual bool dropMimeData(const QMimeData* data, Qt::DropAction action, int row, int column, const QModelIndex& parent); + /// what drag actions do we support? + virtual Qt::DropActions supportedDragActions() const; + + /// what drop actions do we support? + virtual Qt::DropActions supportedDropActions() const; + + void startWatching(); + void stopWatching(); + + virtual bool isValid(); + + QDir dir() const { return m_dir; } + + QString instDirPath() const; + + const QList& allWorlds() const { return m_worlds; } + + private slots: + void directoryChanged(QString path); + void loadWorldsAsync(); + + signals: + void changed(); + + protected: + BaseInstance* m_instance; + QFileSystemWatcher* m_watcher; + bool m_isWatching; + QDir m_dir; + QList m_worlds; +}; diff --git a/launcher/minecraft/auth/AccountData.cpp b/launcher/minecraft/auth/AccountData.cpp new file mode 100644 index 0000000..bfb350a --- /dev/null +++ b/launcher/minecraft/auth/AccountData.cpp @@ -0,0 +1,368 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "AccountData.h" +#include +#include +#include +#include +#include + +namespace { +void tokenToJSONV3(QJsonObject& parent, const Token& t, const char* tokenName) +{ + if (!t.persistent) { + return; + } + QJsonObject out; + if (t.issueInstant.isValid()) { + out["iat"] = QJsonValue(t.issueInstant.toMSecsSinceEpoch() / 1000); + } + + if (t.notAfter.isValid()) { + out["exp"] = QJsonValue(t.notAfter.toMSecsSinceEpoch() / 1000); + } + + bool save = false; + if (!t.token.isEmpty()) { + out["token"] = QJsonValue(t.token); + save = true; + } + if (!t.refresh_token.isEmpty()) { + out["refresh_token"] = QJsonValue(t.refresh_token); + save = true; + } + if (t.extra.size()) { + out["extra"] = QJsonObject::fromVariantMap(t.extra); + save = true; + } + if (save) { + parent[tokenName] = out; + } +} + +Token tokenFromJSONV3(const QJsonObject& parent, const char* tokenName) +{ + Token out; + auto tokenObject = parent.value(tokenName).toObject(); + if (tokenObject.isEmpty()) { + return out; + } + auto issueInstant = tokenObject.value("iat"); + if (issueInstant.isDouble()) { + out.issueInstant = QDateTime::fromMSecsSinceEpoch(((int64_t)issueInstant.toDouble()) * 1000); + } + + auto notAfter = tokenObject.value("exp"); + if (notAfter.isDouble()) { + out.notAfter = QDateTime::fromMSecsSinceEpoch(((int64_t)notAfter.toDouble()) * 1000); + } + + auto token = tokenObject.value("token"); + if (token.isString()) { + out.token = token.toString(); + out.validity = Validity::Assumed; + } + + auto refresh_token = tokenObject.value("refresh_token"); + if (refresh_token.isString()) { + out.refresh_token = refresh_token.toString(); + } + + auto extra = tokenObject.value("extra"); + if (extra.isObject()) { + out.extra = extra.toObject().toVariantMap(); + } + return out; +} + +void profileToJSONV3(QJsonObject& parent, MinecraftProfile p, const char* tokenName) +{ + if (p.id.isEmpty()) { + return; + } + QJsonObject out; + out["id"] = QJsonValue(p.id); + out["name"] = QJsonValue(p.name); + if (!p.currentCape.isEmpty()) { + out["cape"] = p.currentCape; + } + + { + QJsonObject skinObj; + skinObj["id"] = p.skin.id; + skinObj["url"] = p.skin.url; + skinObj["variant"] = p.skin.variant; + if (p.skin.data.size()) { + skinObj["data"] = QString::fromLatin1(p.skin.data.toBase64()); + } + out["skin"] = skinObj; + } + + QJsonArray capesArray; + for (auto& cape : p.capes) { + QJsonObject capeObj; + capeObj["id"] = cape.id; + capeObj["url"] = cape.url; + capeObj["alias"] = cape.alias; + if (cape.data.size()) { + capeObj["data"] = QString::fromLatin1(cape.data.toBase64()); + } + capesArray.push_back(capeObj); + } + out["capes"] = capesArray; + parent[tokenName] = out; +} + +MinecraftProfile profileFromJSONV3(const QJsonObject& parent, const char* tokenName) +{ + MinecraftProfile out; + auto tokenObject = parent.value(tokenName).toObject(); + if (tokenObject.isEmpty()) { + return out; + } + { + auto idV = tokenObject.value("id"); + auto nameV = tokenObject.value("name"); + if (!idV.isString() || !nameV.isString()) { + qWarning() << "mandatory profile attributes are missing or of unexpected type"; + return MinecraftProfile(); + } + out.name = nameV.toString(); + out.id = idV.toString(); + } + + { + auto skinV = tokenObject.value("skin"); + if (!skinV.isObject()) { + qWarning() << "skin is missing"; + return MinecraftProfile(); + } + auto skinObj = skinV.toObject(); + auto idV = skinObj.value("id"); + auto urlV = skinObj.value("url"); + auto variantV = skinObj.value("variant"); + if (!idV.isString() || !urlV.isString() || !variantV.isString()) { + qWarning() << "mandatory skin attributes are missing or of unexpected type"; + return MinecraftProfile(); + } + out.skin.id = idV.toString(); + out.skin.url = urlV.toString(); + out.skin.url.replace("http://textures.minecraft.net", "https://textures.minecraft.net"); + out.skin.variant = variantV.toString(); + + // data for skin is optional + auto dataV = skinObj.value("data"); + if (dataV.isString()) { + // TODO: validate base64 + out.skin.data = QByteArray::fromBase64(dataV.toString().toLatin1()); + } else if (!dataV.isUndefined()) { + qWarning() << "skin data is something unexpected"; + return MinecraftProfile(); + } + } + + { + auto capesV = tokenObject.value("capes"); + if (!capesV.isArray()) { + qWarning() << "capes is not an array!"; + return MinecraftProfile(); + } + auto capesArray = capesV.toArray(); + for (auto capeV : capesArray) { + if (!capeV.isObject()) { + qWarning() << "cape is not an object!"; + return MinecraftProfile(); + } + auto capeObj = capeV.toObject(); + auto idV = capeObj.value("id"); + auto urlV = capeObj.value("url"); + auto aliasV = capeObj.value("alias"); + if (!idV.isString() || !urlV.isString() || !aliasV.isString()) { + qWarning() << "mandatory skin attributes are missing or of unexpected type"; + return MinecraftProfile(); + } + Cape cape; + cape.id = idV.toString(); + cape.url = urlV.toString(); + cape.url.replace("http://textures.minecraft.net", "https://textures.minecraft.net"); + cape.alias = aliasV.toString(); + + // data for cape is optional. + auto dataV = capeObj.value("data"); + if (dataV.isString()) { + // TODO: validate base64 + cape.data = QByteArray::fromBase64(dataV.toString().toLatin1()); + } else if (!dataV.isUndefined()) { + qWarning() << "cape data is something unexpected"; + return MinecraftProfile(); + } + out.capes[cape.id] = cape; + } + } + // current cape + { + auto capeV = tokenObject.value("cape"); + if (capeV.isString()) { + auto currentCape = capeV.toString(); + if (out.capes.contains(currentCape)) { + out.currentCape = currentCape; + } + } + } + out.validity = Validity::Assumed; + return out; +} + +void entitlementToJSONV3(QJsonObject& parent, MinecraftEntitlement p) +{ + if (p.validity == Validity::None) { + return; + } + QJsonObject out; + out["ownsMinecraft"] = QJsonValue(p.ownsMinecraft); + out["canPlayMinecraft"] = QJsonValue(p.canPlayMinecraft); + parent["entitlement"] = out; +} + +bool entitlementFromJSONV3(const QJsonObject& parent, MinecraftEntitlement& out) +{ + auto entitlementObject = parent.value("entitlement").toObject(); + if (entitlementObject.isEmpty()) { + return false; + } + { + auto ownsMinecraftV = entitlementObject.value("ownsMinecraft"); + auto canPlayMinecraftV = entitlementObject.value("canPlayMinecraft"); + if (!ownsMinecraftV.isBool() || !canPlayMinecraftV.isBool()) { + qWarning() << "mandatory attributes are missing or of unexpected type"; + return false; + } + out.canPlayMinecraft = canPlayMinecraftV.toBool(false); + out.ownsMinecraft = ownsMinecraftV.toBool(false); + out.validity = Validity::Assumed; + } + return true; +} + +} // namespace + +bool AccountData::resumeStateFromV3(QJsonObject data) +{ + auto typeV = data.value("type"); + if (!typeV.isString()) { + qWarning() << "Failed to parse account data: type is missing."; + return false; + } + auto typeS = typeV.toString(); + if (typeS == "MSA") { + type = AccountType::MSA; + } else if (typeS == "Offline") { + type = AccountType::Offline; + } else { + qWarning() << "Failed to parse account data: type is not recognized."; + return false; + } + + if (type == AccountType::MSA) { + auto clientIDV = data.value("msa-client-id"); + if (clientIDV.isString()) { + msaClientID = clientIDV.toString(); + } // leave msaClientID empty if it doesn't exist or isn't a string + msaToken = tokenFromJSONV3(data, "msa"); + userToken = tokenFromJSONV3(data, "utoken"); + mojangservicesToken = tokenFromJSONV3(data, "xrp-mc"); + } + + yggdrasilToken = tokenFromJSONV3(data, "ygg"); + // versions before 7.2 used "offline" as the offline token + if (yggdrasilToken.token == "offline") + yggdrasilToken.token = "0"; + + minecraftProfile = profileFromJSONV3(data, "profile"); + if (!entitlementFromJSONV3(data, minecraftEntitlement)) { + if (minecraftProfile.validity != Validity::None) { + minecraftEntitlement.canPlayMinecraft = true; + minecraftEntitlement.ownsMinecraft = true; + minecraftEntitlement.validity = Validity::Assumed; + } + } + + validity_ = minecraftProfile.validity; + return true; +} + +QJsonObject AccountData::saveState() const +{ + QJsonObject output; + if (type == AccountType::MSA) { + output["type"] = "MSA"; + output["msa-client-id"] = msaClientID; + tokenToJSONV3(output, msaToken, "msa"); + tokenToJSONV3(output, userToken, "utoken"); + tokenToJSONV3(output, mojangservicesToken, "xrp-mc"); + } else if (type == AccountType::Offline) { + output["type"] = "Offline"; + } + + tokenToJSONV3(output, yggdrasilToken, "ygg"); + profileToJSONV3(output, minecraftProfile, "profile"); + entitlementToJSONV3(output, minecraftEntitlement); + return output; +} + +QString AccountData::accessToken() const +{ + return yggdrasilToken.token; +} + +QString AccountData::profileId() const +{ + return minecraftProfile.id; +} + +QString AccountData::profileName() const +{ + if (minecraftProfile.name.size() == 0) { + return QObject::tr("No Minecraft profile"); + } + + return minecraftProfile.name; +} + +QString AccountData::lastError() const +{ + return errorString; +} diff --git a/launcher/minecraft/auth/AccountData.h b/launcher/minecraft/auth/AccountData.h new file mode 100644 index 0000000..5fbe321 --- /dev/null +++ b/launcher/minecraft/auth/AccountData.h @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once +#include +#include +#include +#include + +#include +#include +#include + +enum class Validity { None, Assumed, Certain }; + +struct Token { + QDateTime issueInstant; + QDateTime notAfter; + QString token; + QString refresh_token; + QVariantMap extra; + + Validity validity = Validity::None; + bool persistent = true; +}; + +struct Skin { + QString id; + QString url; + QString variant; + + QByteArray data; +}; + +struct Cape { + QString id; + QString url; + QString alias; + + QByteArray data; +}; + +struct MinecraftEntitlement { + bool ownsMinecraft = false; + bool canPlayMinecraft = false; + Validity validity = Validity::None; +}; + +struct MinecraftProfile { + QString id; + QString name; + Skin skin; + QString currentCape; + QMap capes; + Validity validity = Validity::None; +}; + +enum class AccountType { MSA, Offline }; + +enum class AccountState { Unchecked, Offline, Working, Online, Disabled, Errored, Expired, Gone }; + +struct AccountData { + QJsonObject saveState() const; + bool resumeStateFromV3(QJsonObject data); + + //! Yggdrasil access token, as passed to the game. + QString accessToken() const; + + QString profileId() const; + QString profileName() const; + + QString lastError() const; + + AccountType type = AccountType::MSA; + + QString msaClientID; + Token msaToken; + Token userToken; + Token mojangservicesToken; + + Token yggdrasilToken; + MinecraftProfile minecraftProfile; + MinecraftEntitlement minecraftEntitlement; + Validity validity_ = Validity::None; + + // runtime only information (not saved with the account) + QString internalId; + QString errorString; + AccountState accountState = AccountState::Unchecked; +}; diff --git a/launcher/minecraft/auth/AccountList.cpp b/launcher/minecraft/auth/AccountList.cpp new file mode 100644 index 0000000..af430c8 --- /dev/null +++ b/launcher/minecraft/auth/AccountList.cpp @@ -0,0 +1,705 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "AccountList.h" +#include "AccountData.h" +#include "tasks/Task.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +enum AccountListVersion { MojangMSA = 3 }; + +AccountList::AccountList(QObject* parent) : QAbstractListModel(parent) +{ + m_refreshTimer = new QTimer(this); + m_refreshTimer->setSingleShot(true); + connect(m_refreshTimer, &QTimer::timeout, this, &AccountList::fillQueue); + m_nextTimer = new QTimer(this); + m_nextTimer->setSingleShot(true); + connect(m_nextTimer, &QTimer::timeout, this, &AccountList::tryNext); +} + +AccountList::~AccountList() noexcept {} + +int AccountList::findAccountByProfileId(const QString& profileId) const +{ + for (int i = 0; i < count(); i++) { + MinecraftAccountPtr account = at(i); + if (account->profileId() == profileId) { + return i; + } + } + return -1; +} + +MinecraftAccountPtr AccountList::getAccountByProfileName(const QString& profileName) const +{ + for (int i = 0; i < count(); i++) { + MinecraftAccountPtr account = at(i); + if (account->profileName() == profileName) { + return account; + } + } + return nullptr; +} + +const MinecraftAccountPtr AccountList::at(int i) const +{ + return MinecraftAccountPtr(m_accounts.at(i)); +} + +QStringList AccountList::profileNames() const +{ + QStringList out; + for (auto& account : m_accounts) { + auto profileName = account->profileName(); + if (profileName.isEmpty()) { + continue; + } + out.append(profileName); + } + return out; +} + +void AccountList::addAccount(const MinecraftAccountPtr account) +{ + // NOTE: Do not allow adding something that's already there. We shouldn't let it continue + // because of the signal / slot connections after this. + if (m_accounts.contains(account)) { + qDebug() << "Tried to add account that's already on the accounts list!"; + return; + } + + // hook up notifications for changes in the account + connect(account.get(), &MinecraftAccount::changed, this, &AccountList::accountChanged); + connect(account.get(), &MinecraftAccount::activityChanged, this, &AccountList::accountActivityChanged); + + // override/replace existing account with the same profileId + auto profileId = account->profileId(); + if (profileId.size()) { + auto existingAccount = findAccountByProfileId(profileId); + if (existingAccount != -1) { + qDebug() << "Replacing old account with a new one with the same profile ID!"; + + MinecraftAccountPtr existingAccountPtr = m_accounts[existingAccount]; + m_accounts[existingAccount] = account; + if (m_defaultAccount == existingAccountPtr) { + m_defaultAccount = account; + } + // disconnect notifications for changes in the account being replaced + existingAccountPtr->disconnect(this); + emit dataChanged(index(existingAccount), index(existingAccount, columnCount(QModelIndex()) - 1)); + onListChanged(); + return; + } + } + + // if we don't have this profileId yet, add the account to the end + int row = m_accounts.count(); + qDebug() << "Inserting account at index" << row; + + beginInsertRows(QModelIndex(), row, row); + m_accounts.append(account); + endInsertRows(); + + onListChanged(); +} + +void AccountList::removeAccount(QModelIndex index) +{ + int row = index.row(); + if (index.isValid() && row >= 0 && row < m_accounts.size()) { + auto& account = m_accounts[row]; + if (account == m_defaultAccount) { + m_defaultAccount = nullptr; + onDefaultAccountChanged(); + } + account->disconnect(this); + + beginRemoveRows(QModelIndex(), row, row); + m_accounts.removeAt(index.row()); + endRemoveRows(); + onListChanged(); + } +} + +void AccountList::moveAccount(QModelIndex index, int delta) +{ + const int row = index.row(); + const int newRow = row + delta; + if (index.isValid() && row < m_accounts.size() && newRow >= 0 && newRow < m_accounts.size()) { + // Qt is stupid, https://doc.qt.io/qt-6/qabstractitemmodel.html#beginMoveRows + const int modelDestinationRow = (newRow > row) ? newRow + 1 : newRow; + + if (beginMoveRows(QModelIndex(), row, row, QModelIndex(), modelDestinationRow)) { + m_accounts.move(row, newRow); + endMoveRows(); + + onListChanged(); + } else { + qCritical().noquote() << "AccountList: failed to move account from" << row << "to" << newRow + << QString("(%1 accounts in total)").arg(this->count()); + } + } +} + +MinecraftAccountPtr AccountList::defaultAccount() const +{ + return m_defaultAccount; +} + +void AccountList::setDefaultAccount(MinecraftAccountPtr newAccount) +{ + if (!newAccount && m_defaultAccount) { + int idx = 0; + auto previousDefaultAccount = m_defaultAccount; + m_defaultAccount = nullptr; + for (MinecraftAccountPtr account : m_accounts) { + if (account == previousDefaultAccount) { + emit dataChanged(index(idx), index(idx, columnCount(QModelIndex()) - 1)); + } + idx++; + } + onDefaultAccountChanged(); + } else { + auto currentDefaultAccount = m_defaultAccount; + int currentDefaultAccountIdx = -1; + auto newDefaultAccount = m_defaultAccount; + int newDefaultAccountIdx = -1; + int idx = 0; + for (MinecraftAccountPtr account : m_accounts) { + if (account == newAccount) { + newDefaultAccount = account; + newDefaultAccountIdx = idx; + } + if (currentDefaultAccount == account) { + currentDefaultAccountIdx = idx; + } + idx++; + } + if (currentDefaultAccount != newDefaultAccount) { + emit dataChanged(index(currentDefaultAccountIdx), index(currentDefaultAccountIdx, columnCount(QModelIndex()) - 1)); + emit dataChanged(index(newDefaultAccountIdx), index(newDefaultAccountIdx, columnCount(QModelIndex()) - 1)); + m_defaultAccount = newDefaultAccount; + onDefaultAccountChanged(); + } + } +} + +void AccountList::accountChanged() +{ + // the list changed. there is no doubt. + onListChanged(); +} + +void AccountList::accountActivityChanged(bool active) +{ + MinecraftAccount* account = qobject_cast(sender()); + bool found = false; + for (int i = 0; i < count(); i++) { + if (at(i).get() == account) { + emit dataChanged(index(i), index(i, columnCount(QModelIndex()) - 1)); + found = true; + break; + } + } + if (found) { + emit listActivityChanged(); + if (active) { + beginActivity(); + } else { + endActivity(); + } + } +} + +void AccountList::onListChanged() +{ + if (m_autosave) + // TODO: Alert the user if this fails. + saveList(); + + emit listChanged(); +} + +void AccountList::onDefaultAccountChanged() +{ + if (m_autosave) + saveList(); + + emit defaultAccountChanged(); +} + +int AccountList::count() const +{ + return m_accounts.count(); +} + +QString getAccountStatus(AccountState status) +{ + switch (status) { + case AccountState::Unchecked: + return QObject::tr("Unchecked", "Account status"); + case AccountState::Offline: + return QObject::tr("Offline", "Account status"); + case AccountState::Online: + return QObject::tr("Ready", "Account status"); + case AccountState::Working: + return QObject::tr("Working", "Account status"); + case AccountState::Errored: + return QObject::tr("Errored", "Account status"); + case AccountState::Expired: + return QObject::tr("Expired", "Account status"); + case AccountState::Disabled: + return QObject::tr("Disabled", "Account status"); + case AccountState::Gone: + return QObject::tr("Gone", "Account status"); + default: + return QObject::tr("Unknown", "Account status"); + } +} + +QVariant AccountList::data(const QModelIndex& index, int role) const +{ + if (!index.isValid()) + return QVariant(); + + if (index.row() > count()) + return QVariant(); + + MinecraftAccountPtr account = at(index.row()); + + switch (role) { + case Qt::SizeHintRole: + if (index.column() == ProfileNameColumn) { + return QSize(0, 30); + } + + return QVariant(); + case Qt::DecorationRole: + if (index.column() == ProfileNameColumn) { + auto face = account->getFace(24, 24); + + if (!face.isNull()) { + return face; + } else { + return QIcon::fromTheme("noaccount").pixmap(24, 24); + } + } + + return QVariant(); + case Qt::DisplayRole: + switch (index.column()) { + case ProfileNameColumn: + return account->profileName(); + case TypeColumn: { + switch (account->accountType()) { + case AccountType::MSA: { + return tr("MSA", "Account type"); + } + case AccountType::Offline: { + return tr("Offline", "Account type"); + } + } + return tr("Unknown", "Account type"); + } + case StatusColumn: + return getAccountStatus(account->accountState()); + default: + return QVariant(); + } + + case PointerRole: + return QVariant::fromValue(account); + + case Qt::CheckStateRole: + if (index.column() == ProfileNameColumn) + return account == m_defaultAccount ? Qt::Checked : Qt::Unchecked; + return QVariant(); + + default: + return QVariant(); + } +} + +QVariant AccountList::headerData(int section, [[maybe_unused]] Qt::Orientation orientation, int role) const +{ + switch (role) { + case Qt::DisplayRole: + switch (section) { + case ProfileNameColumn: + return tr("Username"); + case TypeColumn: + return tr("Type"); + case StatusColumn: + return tr("Status"); + default: + return QVariant(); + } + + case Qt::ToolTipRole: + switch (section) { + case ProfileNameColumn: + return tr("Minecraft username associated with the account."); + case TypeColumn: + return tr("Type of the account (MSA or Offline)"); + case StatusColumn: + return tr("Current status of the account."); + default: + return QVariant(); + } + + default: + return QVariant(); + } +} + +int AccountList::rowCount(const QModelIndex& parent) const +{ + // Return count + return parent.isValid() ? 0 : count(); +} + +int AccountList::columnCount(const QModelIndex& parent) const +{ + return parent.isValid() ? 0 : NUM_COLUMNS; +} + +Qt::ItemFlags AccountList::flags(const QModelIndex& index) const +{ + if (index.row() < 0 || index.row() >= rowCount(index.parent()) || !index.isValid()) { + return Qt::NoItemFlags; + } + + return Qt::ItemIsUserCheckable | Qt::ItemIsEnabled | Qt::ItemIsSelectable; +} + +bool AccountList::setData(const QModelIndex& idx, const QVariant& value, int role) +{ + if (idx.row() < 0 || idx.row() >= rowCount(idx.parent()) || !idx.isValid()) { + return false; + } + + if (role == Qt::CheckStateRole) { + if (value == Qt::Checked) { + MinecraftAccountPtr account = at(idx.row()); + setDefaultAccount(account); + } else if (m_defaultAccount == at(idx.row())) + setDefaultAccount(nullptr); + } + + emit dataChanged(idx, index(idx.row(), columnCount(QModelIndex()) - 1)); + return true; +} + +bool AccountList::loadList() +{ + if (m_listFilePath.isEmpty()) { + qCritical() << "Can't load Mojang account list. No file path given and no default set."; + return false; + } + + QFile file(m_listFilePath); + + // Try to open the file and fail if we can't. + // TODO: We should probably report this error to the user. + if (!file.open(QIODevice::ReadOnly)) { + qCritical() << QString("Failed to read the account list file %1 (%2).").arg(m_listFilePath).arg(file.errorString()).toUtf8(); + return false; + } + + // Read the file and close it. + QByteArray jsonData = file.readAll(); + file.close(); + + QJsonParseError parseError; + QJsonDocument jsonDoc = QJsonDocument::fromJson(jsonData, &parseError); + + // Fail if the JSON is invalid. + if (parseError.error != QJsonParseError::NoError) { + qCritical() << QString("Failed to parse account list file: %1 at offset %2") + .arg(parseError.errorString(), QString::number(parseError.offset)) + .toUtf8(); + return false; + } + + // Make sure the root is an object. + if (!jsonDoc.isObject()) { + qCritical() << "Invalid account list JSON: Root should be an array."; + return false; + } + + QJsonObject root = jsonDoc.object(); + + // Make sure the format version matches. + auto listVersion = root.value("formatVersion").toVariant().toInt(); + if (listVersion == AccountListVersion::MojangMSA) + return loadV3(root); + + QString newName = "accounts-old.json"; + qWarning() << "Unknown format version when loading account list. Existing one will be renamed to" << newName; + // Attempt to rename the old version. + file.rename(newName); + return false; +} + +bool AccountList::loadV3(QJsonObject& root) +{ + beginResetModel(); + QJsonArray accounts = root.value("accounts").toArray(); + for (QJsonValue accountVal : accounts) { + QJsonObject accountObj = accountVal.toObject(); + MinecraftAccountPtr account = MinecraftAccount::loadFromJsonV3(accountObj); + if (account.get() != nullptr) { + auto profileId = account->profileId(); + if (profileId.size()) { + if (findAccountByProfileId(profileId) != -1) { + continue; + } + } + connect(account.get(), &MinecraftAccount::changed, this, &AccountList::accountChanged); + connect(account.get(), &MinecraftAccount::activityChanged, this, &AccountList::accountActivityChanged); + m_accounts.append(account); + if (accountObj.value("active").toBool(false)) { + m_defaultAccount = account; + } + } else { + qWarning() << "Failed to load an account."; + } + } + endResetModel(); + return true; +} + +bool AccountList::saveList() +{ + if (m_listFilePath.isEmpty()) { + qCritical() << "Can't save Mojang account list. No file path given and no default set."; + return false; + } + + // make sure the parent folder exists + if (!FS::ensureFilePathExists(m_listFilePath)) + return false; + + // make sure the file wasn't overwritten with a folder before (fixes a bug) + QFileInfo finfo(m_listFilePath); + if (finfo.isDir()) { + QDir badDir(m_listFilePath); + badDir.removeRecursively(); + } + + qDebug() << "Writing account list to" << m_listFilePath; + + qDebug() << "Building JSON data structure."; + // Build the JSON document to write to the list file. + QJsonObject root; + + root.insert("formatVersion", AccountListVersion::MojangMSA); + + // Build a list of accounts. + qDebug() << "Building account array."; + QJsonArray accounts; + for (MinecraftAccountPtr account : m_accounts) { + QJsonObject accountObj = account->saveToJson(); + if (m_defaultAccount == account) { + accountObj["active"] = true; + } + accounts.append(accountObj); + } + + // Insert the account list into the root object. + root.insert("accounts", accounts); + + // Create a JSON document object to convert our JSON to bytes. + QJsonDocument doc(root); + + // Now that we're done building the JSON object, we can write it to the file. + qDebug() << "Writing account list to file."; + QSaveFile file(m_listFilePath); + + // Try to open the file and fail if we can't. + // TODO: We should probably report this error to the user. + if (!file.open(QIODevice::WriteOnly)) { + qCritical() << QString("Failed to save the account list file %1 (%2).").arg(m_listFilePath).arg(file.errorString()).toUtf8(); + return false; + } + + // Write the JSON to the file. + file.write(doc.toJson()); + file.setPermissions(QFile::ReadOwner | QFile::WriteOwner | QFile::ReadUser | QFile::WriteUser); + if (file.commit()) { + qDebug() << "Saved account list to" << m_listFilePath; + return true; + } else { + qDebug() << "Failed to save accounts to" << m_listFilePath << "error:" << file.errorString(); + return false; + } +} + +void AccountList::setListFilePath(QString path, bool autosave) +{ + m_listFilePath = path; + m_autosave = autosave; +} + +bool AccountList::anyAccountIsValid() +{ + return true; +} + +void AccountList::fillQueue() +{ + if (m_defaultAccount && m_defaultAccount->shouldRefresh()) { + auto idToRefresh = m_defaultAccount->internalId(); + m_refreshQueue.push_back(idToRefresh); + qDebug() << "AccountList: Queued default account with internal ID" << idToRefresh << "to refresh first"; + } + + for (int i = 0; i < count(); i++) { + auto account = at(i); + if (account == m_defaultAccount) { + continue; + } + + if (account->shouldRefresh()) { + auto idToRefresh = account->internalId(); + queueRefresh(idToRefresh); + } + } + tryNext(); +} + +void AccountList::requestRefresh(QString accountId) +{ + auto index = m_refreshQueue.indexOf(accountId); + if (index != -1) { + m_refreshQueue.removeAt(index); + } + m_refreshQueue.push_front(accountId); + qDebug() << "AccountList: Pushed account with internal ID" << accountId << "to the front of the queue"; + if (!isActive()) { + tryNext(); + } +} + +void AccountList::queueRefresh(QString accountId) +{ + if (m_refreshQueue.indexOf(accountId) != -1) { + return; + } + m_refreshQueue.push_back(accountId); + qDebug() << "AccountList: Queued account with internal ID" << accountId << "to refresh"; +} + +void AccountList::tryNext() +{ + while (m_refreshQueue.length()) { + auto accountId = m_refreshQueue.front(); + m_refreshQueue.pop_front(); + for (int i = 0; i < count(); i++) { + auto account = at(i); + if (account->internalId() == accountId) { + m_currentTask = account->refresh(); + if (m_currentTask) { + connect(m_currentTask.get(), &Task::succeeded, this, &AccountList::authSucceeded); + connect(m_currentTask.get(), &Task::failed, this, &AccountList::authFailed); + m_currentTask->start(); + qDebug() << "RefreshSchedule: Processing account" << account->profileName() << "with internal ID" + << accountId; + return; + } + } + } + qDebug() << "RefreshSchedule: Account with internal ID" << accountId << "not found."; + } + // if we get here, no account needed refreshing. Schedule refresh in an hour. + m_refreshTimer->start(1000 * 3600); +} + +void AccountList::authSucceeded() +{ + qDebug() << "RefreshSchedule: Background account refresh succeeded"; + m_currentTask.reset(); + m_nextTimer->start(1000 * 20); +} + +void AccountList::authFailed(QString reason) +{ + qDebug() << "RefreshSchedule: Background account refresh failed:" << reason; + m_currentTask.reset(); + m_nextTimer->start(1000 * 20); +} + +bool AccountList::isActive() const +{ + return m_activityCount != 0; +} + +void AccountList::beginActivity() +{ + bool activating = m_activityCount == 0; + m_activityCount++; + if (activating) { + emit activityChanged(true); + } +} + +void AccountList::endActivity() +{ + if (m_activityCount == 0) { + qWarning() << "Activity count would become below zero"; + return; + } + bool deactivating = m_activityCount == 1; + m_activityCount--; + if (deactivating) { + emit activityChanged(false); + } +} diff --git a/launcher/minecraft/auth/AccountList.h b/launcher/minecraft/auth/AccountList.h new file mode 100644 index 0000000..916f233 --- /dev/null +++ b/launcher/minecraft/auth/AccountList.h @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "MinecraftAccount.h" +#include "minecraft/auth/AuthFlow.h" + +#include +#include +#include +#include + +/*! + * List of available Mojang accounts. + * This should be loaded in the background by Prism Launcher on startup. + */ +class AccountList : public QAbstractListModel { + Q_OBJECT + public: + enum ModelRoles { PointerRole = 0x34B1CB48 }; + + enum VListColumns { + // TODO: Add icon column. + ProfileNameColumn = 0, + TypeColumn, + StatusColumn, + + NUM_COLUMNS + }; + + explicit AccountList(QObject* parent = 0); + virtual ~AccountList() noexcept; + + const MinecraftAccountPtr at(int i) const; + int count() const; + + //////// List Model Functions //////// + QVariant data(const QModelIndex& index, int role) const override; + virtual QVariant headerData(int section, Qt::Orientation orientation, int role) const override; + virtual int rowCount(const QModelIndex& parent) const override; + virtual int columnCount(const QModelIndex& parent) const override; + virtual Qt::ItemFlags flags(const QModelIndex& index) const override; + virtual bool setData(const QModelIndex& index, const QVariant& value, int role) override; + + void addAccount(MinecraftAccountPtr account); + void removeAccount(QModelIndex index); + void moveAccount(QModelIndex index, int delta); + int findAccountByProfileId(const QString& profileId) const; + MinecraftAccountPtr getAccountByProfileName(const QString& profileName) const; + QStringList profileNames() const; + + // requesting a refresh pushes it to the front of the queue + void requestRefresh(QString accountId); + // queuing a refresh will let it go to the back of the queue (unless it's somewhere inside the queue already) + void queueRefresh(QString accountId); + + /*! + * Sets the path to load/save the list file from/to. + * If autosave is true, this list will automatically save to the given path whenever it changes. + * THIS FUNCTION DOES NOT LOAD THE LIST. If you set autosave, be sure to call loadList() immediately + * after calling this function to ensure an autosaved change doesn't overwrite the list you intended + * to load. + */ + void setListFilePath(QString path, bool autosave = false); + + bool loadList(); + bool loadV3(QJsonObject& root); + bool saveList(); + + MinecraftAccountPtr defaultAccount() const; + void setDefaultAccount(MinecraftAccountPtr profileId); + bool anyAccountIsValid(); + + bool isActive() const; + + protected: + void beginActivity(); + void endActivity(); + + private: + uint32_t m_activityCount = 0; + signals: + void listChanged(); + void listActivityChanged(); + void defaultAccountChanged(); + void activityChanged(bool active); + + public slots: + /** + * This is called when one of the accounts changes and the list needs to be updated + */ + void accountChanged(); + + /** + * This is called when a (refresh/login) task involving the account starts or ends + */ + void accountActivityChanged(bool active); + + /** + * This is initially to run background account refresh tasks, or on a hourly timer + */ + void fillQueue(); + + private slots: + void tryNext(); + + void authSucceeded(); + void authFailed(QString reason); + + protected: + QList m_refreshQueue; + QTimer* m_refreshTimer; + QTimer* m_nextTimer; + shared_qobject_ptr m_currentTask; + + /*! + * Called whenever the list changes. + * This emits the listChanged() signal and autosaves the list (if autosave is enabled). + */ + void onListChanged(); + + /*! + * Called whenever the active account changes. + * Emits the defaultAccountChanged() signal and autosaves the list if enabled. + */ + void onDefaultAccountChanged(); + + QList m_accounts; + + MinecraftAccountPtr m_defaultAccount; + + //! Path to the account list file. Empty string if there isn't one. + QString m_listFilePath; + + /*! + * If true, the account list will automatically save to the account list path when it changes. + * Ignored if m_listFilePath is blank. + */ + bool m_autosave = false; +}; diff --git a/launcher/minecraft/auth/AuthFlow.cpp b/launcher/minecraft/auth/AuthFlow.cpp new file mode 100644 index 0000000..5b8f981 --- /dev/null +++ b/launcher/minecraft/auth/AuthFlow.cpp @@ -0,0 +1,155 @@ +#include +#include +#include + +#include "minecraft/auth/AccountData.h" +#include "minecraft/auth/steps/EntitlementsStep.h" +#include "minecraft/auth/steps/GetSkinStep.h" +#include "minecraft/auth/steps/LauncherLoginStep.h" +#include "minecraft/auth/steps/MSADeviceCodeStep.h" +#include "minecraft/auth/steps/MSAStep.h" +#include "minecraft/auth/steps/MinecraftProfileStep.h" +#include "minecraft/auth/steps/XboxAuthorizationStep.h" +#include "minecraft/auth/steps/XboxUserStep.h" +#include "tasks/Task.h" + +#include "AuthFlow.h" + +#include + +AuthFlow::AuthFlow(AccountData* data, Action action) : Task(), m_data(data) +{ + if (data->type == AccountType::MSA) { + if (action == Action::DeviceCode) { + auto oauthStep = makeShared(m_data); + connect(oauthStep.get(), &MSADeviceCodeStep::authorizeWithBrowser, this, &AuthFlow::authorizeWithBrowserWithExtra); + connect(this, &Task::aborted, oauthStep.get(), &MSADeviceCodeStep::abort); + m_steps.append(oauthStep); + } else { + auto oauthStep = makeShared(m_data, action == Action::Refresh); + connect(oauthStep.get(), &MSAStep::authorizeWithBrowser, this, &AuthFlow::authorizeWithBrowser); + m_steps.append(oauthStep); + } + m_steps.append(makeShared(m_data)); + m_steps.append( + makeShared(m_data, &m_data->mojangservicesToken, "rp://api.minecraftservices.com/", "Mojang")); + m_steps.append(makeShared(m_data)); + m_steps.append(makeShared(m_data)); + m_steps.append(makeShared(m_data)); + m_steps.append(makeShared(m_data)); + } + changeState(AccountTaskState::STATE_CREATED); +} + +void AuthFlow::succeed() +{ + m_data->validity_ = Validity::Certain; + changeState(AccountTaskState::STATE_SUCCEEDED, tr("Finished all authentication steps")); +} + +void AuthFlow::executeTask() +{ + changeState(AccountTaskState::STATE_WORKING, tr("Initializing")); + nextStep(); +} + +void AuthFlow::nextStep() +{ + if (!Task::isRunning()) { + return; + } + if (m_steps.size() == 0) { + // we got to the end without an incident... assume this is all. + m_currentStep.reset(); + succeed(); + return; + } + m_currentStep = m_steps.front(); + qDebug() << "AuthFlow:" << m_currentStep->describe(); + setStatus(m_currentStep->describe()); + m_steps.pop_front(); + connect(m_currentStep.get(), &AuthStep::finished, this, &AuthFlow::stepFinished); + + m_currentStep->perform(); +} + +void AuthFlow::stepFinished(AccountTaskState resultingState, QString message) +{ + if (changeState(resultingState, message)) + nextStep(); +} + +bool AuthFlow::changeState(AccountTaskState newState, QString reason) +{ + m_taskState = newState; + setDetails(reason); + switch (newState) { + case AccountTaskState::STATE_CREATED: { + setStatus(tr("Waiting...")); + m_data->errorString.clear(); + return true; + } + case AccountTaskState::STATE_WORKING: { + if (!m_currentStep) { + setStatus(tr("Preparing to log in...")); + } + m_data->accountState = AccountState::Working; + return true; + } + case AccountTaskState::STATE_SUCCEEDED: { + setStatus(tr("Authentication task succeeded.")); + m_data->accountState = AccountState::Online; + emitSucceeded(); + return false; + } + case AccountTaskState::STATE_OFFLINE: { + setStatus(tr("Failed to contact the authentication server.")); + m_data->errorString = reason; + m_data->accountState = AccountState::Offline; + emitFailed(reason); + return false; + } + case AccountTaskState::STATE_DISABLED: { + setStatus(tr("Client ID has changed. New session needs to be created.")); + m_data->errorString = reason; + m_data->accountState = AccountState::Disabled; + emitFailed(reason); + return false; + } + case AccountTaskState::STATE_FAILED_SOFT: { + setStatus(tr("Encountered an error during authentication.")); + m_data->errorString = reason; + m_data->accountState = AccountState::Errored; + emitFailed(reason); + return false; + } + case AccountTaskState::STATE_FAILED_HARD: { + setStatus(tr("Failed to authenticate. The session has expired.")); + m_data->errorString = reason; + m_data->accountState = AccountState::Expired; + emitFailed(reason); + return false; + } + case AccountTaskState::STATE_FAILED_GONE: { + setStatus(tr("Failed to authenticate. The account no longer exists.")); + m_data->errorString = reason; + m_data->accountState = AccountState::Gone; + emitFailed(reason); + return false; + } + default: { + setStatus(tr("...")); + QString error = tr("Unknown account task state: %1").arg(int(newState)); + m_data->accountState = AccountState::Errored; + emitFailed(error); + return false; + } + } +} +bool AuthFlow::abort() +{ + if (m_currentStep) + m_currentStep->abort(); + emitAborted(); + return true; +} diff --git a/launcher/minecraft/auth/AuthFlow.h b/launcher/minecraft/auth/AuthFlow.h new file mode 100644 index 0000000..d881a76 --- /dev/null +++ b/launcher/minecraft/auth/AuthFlow.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include + +#include "minecraft/auth/AccountData.h" +#include "minecraft/auth/AuthStep.h" +#include "tasks/Task.h" + +class AuthFlow : public Task { + Q_OBJECT + + public: + enum class Action { Refresh, Login, DeviceCode }; + + explicit AuthFlow(AccountData* data, Action action = Action::Refresh); + virtual ~AuthFlow() = default; + + void executeTask() override; + + AccountTaskState taskState() { return m_taskState; } + + public slots: + bool abort() override; + + signals: + void authorizeWithBrowser(const QUrl& url); + void authorizeWithBrowserWithExtra(QString url, QString code, int expiresIn); + + protected: + void succeed(); + void nextStep(); + + private slots: + // NOTE: true -> non-terminal state, false -> terminal state + bool changeState(AccountTaskState newState, QString reason = QString()); + void stepFinished(AccountTaskState resultingState, QString message); + + private: + AccountTaskState m_taskState = AccountTaskState::STATE_CREATED; + QList m_steps; + AuthStep::Ptr m_currentStep; + AccountData* m_data = nullptr; +}; diff --git a/launcher/minecraft/auth/AuthSession.cpp b/launcher/minecraft/auth/AuthSession.cpp new file mode 100644 index 0000000..85d77be --- /dev/null +++ b/launcher/minecraft/auth/AuthSession.cpp @@ -0,0 +1,36 @@ +#include "AuthSession.h" +#include +#include +#include +#include + +QString AuthSession::serializeUserProperties() +{ + QJsonObject userAttrs; + /* + for (auto key : u.properties.keys()) + { + auto array = QJsonArray::fromStringList(u.properties.values(key)); + userAttrs.insert(key, array); + } + */ + QJsonDocument value(userAttrs); + return value.toJson(QJsonDocument::Compact); +} + +bool AuthSession::MakeOffline(QString offline_playername) +{ + session = "-"; + access_token = "0"; + player_name = offline_playername; + return true; +} + +void AuthSession::MakeDemo(QString name, QString u) +{ + uuid = u; + session = "-"; + access_token = "0"; + player_name = name; + launchMode = LaunchMode::Demo; +}; diff --git a/launcher/minecraft/auth/AuthSession.h b/launcher/minecraft/auth/AuthSession.h new file mode 100644 index 0000000..07db542 --- /dev/null +++ b/launcher/minecraft/auth/AuthSession.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include + +#include "LaunchMode.h" + +class MinecraftAccount; + +struct AuthSession { + bool MakeOffline(QString offline_playername); + void MakeDemo(QString name, QString uuid); + + QString serializeUserProperties(); + + // combined session ID + QString session; + // volatile auth token + QString access_token; + // profile name + QString player_name; + // profile ID + QString uuid; + // 'msa' or 'offline', depending on account type + QString user_type; + // the actual launch mode for this session + LaunchMode launchMode; +}; + +using AuthSessionPtr = std::shared_ptr; diff --git a/launcher/minecraft/auth/AuthStep.h b/launcher/minecraft/auth/AuthStep.h new file mode 100644 index 0000000..f813150 --- /dev/null +++ b/launcher/minecraft/auth/AuthStep.h @@ -0,0 +1,43 @@ +#pragma once +#include +#include + +#include "QObjectPtr.h" +#include "minecraft/auth/AccountData.h" + +/** + * Enum for describing the state of the current task. + * Used by the getStateMessage function to determine what the status message should be. + */ +enum class AccountTaskState { + STATE_CREATED, + STATE_WORKING, + STATE_SUCCEEDED, + STATE_DISABLED, //!< MSA Client ID has changed. Tell user to reloginn + STATE_FAILED_SOFT, //!< soft failure. authentication went through partially + STATE_FAILED_HARD, //!< hard failure. main tokens are invalid + STATE_FAILED_GONE, //!< hard failure. main tokens are invalid, and the account no longer exists + STATE_OFFLINE //!< soft failure. authentication failed in the first step in a 'soft' way +}; + +class AuthStep : public QObject { + Q_OBJECT + + public: + using Ptr = shared_qobject_ptr; + + explicit AuthStep(AccountData* data) : QObject(nullptr), m_data(data) {}; + virtual ~AuthStep() noexcept = default; + + virtual QString describe() = 0; + + public slots: + virtual void perform() = 0; + virtual void abort() {} + + signals: + void finished(AccountTaskState resultingState, QString message); + + protected: + AccountData* m_data; +}; diff --git a/launcher/minecraft/auth/MinecraftAccount.cpp b/launcher/minecraft/auth/MinecraftAccount.cpp new file mode 100644 index 0000000..e346f01 --- /dev/null +++ b/launcher/minecraft/auth/MinecraftAccount.cpp @@ -0,0 +1,297 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Authors: Orochimarufan + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "MinecraftAccount.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include "minecraft/auth/AccountData.h" +#include "minecraft/auth/AuthFlow.h" + +MinecraftAccount::MinecraftAccount(QObject* parent) : QObject(parent) +{ + data.internalId = QUuid::createUuid().toString(QUuid::Id128); +} + +MinecraftAccountPtr MinecraftAccount::loadFromJsonV3(const QJsonObject& json) +{ + MinecraftAccountPtr account(new MinecraftAccount()); + if (account->data.resumeStateFromV3(json)) { + return account; + } + return nullptr; +} + +MinecraftAccountPtr MinecraftAccount::createBlankMSA() +{ + MinecraftAccountPtr account(new MinecraftAccount()); + account->data.type = AccountType::MSA; + return account; +} + +MinecraftAccountPtr MinecraftAccount::createOffline(const QString& username) +{ + auto account = makeShared(); + account->data.type = AccountType::Offline; + account->data.yggdrasilToken.token = "0"; + account->data.yggdrasilToken.validity = Validity::Certain; + account->data.yggdrasilToken.issueInstant = QDateTime::currentDateTimeUtc(); + account->data.yggdrasilToken.extra["userName"] = username; + account->data.yggdrasilToken.extra["clientToken"] = QUuid::createUuid().toString(QUuid::Id128); + account->data.minecraftProfile.id = uuidFromUsername(username).toString(QUuid::Id128); + account->data.minecraftProfile.name = username; + account->data.minecraftProfile.validity = Validity::Certain; + return account; +} + +QJsonObject MinecraftAccount::saveToJson() const +{ + return data.saveState(); +} + +AccountState MinecraftAccount::accountState() const +{ + return data.accountState; +} + +QPixmap MinecraftAccount::getFace(int width, int height) const +{ + QPixmap skinTexture; + if (!skinTexture.loadFromData(data.minecraftProfile.skin.data, "PNG")) { + return QPixmap(); + } + QPixmap skin = QPixmap(8, 8); + skin.fill(QColorConstants::Transparent); + QPainter painter(&skin); + painter.drawPixmap(0, 0, skinTexture.copy(8, 8, 8, 8)); + painter.drawPixmap(0, 0, skinTexture.copy(40, 8, 8, 8)); + return skin.scaled(width, height, Qt::KeepAspectRatio); +} + +shared_qobject_ptr MinecraftAccount::login(bool useDeviceCode) +{ + Q_ASSERT(m_currentTask.get() == nullptr); + + m_currentTask.reset(new AuthFlow(&data, useDeviceCode ? AuthFlow::Action::DeviceCode : AuthFlow::Action::Login)); + connect(m_currentTask.get(), &Task::succeeded, this, &MinecraftAccount::authSucceeded); + connect(m_currentTask.get(), &Task::failed, this, &MinecraftAccount::authFailed); + connect(m_currentTask.get(), &Task::aborted, this, [this] { authFailed(tr("Aborted")); }); + emit activityChanged(true); + return m_currentTask; +} + +shared_qobject_ptr MinecraftAccount::refresh() +{ + if (m_currentTask) { + return m_currentTask; + } + + m_currentTask.reset(new AuthFlow(&data, AuthFlow::Action::Refresh)); + + connect(m_currentTask.get(), &Task::succeeded, this, &MinecraftAccount::authSucceeded); + connect(m_currentTask.get(), &Task::failed, this, &MinecraftAccount::authFailed); + connect(m_currentTask.get(), &Task::aborted, this, [this] { authFailed(tr("Aborted")); }); + emit activityChanged(true); + return m_currentTask; +} + +shared_qobject_ptr MinecraftAccount::currentTask() +{ + return m_currentTask; +} + +void MinecraftAccount::authSucceeded() +{ + m_currentTask.reset(); + emit changed(); + emit activityChanged(false); +} + +void MinecraftAccount::authFailed(QString reason) +{ + switch (m_currentTask->taskState()) { + case AccountTaskState::STATE_OFFLINE: + case AccountTaskState::STATE_DISABLED: { + // NOTE: user will need to fix this themselves. + } + case AccountTaskState::STATE_FAILED_SOFT: { + // NOTE: this doesn't do much. There was an error of some sort. + } break; + case AccountTaskState::STATE_FAILED_HARD: { + if (accountType() == AccountType::MSA) { + data.msaToken.token = QString(); + data.msaToken.refresh_token = QString(); + data.msaToken.validity = Validity::None; + data.validity_ = Validity::None; + } else { + data.yggdrasilToken.token = QString(); + data.yggdrasilToken.validity = Validity::None; + data.validity_ = Validity::None; + } + emit changed(); + } break; + case AccountTaskState::STATE_FAILED_GONE: { + data.validity_ = Validity::None; + emit changed(); + } break; + case AccountTaskState::STATE_WORKING: { + data.accountState = AccountState::Unchecked; + } break; + case AccountTaskState::STATE_CREATED: + case AccountTaskState::STATE_SUCCEEDED: { + // Not reachable here, as they are not failures. + } + } + m_currentTask.reset(); + emit activityChanged(false); +} + +QString MinecraftAccount::displayName() const +{ + if (const QList validStates{ AccountState::Unchecked, AccountState::Working, AccountState::Offline, AccountState::Online }; !validStates.contains(accountState())) { + return QString("⚠ %1").arg(profileName()); + } + return profileName(); +} + +bool MinecraftAccount::isActive() const +{ + return !m_currentTask.isNull(); +} + +bool MinecraftAccount::shouldRefresh() const +{ + /* + * Never refresh accounts that are being used by the game, it breaks the game session. + * Always refresh accounts that have not been refreshed yet during this session. + * Don't refresh broken accounts. + * Refresh accounts that would expire in the next 12 hours (fresh token validity is 24 hours). + */ + if (isInUse()) { + return false; + } + switch (data.validity_) { + case Validity::Certain: { + break; + } + case Validity::None: { + return false; + } + case Validity::Assumed: { + return true; + } + } + auto now = QDateTime::currentDateTimeUtc(); + auto issuedTimestamp = data.yggdrasilToken.issueInstant; + auto expiresTimestamp = data.yggdrasilToken.notAfter; + + if (!expiresTimestamp.isValid()) { + expiresTimestamp = issuedTimestamp.addSecs(24 * 3600); + } + if (now.secsTo(expiresTimestamp) < (12 * 3600)) { + return true; + } + return false; +} + +void MinecraftAccount::fillSession(AuthSessionPtr session) +{ + // volatile auth token + session->access_token = data.accessToken(); + // profile name + session->player_name = data.profileName(); + // profile ID + session->uuid = data.profileId(); + if (session->uuid.isEmpty()) + session->uuid = uuidFromUsername(session->player_name).toString(QUuid::Id128); + // 'legacy' or 'mojang', depending on account type + session->user_type = typeString(); + if (!session->access_token.isEmpty()) { + session->session = "token:" + data.accessToken() + ":" + data.profileId(); + } else { + session->session = "-"; + } +} + +void MinecraftAccount::decrementUses() +{ + Usable::decrementUses(); + if (!isInUse()) { + emit changed(); + // FIXME: we now need a better way to identify accounts... + qWarning() << "Profile" << data.profileId() << "is no longer in use."; + } +} + +void MinecraftAccount::incrementUses() +{ + bool wasInUse = isInUse(); + Usable::incrementUses(); + if (!wasInUse) { + emit changed(); + // FIXME: we now need a better way to identify accounts... + qWarning() << "Profile" << data.profileId() << "is now in use."; + } +} + +QUuid MinecraftAccount::uuidFromUsername(QString username) +{ + auto input = QString("OfflinePlayer:%1").arg(username).toUtf8(); + + // basically a reimplementation of Java's UUID#nameUUIDFromBytes + QByteArray digest = QCryptographicHash::hash(input, QCryptographicHash::Md5); + + auto bOr = [](QByteArray& array, qsizetype index, uint8_t value) { array[index] |= value; }; + auto bAnd = [](QByteArray& array, qsizetype index, uint8_t value) { array[index] &= value; }; + bAnd(digest, 6, 0x0f); // clear version + bOr(digest, 6, 0x30); // set to version 3 + bAnd(digest, 8, 0x3f); // clear variant + bOr(digest, 8, 0x80); // set to IETF variant + + return QUuid::fromRfc4122(digest); +} diff --git a/launcher/minecraft/auth/MinecraftAccount.h b/launcher/minecraft/auth/MinecraftAccount.h new file mode 100644 index 0000000..8b6b003 --- /dev/null +++ b/launcher/minecraft/auth/MinecraftAccount.h @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "AccountData.h" +#include "AuthSession.h" +#include "QObjectPtr.h" +#include "Usable.h" +#include "minecraft/auth/AuthFlow.h" + +class Task; +class MinecraftAccount; + +using MinecraftAccountPtr = shared_qobject_ptr; +Q_DECLARE_METATYPE(MinecraftAccountPtr) + +/** + * A profile within someone's Mojang account. + * + * Currently, the profile system has not been implemented by Mojang yet, + * but we might as well add some things for it in Prism Launcher right now so + * we don't have to rip the code to pieces to add it later. + */ +struct AccountProfile { + QString id; + QString name; + bool legacy; +}; + +/** + * Object that stores information about a certain Mojang account. + * + * Said information may include things such as that account's username, client token, and access + * token if the user chose to stay logged in. + */ +class MinecraftAccount : public QObject, public Usable { + Q_OBJECT + public: /* construction */ + //! Do not copy accounts. ever. + explicit MinecraftAccount(const MinecraftAccount& other, QObject* parent) = delete; + + //! Default constructor + explicit MinecraftAccount(QObject* parent = 0); + + static MinecraftAccountPtr createBlankMSA(); + + static MinecraftAccountPtr createOffline(const QString& username); + + static MinecraftAccountPtr loadFromJsonV3(const QJsonObject& json); + + static QUuid uuidFromUsername(QString username); + + //! Saves a MinecraftAccount to a JSON object and returns it. + QJsonObject saveToJson() const; + + public: /* manipulation */ + shared_qobject_ptr login(bool useDeviceCode = false); + + shared_qobject_ptr refresh(); + + shared_qobject_ptr currentTask(); + + public: /* queries */ + QString internalId() const { return data.internalId; } + + QString accessToken() const { return data.accessToken(); } + + QString profileId() const { return data.profileId(); } + + QString profileName() const { return data.profileName(); } + + QString displayName() const; + + bool isActive() const; + + AccountType accountType() const noexcept { return data.type; } + + bool ownsMinecraft() const { return true; } + + bool hasProfile() const { return data.profileId().size() != 0; } + + QString typeString() const + { + switch (data.type) { + case AccountType::MSA: { + return "msa"; + } break; + case AccountType::Offline: { + return "offline"; + } break; + default: { + return "unknown"; + } + } + } + + QPixmap getFace(int width = 64, int height = 64) const; + + //! Returns the current state of the account + AccountState accountState() const; + + AccountData* accountData() { return &data; } + + bool shouldRefresh() const; + + void fillSession(AuthSessionPtr session); + + QString lastError() const { return data.lastError(); } + + signals: + /** + * This signal is emitted when the account changes + */ + void changed(); + + void activityChanged(bool active); + + // TODO: better signalling for the various possible state changes - especially errors + + protected: /* variables */ + AccountData data; + + // current task we are executing here + shared_qobject_ptr m_currentTask; + + protected: /* methods */ + void incrementUses() override; + void decrementUses() override; + + private slots: + void authSucceeded(); + void authFailed(QString reason); +}; diff --git a/launcher/minecraft/auth/Parsers.cpp b/launcher/minecraft/auth/Parsers.cpp new file mode 100644 index 0000000..08c7806 --- /dev/null +++ b/launcher/minecraft/auth/Parsers.cpp @@ -0,0 +1,496 @@ +#include "Parsers.h" +#include "Json.h" +#include "Logging.h" + +#include +#include +#include + +namespace Parsers { + +bool getDateTime(QJsonValue value, QDateTime& out) +{ + if (!value.isString()) { + return false; + } + out = QDateTime::fromString(value.toString(), Qt::ISODate); + return out.isValid(); +} + +bool getString(QJsonValue value, QString& out) +{ + if (!value.isString()) { + return false; + } + out = value.toString(); + return true; +} + +bool getNumber(QJsonValue value, double& out) +{ + if (!value.isDouble()) { + return false; + } + out = value.toDouble(); + return true; +} + +bool getNumber(QJsonValue value, int64_t& out) +{ + if (!value.isDouble()) { + return false; + } + out = (int64_t)value.toDouble(); + return true; +} + +bool getBool(QJsonValue value, bool& out) +{ + if (!value.isBool()) { + return false; + } + out = value.toBool(); + return true; +} + +/* +{ + "IssueInstant":"2020-12-07T19:52:08.4463796Z", + "NotAfter":"2020-12-21T19:52:08.4463796Z", + "Token":"token", + "DisplayClaims":{ + "xui":[ + { + "uhs":"userhash" + } + ] + } + } +*/ +// TODO: handle error responses ... +/* +{ + "Identity":"0", + "XErr":2148916238, + "Message":"", + "Redirect":"https://start.ui.xboxlive.com/AddChildToFamily" +} +// 2148916233 = missing Xbox account +// 2148916238 = child account not linked to a family +*/ + +bool parseXTokenResponse(QByteArray& data, Token& output, QString name) +{ + qDebug() << "Parsing" << name << ":"; + qCDebug(authCredentials()) << data; + QJsonParseError jsonError; + QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); + if (jsonError.error) { + qWarning() << "Failed to parse response from user.auth.xboxlive.com as JSON:" << jsonError.errorString(); + return false; + } + + auto obj = doc.object(); + if (!getDateTime(obj.value("IssueInstant"), output.issueInstant)) { + qWarning() << "User IssueInstant is not a timestamp"; + return false; + } + if (!getDateTime(obj.value("NotAfter"), output.notAfter)) { + qWarning() << "User NotAfter is not a timestamp"; + return false; + } + if (!getString(obj.value("Token"), output.token)) { + qWarning() << "User Token is not a string"; + return false; + } + auto arrayVal = obj.value("DisplayClaims").toObject().value("xui"); + if (!arrayVal.isArray()) { + qWarning() << "Missing xui claims array"; + return false; + } + bool foundUHS = false; + for (auto item : arrayVal.toArray()) { + if (!item.isObject()) { + continue; + } + auto obj_ = item.toObject(); + if (obj_.contains("uhs")) { + foundUHS = true; + } else { + continue; + } + // consume all 'display claims' ... whatever that means + for (auto iter = obj_.begin(); iter != obj_.end(); iter++) { + QString claim; + if (!getString(obj_.value(iter.key()), claim)) { + qWarning() << "display claim" << iter.key() << "is not a string..."; + return false; + } + output.extra[iter.key()] = claim; + } + + break; + } + if (!foundUHS) { + qWarning() << "Missing uhs"; + return false; + } + output.validity = Validity::Certain; + qDebug() << name << "is valid."; + return true; +} + +bool parseMinecraftProfile(QByteArray& data, MinecraftProfile& output) +{ + qDebug() << "Parsing Minecraft profile..."; + qCDebug(authCredentials()) << data; + + QJsonParseError jsonError; + QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); + if (jsonError.error) { + qWarning() << "Failed to parse response from user.auth.xboxlive.com as JSON:" << jsonError.errorString(); + return false; + } + + auto obj = doc.object(); + if (!getString(obj.value("id"), output.id)) { + qWarning() << "Minecraft profile id is not a string"; + return false; + } + + if (!getString(obj.value("name"), output.name)) { + qWarning() << "Minecraft profile name is not a string"; + return false; + } + + auto skinsArray = obj.value("skins").toArray(); + for (auto skin : skinsArray) { + auto skinObj = skin.toObject(); + Skin skinOut; + if (!getString(skinObj.value("id"), skinOut.id)) { + continue; + } + QString state; + if (!getString(skinObj.value("state"), state)) { + continue; + } + if (state != "ACTIVE") { + continue; + } + if (!getString(skinObj.value("url"), skinOut.url)) { + continue; + } + skinOut.url.replace("http://textures.minecraft.net", "https://textures.minecraft.net"); + if (!getString(skinObj.value("variant"), skinOut.variant)) { + continue; + } + // we deal with only the active skin + output.skin = skinOut; + break; + } + auto capesArray = obj.value("capes").toArray(); + + QString currentCape; + for (auto cape : capesArray) { + auto capeObj = cape.toObject(); + Cape capeOut; + if (!getString(capeObj.value("id"), capeOut.id)) { + continue; + } + QString state; + if (!getString(capeObj.value("state"), state)) { + continue; + } + if (state == "ACTIVE") { + currentCape = capeOut.id; + } + if (!getString(capeObj.value("url"), capeOut.url)) { + continue; + } + capeOut.url.replace("http://textures.minecraft.net", "https://textures.minecraft.net"); + if (!getString(capeObj.value("alias"), capeOut.alias)) { + continue; + } + + output.capes[capeOut.id] = capeOut; + } + output.currentCape = currentCape; + output.validity = Validity::Certain; + return true; +} + +namespace { +// these skin URLs are for the MHF_Steve and MHF_Alex accounts (made by a Mojang employee) +// they are needed because the session server doesn't return skin urls for default skins +static const QString SKIN_URL_STEVE = + "https://textures.minecraft.net/texture/1a4af718455d4aab528e7a61f86fa25e6a369d1768dcb13f7df319a713eb810b"; +static const QString SKIN_URL_ALEX = + "https://textures.minecraft.net/texture/83cee5ca6afcdb171285aa00e8049c297b2dbeba0efb8ff970a5677a1b644032"; + +bool isDefaultModelSteve(QString uuid) +{ + // need to calculate *Java* hashCode of UUID + // if number is even, skin/model is steve, otherwise it is alex + + // just in case dashes are in the id + uuid.remove('-'); + + if (uuid.size() != 32) { + return true; + } + + // qulonglong is guaranteed to be 64 bits + // we need to use unsigned numbers to guarantee truncation below + qulonglong most = uuid.left(16).toULongLong(nullptr, 16); + qulonglong least = uuid.right(16).toULongLong(nullptr, 16); + qulonglong xored = most ^ least; + return ((static_cast(xored >> 32)) ^ static_cast(xored)) % 2 == 0; +} +} // namespace + +/** +Uses session server for skin/cape lookup instead of profile, +because locked Mojang accounts cannot access profile endpoint +(https://api.minecraftservices.com/minecraft/profile/) + +ref: https://wiki.vg/Mojang_API#UUID_to_Profile_and_Skin.2FCape + +{ + "id": "", + "name": "", + "properties": [ + { + "name": "textures", + "value": "" + } + ] +} + +decoded base64 "value": +{ + "timestamp": , + "profileId": "", + "profileName": "", + "textures": { + "SKIN": { + "url": "" + }, + "CAPE": { + "url": "" + } + } +} +*/ + +bool parseMinecraftProfileMojang(QByteArray& data, MinecraftProfile& output) +{ + qDebug() << "Parsing Minecraft profile..."; + qCDebug(authCredentials()) << data; + + QJsonParseError jsonError; + QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); + if (jsonError.error) { + qWarning() << "Failed to parse response as JSON:" << jsonError.errorString(); + return false; + } + + auto obj = Json::requireObject(doc, "mojang minecraft profile"); + if (!getString(obj.value("id"), output.id)) { + qWarning() << "Minecraft profile id is not a string"; + return false; + } + + if (!getString(obj.value("name"), output.name)) { + qWarning() << "Minecraft profile name is not a string"; + return false; + } + + auto propsArray = obj.value("properties").toArray(); + QByteArray texturePayload; + for (auto p : propsArray) { + auto pObj = p.toObject(); + auto name = pObj.value("name"); + if (!name.isString() || name.toString() != "textures") { + continue; + } + + auto value = pObj.value("value"); + if (value.isString()) { + texturePayload = QByteArray::fromBase64(value.toString().toUtf8(), QByteArray::AbortOnBase64DecodingErrors); + } + + if (!texturePayload.isEmpty()) { + break; + } + } + + if (texturePayload.isNull()) { + qWarning() << "No texture payload data"; + return false; + } + + doc = QJsonDocument::fromJson(texturePayload, &jsonError); + if (jsonError.error) { + qWarning() << "Failed to parse response as JSON:" << jsonError.errorString(); + return false; + } + + obj = Json::requireObject(doc, "session texture payload"); + auto textures = obj.value("textures"); + if (!textures.isObject()) { + qWarning() << "No textures array in response"; + return false; + } + + Skin skinOut; + // fill in default skin info ourselves, as this endpoint doesn't provide it + bool steve = isDefaultModelSteve(output.id); + skinOut.variant = steve ? "CLASSIC" : "SLIM"; + skinOut.url = steve ? SKIN_URL_STEVE : SKIN_URL_ALEX; + // sadly we can't figure this out, but I don't think it really matters... + skinOut.id = "00000000-0000-0000-0000-000000000000"; + Cape capeOut; + auto tObj = textures.toObject(); + for (auto idx = tObj.constBegin(); idx != tObj.constEnd(); ++idx) { + if (idx->isObject()) { + if (idx.key() == "SKIN") { + auto skin = idx->toObject(); + if (!getString(skin.value("url"), skinOut.url)) { + qWarning() << "Skin url is not a string"; + return false; + } + skinOut.url.replace("http://textures.minecraft.net", "https://textures.minecraft.net"); + + auto maybeMeta = skin.find("metadata"); + if (maybeMeta != skin.end() && maybeMeta->isObject()) { + auto meta = maybeMeta->toObject(); + // might not be present + getString(meta.value("model"), skinOut.variant); + } + } else if (idx.key() == "CAPE") { + auto cape = idx->toObject(); + if (!getString(cape.value("url"), capeOut.url)) { + qWarning() << "Cape url is not a string"; + return false; + } + capeOut.url.replace("http://textures.minecraft.net", "https://textures.minecraft.net"); + + // we don't know the cape ID as it is not returned from the session server + // so just fake it - changing capes is probably locked anyway :( + capeOut.alias = "cape"; + } + } + } + + output.skin = skinOut; + if (capeOut.alias == "cape") { + output.capes = QMap({ { capeOut.alias, capeOut } }); + output.currentCape = capeOut.alias; + } + + output.validity = Validity::Certain; + return true; +} + +bool parseMinecraftEntitlements(QByteArray& data, MinecraftEntitlement& output) +{ + qDebug() << "Parsing Minecraft entitlements..."; + qCDebug(authCredentials()) << data; + + QJsonParseError jsonError; + QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); + if (jsonError.error) { + qWarning() << "Failed to parse response from user.auth.xboxlive.com as JSON:" << jsonError.errorString(); + return false; + } + + auto obj = doc.object(); + output.canPlayMinecraft = false; + output.ownsMinecraft = false; + + auto itemsArray = obj.value("items").toArray(); + for (auto item : itemsArray) { + auto itemObj = item.toObject(); + QString name; + if (!getString(itemObj.value("name"), name)) { + continue; + } + if (name == "game_minecraft") { + output.canPlayMinecraft = true; + } + if (name == "product_minecraft") { + output.ownsMinecraft = true; + } + } + output.validity = Validity::Certain; + return true; +} + +bool parseRolloutResponse(QByteArray& data, bool& result) +{ + qDebug() << "Parsing Rollout response..."; + qCDebug(authCredentials()) << data; + + QJsonParseError jsonError; + QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); + if (jsonError.error) { + qWarning() << "Failed to parse response from https://api.minecraftservices.com/rollout/v1/msamigration as JSON: " + << jsonError.errorString(); + return false; + } + + auto obj = doc.object(); + QString feature; + if (!getString(obj.value("feature"), feature)) { + qWarning() << "Rollout feature is not a string"; + return false; + } + if (feature != "msamigration") { + qWarning() << "Rollout feature is not what we expected (msamigration), but is instead \"" << feature << "\""; + return false; + } + if (!getBool(obj.value("rollout"), result)) { + qWarning() << "Rollout feature is not a string"; + return false; + } + return true; +} + +bool parseMojangResponse(QByteArray& data, Token& output) +{ + QJsonParseError jsonError; + qDebug() << "Parsing Mojang response..."; + qCDebug(authCredentials()) << data; + QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); + if (jsonError.error) { + qWarning() << "Failed to parse response from api.minecraftservices.com/launcher/login as JSON:" << jsonError.errorString(); + return false; + } + + auto obj = doc.object(); + double expires_in = 0; + if (!getNumber(obj.value("expires_in"), expires_in)) { + qWarning() << "expires_in is not a valid number"; + return false; + } + auto currentTime = QDateTime::currentDateTimeUtc(); + output.issueInstant = currentTime; + output.notAfter = currentTime.addSecs(expires_in); + + QString username; + if (!getString(obj.value("username"), username)) { + qWarning() << "username is not valid"; + return false; + } + + // TODO: it's a JWT... validate it? + if (!getString(obj.value("access_token"), output.token)) { + qWarning() << "access_token is not valid"; + return false; + } + output.validity = Validity::Certain; + qDebug() << "Mojang response is valid."; + return true; +} + +} // namespace Parsers diff --git a/launcher/minecraft/auth/Parsers.h b/launcher/minecraft/auth/Parsers.h new file mode 100644 index 0000000..4a235e4 --- /dev/null +++ b/launcher/minecraft/auth/Parsers.h @@ -0,0 +1,19 @@ +#pragma once + +#include "AccountData.h" + +namespace Parsers { +bool getDateTime(QJsonValue value, QDateTime& out); +bool getString(QJsonValue value, QString& out); +bool getNumber(QJsonValue value, double& out); +bool getNumber(QJsonValue value, int64_t& out); +bool getBool(QJsonValue value, bool& out); + +bool parseXTokenResponse(QByteArray& data, Token& output, QString name); +bool parseMojangResponse(QByteArray& data, Token& output); + +bool parseMinecraftProfile(QByteArray& data, MinecraftProfile& output); +bool parseMinecraftProfileMojang(QByteArray& data, MinecraftProfile& output); +bool parseMinecraftEntitlements(QByteArray& data, MinecraftEntitlement& output); +bool parseRolloutResponse(QByteArray& data, bool& result); +} // namespace Parsers diff --git a/launcher/minecraft/auth/steps/EntitlementsStep.cpp b/launcher/minecraft/auth/steps/EntitlementsStep.cpp new file mode 100644 index 0000000..1a4e9aa --- /dev/null +++ b/launcher/minecraft/auth/steps/EntitlementsStep.cpp @@ -0,0 +1,57 @@ +#include "EntitlementsStep.h" + +#include +#include +#include +#include +#include + +#include "Application.h" +#include "Logging.h" +#include "minecraft/auth/Parsers.h" +#include "net/Download.h" +#include "net/NetJob.h" +#include "net/RawHeaderProxy.h" +#include "tasks/Task.h" + +EntitlementsStep::EntitlementsStep(AccountData* data) : AuthStep(data) {} + +QString EntitlementsStep::describe() +{ + return tr("Determining game ownership."); +} + +void EntitlementsStep::perform() +{ + m_entitlements_request_id = QUuid::createUuid().toString(QUuid::WithoutBraces); + + QUrl url("https://api.minecraftservices.com/entitlements/license?requestId=" + m_entitlements_request_id); + auto headers = QList{ { "Content-Type", "application/json" }, + { "Accept", "application/json" }, + { "Authorization", QString("Bearer %1").arg(m_data->yggdrasilToken.token).toUtf8() } }; + + auto [request, response] = Net::Download::makeByteArray(url); + m_request = request; + m_request->addHeaderProxy(std::make_unique(headers)); + m_request->enableAutoRetry(true); + + m_task.reset(new NetJob("EntitlementsStep", APPLICATION->network())); + m_task->setAskRetry(false); + m_task->addNetAction(m_request); + + connect(m_task.get(), &Task::finished, this, [this, response] { onRequestDone(response); }); + + m_task->start(); + qDebug() << "Getting entitlements..."; +} + +void EntitlementsStep::onRequestDone(QByteArray* response) +{ + qCDebug(authCredentials()) << *response; + + // TODO: check presence of same entitlementsRequestId? + // TODO: validate JWTs? + Parsers::parseMinecraftEntitlements(*response, m_data->minecraftEntitlement); + + emit finished(AccountTaskState::STATE_WORKING, tr("Got entitlements")); +} diff --git a/launcher/minecraft/auth/steps/EntitlementsStep.h b/launcher/minecraft/auth/steps/EntitlementsStep.h new file mode 100644 index 0000000..72f77da --- /dev/null +++ b/launcher/minecraft/auth/steps/EntitlementsStep.h @@ -0,0 +1,26 @@ +#pragma once +#include + +#include "minecraft/auth/AuthStep.h" +#include "net/Download.h" +#include "net/NetJob.h" + +class EntitlementsStep : public AuthStep { + Q_OBJECT + + public: + explicit EntitlementsStep(AccountData* data); + virtual ~EntitlementsStep() noexcept = default; + + void perform() override; + + QString describe() override; + + private slots: + void onRequestDone(QByteArray* response); + + private: + QString m_entitlements_request_id; + Net::Download::Ptr m_request; + NetJob::Ptr m_task; +}; diff --git a/launcher/minecraft/auth/steps/GetSkinStep.cpp b/launcher/minecraft/auth/steps/GetSkinStep.cpp new file mode 100644 index 0000000..7b26ca4 --- /dev/null +++ b/launcher/minecraft/auth/steps/GetSkinStep.cpp @@ -0,0 +1,37 @@ + +#include "GetSkinStep.h" + +#include + +#include "Application.h" + +GetSkinStep::GetSkinStep(AccountData* data) : AuthStep(data) {} + +QString GetSkinStep::describe() +{ + return tr("Getting skin."); +} + +void GetSkinStep::perform() +{ + QUrl url(m_data->minecraftProfile.skin.url); + + auto [request, response] = Net::Download::makeByteArray(url); + m_request = request; + m_request->enableAutoRetry(true); + + m_task.reset(new NetJob("GetSkinStep", APPLICATION->network())); + m_task->setAskRetry(false); + m_task->addNetAction(m_request); + + connect(m_task.get(), &Task::finished, this, [this, response] { onRequestDone(response); }); + + m_task->start(); +} + +void GetSkinStep::onRequestDone(QByteArray* response) +{ + if (m_request->error() == QNetworkReply::NoError) + m_data->minecraftProfile.skin.data = *response; + emit finished(AccountTaskState::STATE_WORKING, tr("Got skin")); +} diff --git a/launcher/minecraft/auth/steps/GetSkinStep.h b/launcher/minecraft/auth/steps/GetSkinStep.h new file mode 100644 index 0000000..2cd74ab --- /dev/null +++ b/launcher/minecraft/auth/steps/GetSkinStep.h @@ -0,0 +1,25 @@ +#pragma once +#include + +#include "minecraft/auth/AuthStep.h" +#include "net/Download.h" +#include "net/NetJob.h" + +class GetSkinStep : public AuthStep { + Q_OBJECT + + public: + explicit GetSkinStep(AccountData* data); + virtual ~GetSkinStep() noexcept = default; + + void perform() override; + + QString describe() override; + + private slots: + void onRequestDone(QByteArray* response); + + private: + Net::Download::Ptr m_request; + NetJob::Ptr m_task; +}; diff --git a/launcher/minecraft/auth/steps/LauncherLoginStep.cpp b/launcher/minecraft/auth/steps/LauncherLoginStep.cpp new file mode 100644 index 0000000..89293c2 --- /dev/null +++ b/launcher/minecraft/auth/steps/LauncherLoginStep.cpp @@ -0,0 +1,74 @@ +#include "LauncherLoginStep.h" + +#include +#include + +#include "Application.h" +#include "Logging.h" +#include "minecraft/auth/Parsers.h" +#include "net/NetUtils.h" +#include "net/RawHeaderProxy.h" +#include "net/Upload.h" + +LauncherLoginStep::LauncherLoginStep(AccountData* data) : AuthStep(data) {} + +QString LauncherLoginStep::describe() +{ + return tr("Fetching Minecraft access token"); +} + +void LauncherLoginStep::perform() +{ + QUrl url("https://api.minecraftservices.com/launcher/login"); + auto uhs = m_data->mojangservicesToken.extra["uhs"].toString(); + auto xToken = m_data->mojangservicesToken.token; + + QString mc_auth_template = R"XXX( +{ + "xtoken": "XBL3.0 x=%1;%2", + "platform": "PC_LAUNCHER" +} +)XXX"; + auto requestBody = mc_auth_template.arg(uhs, xToken); + + auto headers = QList{ + { "Content-Type", "application/json" }, + { "Accept", "application/json" }, + }; + + auto [request, response] = Net::Upload::makeByteArray(url, requestBody.toUtf8()); + m_request = request; + m_request->addHeaderProxy(std::make_unique(headers)); + m_request->enableAutoRetry(true); + + m_task.reset(new NetJob("LauncherLoginStep", APPLICATION->network())); + m_task->setAskRetry(false); + m_task->addNetAction(m_request); + + connect(m_task.get(), &Task::finished, this, [this, response] { onRequestDone(response); }); + + m_task->start(); + qDebug() << "Getting Minecraft access token..."; +} + +void LauncherLoginStep::onRequestDone(QByteArray* response) +{ + qCDebug(authCredentials()) << *response; + if (m_request->error() != QNetworkReply::NoError) { + qWarning() << "Reply error:" << m_request->error(); + if (Net::isApplicationError(m_request->error())) { + emit finished(AccountTaskState::STATE_FAILED_SOFT, + tr("Failed to get Minecraft access token: %1").arg(m_request->errorString())); + } else { + emit finished(AccountTaskState::STATE_OFFLINE, tr("Failed to get Minecraft access token: %1").arg(m_request->errorString())); + } + return; + } + + if (!Parsers::parseMojangResponse(*response, m_data->yggdrasilToken)) { + qWarning() << "Could not parse login_with_xbox response..."; + emit finished(AccountTaskState::STATE_FAILED_SOFT, tr("Failed to parse the Minecraft access token response.")); + return; + } + emit finished(AccountTaskState::STATE_WORKING, tr("Got Minecraft access token")); +} diff --git a/launcher/minecraft/auth/steps/LauncherLoginStep.h b/launcher/minecraft/auth/steps/LauncherLoginStep.h new file mode 100644 index 0000000..2501f57 --- /dev/null +++ b/launcher/minecraft/auth/steps/LauncherLoginStep.h @@ -0,0 +1,25 @@ +#pragma once +#include + +#include "minecraft/auth/AuthStep.h" +#include "net/NetJob.h" +#include "net/Upload.h" + +class LauncherLoginStep : public AuthStep { + Q_OBJECT + + public: + explicit LauncherLoginStep(AccountData* data); + virtual ~LauncherLoginStep() noexcept = default; + + void perform() override; + + QString describe() override; + + private slots: + void onRequestDone(QByteArray* response); + + private: + Net::Upload::Ptr m_request; + NetJob::Ptr m_task; +}; diff --git a/launcher/minecraft/auth/steps/MSADeviceCodeStep.cpp b/launcher/minecraft/auth/steps/MSADeviceCodeStep.cpp new file mode 100644 index 0000000..3feb685 --- /dev/null +++ b/launcher/minecraft/auth/steps/MSADeviceCodeStep.cpp @@ -0,0 +1,278 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2024 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "MSADeviceCodeStep.h" + +#include +#include + +#include "Application.h" +#include "Json.h" +#include "net/RawHeaderProxy.h" + +// https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-device-code +MSADeviceCodeStep::MSADeviceCodeStep(AccountData* data) : AuthStep(data) +{ + m_clientId = APPLICATION->getMSAClientID(); + connect(&m_expiration_timer, &QTimer::timeout, this, &MSADeviceCodeStep::abort); + connect(&m_pool_timer, &QTimer::timeout, this, &MSADeviceCodeStep::authenticateUser); +} + +QString MSADeviceCodeStep::describe() +{ + return tr("Logging in with Microsoft account(device code)."); +} + +void MSADeviceCodeStep::perform() +{ + QUrlQuery data; + data.addQueryItem("client_id", m_clientId); + data.addQueryItem("scope", "XboxLive.SignIn XboxLive.offline_access"); + auto payload = data.query(QUrl::FullyEncoded).toUtf8(); + QUrl url("https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode"); + auto headers = QList{ + { "Content-Type", "application/x-www-form-urlencoded" }, + { "Accept", "application/json" }, + }; + auto [request, response] = Net::Upload::makeByteArray(url, payload); + m_request = request; + m_request->addHeaderProxy(std::make_unique(headers)); + m_request->enableAutoRetry(true); + + m_task.reset(new NetJob("MSADeviceCodeStep", APPLICATION->network())); + m_task->setAskRetry(false); + m_task->addNetAction(m_request); + + connect(m_task.get(), &Task::finished, this, [this, response] { deviceAuthorizationFinished(response); }); + + m_task->start(); +} + +struct DeviceAuthorizationResponse { + QString device_code; + QString user_code; + QString verification_uri; + int expires_in; + int interval; + + QString error; + QString error_description; +}; + +DeviceAuthorizationResponse parseDeviceAuthorizationResponse(const QByteArray& data) +{ + QJsonParseError err; + QJsonDocument doc = QJsonDocument::fromJson(data, &err); + if (err.error != QJsonParseError::NoError) { + qWarning() << "Failed to parse device authorization response due to err:" << err.errorString(); + return {}; + } + + if (!doc.isObject()) { + qWarning() << "Device authorization response is not an object"; + return {}; + } + auto obj = doc.object(); + return { + obj["device_code"].toString(), obj["user_code"].toString(), obj["verification_uri"].toString(), obj["expires_in"].toInt(), + obj["interval"].toInt(), obj["error"].toString(), obj["error_description"].toString(), + }; +} + +void MSADeviceCodeStep::deviceAuthorizationFinished(QByteArray* response) +{ + auto rsp = parseDeviceAuthorizationResponse(*response); + if (!rsp.error.isEmpty() || !rsp.error_description.isEmpty()) { + qWarning() << "Device authorization failed:" << rsp.error; + emit finished(AccountTaskState::STATE_FAILED_HARD, + tr("Device authorization failed: %1").arg(rsp.error_description.isEmpty() ? rsp.error : rsp.error_description)); + return; + } + if (!m_request->wasSuccessful() || m_request->error() != QNetworkReply::NoError) { + qWarning() << "Device authorization failed:" << *response; + emit finished(AccountTaskState::STATE_FAILED_HARD, tr("Failed to retrieve device authorization")); + return; + } + + if (rsp.device_code.isEmpty() || rsp.user_code.isEmpty() || rsp.verification_uri.isEmpty() || rsp.expires_in == 0) { + qWarning() << "Device authorization failed: required fields missing"; + emit finished(AccountTaskState::STATE_FAILED_HARD, tr("Device authorization failed: required fields missing")); + return; + } + if (rsp.interval != 0) { + interval = rsp.interval; + } + m_device_code = rsp.device_code; + emit authorizeWithBrowser(rsp.verification_uri, rsp.user_code, rsp.expires_in); + m_expiration_timer.setTimerType(Qt::VeryCoarseTimer); + m_expiration_timer.setInterval(rsp.expires_in * 1000); + m_expiration_timer.setSingleShot(true); + m_expiration_timer.start(); + m_pool_timer.setTimerType(Qt::VeryCoarseTimer); + m_pool_timer.setSingleShot(true); + startPoolTimer(); +} + +void MSADeviceCodeStep::abort() +{ + m_expiration_timer.stop(); + m_pool_timer.stop(); + if (m_request) { + m_request->abort(); + } + m_is_aborted = true; + emit finished(AccountTaskState::STATE_FAILED_HARD, tr("Task aborted")); +} + +void MSADeviceCodeStep::startPoolTimer() +{ + if (m_is_aborted) { + return; + } + if (m_expiration_timer.remainingTime() < interval * 1000) { + perform(); + return; + } + + m_pool_timer.setInterval(interval * 1000); + m_pool_timer.start(); +} + +void MSADeviceCodeStep::authenticateUser() +{ + QUrlQuery data; + data.addQueryItem("client_id", m_clientId); + data.addQueryItem("grant_type", "urn:ietf:params:oauth:grant-type:device_code"); + data.addQueryItem("device_code", m_device_code); + auto payload = data.query(QUrl::FullyEncoded).toUtf8(); + QUrl url("https://login.microsoftonline.com/consumers/oauth2/v2.0/token"); + auto headers = QList{ + { "Content-Type", "application/x-www-form-urlencoded" }, + { "Accept", "application/json" }, + }; + auto [request, response] = Net::Upload::makeByteArray(url, payload); + m_request = request; + m_request->addHeaderProxy(std::make_unique(headers)); + + connect(m_request.get(), &Task::finished, this, [this, response] { authenticationFinished(response); }); + + m_request->setNetwork(APPLICATION->network()); + m_request->start(); +} + +struct AuthenticationResponse { + QString access_token; + QString token_type; + QString refresh_token; + int expires_in; + + QString error; + QString error_description; + + QVariantMap extra; +}; + +AuthenticationResponse parseAuthenticationResponse(const QByteArray& data) +{ + QJsonParseError err; + QJsonDocument doc = QJsonDocument::fromJson(data, &err); + if (err.error != QJsonParseError::NoError) { + qWarning() << "Failed to parse device authorization response due to err:" << err.errorString(); + return {}; + } + + if (!doc.isObject()) { + qWarning() << "Device authorization response is not an object"; + return {}; + } + auto obj = doc.object(); + return { obj["access_token"].toString(), + obj["token_type"].toString(), + obj["refresh_token"].toString(), + obj["expires_in"].toInt(), + obj["error"].toString(), + obj["error_description"].toString(), + obj.toVariantMap() }; +} + +void MSADeviceCodeStep::authenticationFinished(QByteArray* response) +{ + if (m_request->error() == QNetworkReply::TimeoutError) { + // rfc8628#section-3.5 + // "On encountering a connection timeout, clients MUST unilaterally + // reduce their polling frequency before retrying. The use of an + // exponential backoff algorithm to achieve this, such as doubling the + // polling interval on each such connection timeout, is RECOMMENDED." + interval *= 2; + startPoolTimer(); + return; + } + auto rsp = parseAuthenticationResponse(*response); + if (rsp.error == "slow_down") { + // rfc8628#section-3.5 + // "A variant of 'authorization_pending', the authorization request is + // still pending and polling should continue, but the interval MUST + // be increased by 5 seconds for this and all subsequent requests." + interval += 5; + startPoolTimer(); + return; + } + if (rsp.error == "authorization_pending") { + // keep trying - rfc8628#section-3.5 + // "The authorization request is still pending as the end user hasn't + // yet completed the user-interaction steps (Section 3.3)." + startPoolTimer(); + return; + } + if (!rsp.error.isEmpty() || !rsp.error_description.isEmpty()) { + qWarning() << "Device Access failed:" << rsp.error; + emit finished(AccountTaskState::STATE_FAILED_HARD, + tr("Device Access failed: %1").arg(rsp.error_description.isEmpty() ? rsp.error : rsp.error_description)); + return; + } + if (!m_request->wasSuccessful() || m_request->error() != QNetworkReply::NoError) { + startPoolTimer(); // it failed so just try again without increasing the interval + return; + } + + m_expiration_timer.stop(); + m_data->msaClientID = m_clientId; + m_data->msaToken.issueInstant = QDateTime::currentDateTimeUtc(); + m_data->msaToken.notAfter = QDateTime::currentDateTime().addSecs(rsp.expires_in); + m_data->msaToken.extra = rsp.extra; + m_data->msaToken.refresh_token = rsp.refresh_token; + m_data->msaToken.token = rsp.access_token; + emit finished(AccountTaskState::STATE_WORKING, tr("Got MSA token")); +} diff --git a/launcher/minecraft/auth/steps/MSADeviceCodeStep.h b/launcher/minecraft/auth/steps/MSADeviceCodeStep.h new file mode 100644 index 0000000..cfb8270 --- /dev/null +++ b/launcher/minecraft/auth/steps/MSADeviceCodeStep.h @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2024 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once +#include +#include + +#include "minecraft/auth/AuthStep.h" +#include "net/NetJob.h" +#include "net/Upload.h" + +class MSADeviceCodeStep : public AuthStep { + Q_OBJECT + public: + explicit MSADeviceCodeStep(AccountData* data); + virtual ~MSADeviceCodeStep() noexcept = default; + + void perform() override; + + QString describe() override; + + public slots: + void abort() override; + + signals: + void authorizeWithBrowser(QString url, QString code, int expiresIn); + + private slots: + void deviceAuthorizationFinished(QByteArray* response); + void startPoolTimer(); + void authenticateUser(); + void authenticationFinished(QByteArray* response); + + private: + QString m_clientId; + QString m_device_code; + bool m_is_aborted = false; + int interval = 5; + + QTimer m_pool_timer; + QTimer m_expiration_timer; + + Net::Upload::Ptr m_request; + NetJob::Ptr m_task; +}; diff --git a/launcher/minecraft/auth/steps/MSAStep.cpp b/launcher/minecraft/auth/steps/MSAStep.cpp new file mode 100644 index 0000000..51a5e5c --- /dev/null +++ b/launcher/minecraft/auth/steps/MSAStep.cpp @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "MSAStep.h" + +#include +#include +#include +#include +#include + +#include "Application.h" +#include "BuildConfig.h" +#include "FileSystem.h" + +#include +#include +#include + +bool isSchemeHandlerRegistered() +{ +#ifdef Q_OS_LINUX + QProcess process; + process.start("xdg-mime", { "query", "default", "x-scheme-handler/" + BuildConfig.LAUNCHER_APP_BINARY_NAME }); + process.waitForFinished(); + QString output = process.readAllStandardOutput().trimmed(); + + return output.contains(APPLICATION->desktopFileName()); + +#elif defined(Q_OS_WIN) + QString regPath = QString("HKEY_CURRENT_USER\\Software\\Classes\\%1").arg(BuildConfig.LAUNCHER_APP_BINARY_NAME); + QSettings settings(regPath, QSettings::NativeFormat); + + const QString registeredRunCommand = settings.value("shell/open/command/.").toString().replace("\\", "/"); + return registeredRunCommand.contains(QCoreApplication::applicationFilePath()); +#endif + return true; +} + +class CustomOAuthOobReplyHandler : public QOAuthOobReplyHandler { + Q_OBJECT + + public: + explicit CustomOAuthOobReplyHandler(QObject* parent = nullptr) : QOAuthOobReplyHandler(parent) + { + connect(APPLICATION, &Application::oauthReplyRecieved, this, &QOAuthOobReplyHandler::callbackReceived); + } + ~CustomOAuthOobReplyHandler() override + { + disconnect(APPLICATION, &Application::oauthReplyRecieved, this, &QOAuthOobReplyHandler::callbackReceived); + } + QString callback() const override { return BuildConfig.LAUNCHER_APP_BINARY_NAME + "://oauth/microsoft"; } + + protected: + void networkReplyFinished(QNetworkReply* reply) override + { + if (reply->error() != QNetworkReply::NoError) { + qWarning() << "OAuth2 request failed:" << reply->readAll(); + } + + QOAuthOobReplyHandler::networkReplyFinished(reply); + } +}; + +class LoggingOAuthHttpServerReplyHandler final : public QOAuthHttpServerReplyHandler { + Q_OBJECT + + public: + explicit LoggingOAuthHttpServerReplyHandler(QObject* parent = nullptr) : QOAuthHttpServerReplyHandler(parent) {} + + protected: + void networkReplyFinished(QNetworkReply* reply) override + { + if (reply->error() != QNetworkReply::NoError) { + qWarning() << "OAuth2 request failed:" << reply->readAll(); + } + + QOAuthHttpServerReplyHandler::networkReplyFinished(reply); + } +}; + +MSAStep::MSAStep(AccountData* data, bool silent) : AuthStep(data), m_silent(silent) +{ + m_clientId = APPLICATION->getMSAClientID(); + if (QCoreApplication::applicationFilePath().startsWith("/tmp/.mount_") || APPLICATION->isPortable() || !isSchemeHandlerRegistered()) + + { + auto replyHandler = new LoggingOAuthHttpServerReplyHandler(this); + replyHandler->setCallbackText(QString(R"XXX( + + Login Successful, redirecting... + + )XXX") + .arg(BuildConfig.LOGIN_CALLBACK_URL)); + m_oauth2.setReplyHandler(replyHandler); + } else { + m_oauth2.setReplyHandler(new CustomOAuthOobReplyHandler(this)); + } + m_oauth2.setAuthorizationUrl(QUrl("https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize")); + m_oauth2.setAccessTokenUrl(QUrl("https://login.microsoftonline.com/consumers/oauth2/v2.0/token")); + m_oauth2.setScope("XboxLive.SignIn XboxLive.offline_access"); + m_oauth2.setClientIdentifier(m_clientId); + m_oauth2.setNetworkAccessManager(APPLICATION->network()); + + connect(&m_oauth2, &QOAuth2AuthorizationCodeFlow::granted, this, [this] { + m_data->msaClientID = m_oauth2.clientIdentifier(); + m_data->msaToken.issueInstant = QDateTime::currentDateTimeUtc(); + m_data->msaToken.notAfter = m_oauth2.expirationAt(); + m_data->msaToken.extra = m_oauth2.extraTokens(); + m_data->msaToken.refresh_token = m_oauth2.refreshToken(); + m_data->msaToken.token = m_oauth2.token(); + emit finished(AccountTaskState::STATE_WORKING, tr("Got MSA token")); + }); + connect(&m_oauth2, &QOAuth2AuthorizationCodeFlow::authorizeWithBrowser, this, &MSAStep::authorizeWithBrowser); + connect(&m_oauth2, &QOAuth2AuthorizationCodeFlow::requestFailed, this, [this, silent](const QAbstractOAuth2::Error err) { + auto state = AccountTaskState::STATE_FAILED_HARD; + if (m_oauth2.status() == QAbstractOAuth::Status::Granted || silent) { + if (err == QAbstractOAuth2::Error::NetworkError) { + state = AccountTaskState::STATE_OFFLINE; + } else { + state = AccountTaskState::STATE_FAILED_SOFT; + } + } + auto message = tr("Microsoft user authentication failed."); + if (silent) { + message = tr("Failed to refresh token."); + } + qWarning() << message; + emit finished(state, message); + }); + connect(&m_oauth2, &QOAuth2AuthorizationCodeFlow::error, this, + [this](const QString& error, const QString& errorDescription, const QUrl& uri) { + qWarning() << "Failed to login because" << error << errorDescription; + emit finished(AccountTaskState::STATE_FAILED_HARD, errorDescription); + }); + + connect(&m_oauth2, &QOAuth2AuthorizationCodeFlow::extraTokensChanged, this, + [this](const QVariantMap& tokens) { m_data->msaToken.extra = tokens; }); + + connect(&m_oauth2, &QOAuth2AuthorizationCodeFlow::clientIdentifierChanged, this, + [this](const QString& clientIdentifier) { m_data->msaClientID = clientIdentifier; }); +} + +QString MSAStep::describe() +{ + return tr("Logging in with Microsoft account."); +} + +void MSAStep::perform() +{ + if (m_silent) { + if (m_data->msaClientID != m_clientId) { + emit finished(AccountTaskState::STATE_DISABLED, + tr("Microsoft user authentication failed - client identification has changed.")); + return; + } + if (m_data->msaToken.refresh_token.isEmpty()) { + emit finished(AccountTaskState::STATE_DISABLED, tr("Microsoft user authentication failed - refresh token is empty.")); + return; + } + m_oauth2.setRefreshToken(m_data->msaToken.refresh_token); + m_oauth2.refreshAccessToken(); + } else { + m_oauth2.setModifyParametersFunction( + [](QAbstractOAuth::Stage stage, QMultiMap* map) { map->insert("prompt", "select_account"); }); + + *m_data = AccountData(); + m_data->msaClientID = m_clientId; + m_oauth2.grant(); + } +} + +#include "MSAStep.moc" diff --git a/launcher/minecraft/auth/steps/MSAStep.h b/launcher/minecraft/auth/steps/MSAStep.h new file mode 100644 index 0000000..2f4e781 --- /dev/null +++ b/launcher/minecraft/auth/steps/MSAStep.h @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once +#include + +#include "minecraft/auth/AuthStep.h" + +#include +class MSAStep : public AuthStep { + Q_OBJECT + public: + explicit MSAStep(AccountData* data, bool silent = false); + virtual ~MSAStep() noexcept = default; + + void perform() override; + + QString describe() override; + + signals: + void authorizeWithBrowser(const QUrl& url); + + private: + bool m_silent; + QString m_clientId; + QOAuth2AuthorizationCodeFlow m_oauth2; +}; diff --git a/launcher/minecraft/auth/steps/MinecraftProfileStep.cpp b/launcher/minecraft/auth/steps/MinecraftProfileStep.cpp new file mode 100644 index 0000000..418c46a --- /dev/null +++ b/launcher/minecraft/auth/steps/MinecraftProfileStep.cpp @@ -0,0 +1,71 @@ +#include "MinecraftProfileStep.h" + +#include + +#include "Application.h" +#include "minecraft/auth/Parsers.h" +#include "net/NetUtils.h" +#include "net/RawHeaderProxy.h" + +MinecraftProfileStep::MinecraftProfileStep(AccountData* data) : AuthStep(data) {} + +QString MinecraftProfileStep::describe() +{ + return tr("Fetching the Minecraft profile."); +} + +void MinecraftProfileStep::perform() +{ + QUrl url("https://api.minecraftservices.com/minecraft/profile"); + auto headers = QList{ { "Content-Type", "application/json" }, + { "Accept", "application/json" }, + { "Authorization", QString("Bearer %1").arg(m_data->yggdrasilToken.token).toUtf8() } }; + + auto [request, response] = Net::Download::makeByteArray(url); + m_request = request; + m_request->addHeaderProxy(std::make_unique(headers)); + m_request->enableAutoRetry(true); + + m_task.reset(new NetJob("MinecraftProfileStep", APPLICATION->network())); + m_task->setAskRetry(false); + m_task->addNetAction(m_request); + + connect(m_task.get(), &Task::finished, this, [this, response] { onRequestDone(response); }); + + m_task->start(); +} + +void MinecraftProfileStep::onRequestDone(QByteArray* response) +{ + if (m_request->error() == QNetworkReply::ContentNotFoundError) { + // NOTE: Succeed even if we do not have a profile. This is a valid account state. + m_data->minecraftProfile = MinecraftProfile(); + emit finished(AccountTaskState::STATE_WORKING, tr("Account has no Minecraft profile.")); + return; + } + if (m_request->error() != QNetworkReply::NoError) { + qWarning() << "Error getting profile:"; + qWarning() << " HTTP Status :" << m_request->replyStatusCode(); + qWarning() << " Internal error no.:" << m_request->error(); + qWarning() << " Error string :" << m_request->errorString(); + + qWarning() << " Response:"; + qWarning() << QString::fromUtf8(*response); + + if (Net::isApplicationError(m_request->error())) { + emit finished(AccountTaskState::STATE_FAILED_SOFT, + tr("Minecraft Java profile acquisition failed: %1").arg(m_request->errorString())); + } else { + emit finished(AccountTaskState::STATE_OFFLINE, + tr("Minecraft Java profile acquisition failed: %1").arg(m_request->errorString())); + } + return; + } + if (!Parsers::parseMinecraftProfile(*response, m_data->minecraftProfile)) { + m_data->minecraftProfile = MinecraftProfile(); + emit finished(AccountTaskState::STATE_FAILED_SOFT, tr("Minecraft Java profile response could not be parsed")); + return; + } + + emit finished(AccountTaskState::STATE_WORKING, tr("Got Minecraft profile")); +} diff --git a/launcher/minecraft/auth/steps/MinecraftProfileStep.h b/launcher/minecraft/auth/steps/MinecraftProfileStep.h new file mode 100644 index 0000000..5348f5b --- /dev/null +++ b/launcher/minecraft/auth/steps/MinecraftProfileStep.h @@ -0,0 +1,25 @@ +#pragma once +#include + +#include "minecraft/auth/AuthStep.h" +#include "net/Download.h" +#include "net/NetJob.h" + +class MinecraftProfileStep : public AuthStep { + Q_OBJECT + + public: + explicit MinecraftProfileStep(AccountData* data); + virtual ~MinecraftProfileStep() noexcept = default; + + void perform() override; + + QString describe() override; + + private slots: + void onRequestDone(QByteArray* response); + + private: + Net::Download::Ptr m_request; + NetJob::Ptr m_task; +}; diff --git a/launcher/minecraft/auth/steps/XboxAuthorizationStep.cpp b/launcher/minecraft/auth/steps/XboxAuthorizationStep.cpp new file mode 100644 index 0000000..9e101d4 --- /dev/null +++ b/launcher/minecraft/auth/steps/XboxAuthorizationStep.cpp @@ -0,0 +1,172 @@ +#include "XboxAuthorizationStep.h" + +#include +#include +#include + +#include "Application.h" +#include "Logging.h" +#include "minecraft/auth/Parsers.h" +#include "net/NetUtils.h" +#include "net/RawHeaderProxy.h" +#include "net/Upload.h" + +XboxAuthorizationStep::XboxAuthorizationStep(AccountData* data, Token* token, QString relyingParty, QString authorizationKind) + : AuthStep(data), m_token(token), m_relyingParty(relyingParty), m_authorizationKind(authorizationKind) +{} + +QString XboxAuthorizationStep::describe() +{ + return tr("Getting authorization to access %1 services.").arg(m_authorizationKind); +} + +void XboxAuthorizationStep::perform() +{ + QString xbox_auth_template = R"XXX( +{ + "Properties": { + "SandboxId": "RETAIL", + "UserTokens": [ + "%1" + ] + }, + "RelyingParty": "%2", + "TokenType": "JWT" +} +)XXX"; + auto xbox_auth_data = xbox_auth_template.arg(m_data->userToken.token, m_relyingParty); + // http://xboxlive.com + QUrl url("https://xsts.auth.xboxlive.com/xsts/authorize"); + auto headers = QList{ + { "Content-Type", "application/json" }, + { "Accept", "application/json" }, + { "x-xbl-contract-version", "1" } + }; + auto [request, response] = Net::Upload::makeByteArray(url, xbox_auth_data.toUtf8()); + m_request = request; + m_request->addHeaderProxy(std::make_unique(headers)); + m_request->enableAutoRetry(true); + + m_task.reset(new NetJob("XboxAuthorizationStep", APPLICATION->network())); + m_task->setAskRetry(false); + m_task->addNetAction(m_request); + + connect(m_task.get(), &Task::finished, this, [this, response] { onRequestDone(response); }); + + m_task->start(); + qDebug() << "Getting authorization token for" << m_relyingParty; +} + +void XboxAuthorizationStep::onRequestDone(QByteArray* response) +{ + qCDebug(authCredentials()) << *response; + if (m_request->error() != QNetworkReply::NoError) { + qWarning() << "Reply error:" << m_request->error(); + if (Net::isApplicationError(m_request->error())) { + if (!processSTSError(*response)) { + emit finished(AccountTaskState::STATE_FAILED_SOFT, + tr("Failed to get authorization for %1 services. Error %2.").arg(m_authorizationKind, m_request->error())); + } else { + emit finished(AccountTaskState::STATE_FAILED_SOFT, + tr("Unknown STS error for %1 services: %2").arg(m_authorizationKind, m_request->errorString())); + } + } else { + emit finished(AccountTaskState::STATE_OFFLINE, + tr("Failed to get authorization for %1 services: %2").arg(m_authorizationKind, m_request->errorString())); + } + return; + } + + Token temp; + if (!Parsers::parseXTokenResponse(*response, temp, m_authorizationKind)) { + emit finished(AccountTaskState::STATE_FAILED_SOFT, + tr("Could not parse authorization response for access to %1 services.").arg(m_authorizationKind)); + return; + } + + if (temp.extra["uhs"] != m_data->userToken.extra["uhs"]) { + emit finished(AccountTaskState::STATE_FAILED_SOFT, + tr("Server has changed %1 authorization user hash in the reply. Something is wrong.").arg(m_authorizationKind)); + return; + } + auto& token = *m_token; + token = temp; + + emit finished(AccountTaskState::STATE_WORKING, tr("Got authorization to access %1").arg(m_relyingParty)); +} + +bool XboxAuthorizationStep::processSTSError(const QByteArray& response) +{ + if (m_request->error() == QNetworkReply::AuthenticationRequiredError) { + QJsonParseError jsonError; + QJsonDocument doc = QJsonDocument::fromJson(response, &jsonError); + if (jsonError.error) { + qWarning() << "Cannot parse error XSTS response as JSON:" << jsonError.errorString(); + emit finished(AccountTaskState::STATE_FAILED_SOFT, + tr("Cannot parse %1 authorization error response as JSON: %2").arg(m_authorizationKind, jsonError.errorString())); + return true; + } + + int64_t errorCode = -1; + auto obj = doc.object(); + if (!Parsers::getNumber(obj.value("XErr"), errorCode)) { + emit finished(AccountTaskState::STATE_FAILED_SOFT, + tr("XErr element is missing from %1 authorization error response.").arg(m_authorizationKind)); + return true; + } + switch (errorCode) { + case 2148916233: { + emit finished(AccountTaskState::STATE_FAILED_SOFT, + tr("This Microsoft account does not have an Xbox Live profile. Buy the game on %1 first.") + .arg("minecraft.net")); + return true; + } + case 2148916235: { + // NOTE: this is the Grulovia error + emit finished(AccountTaskState::STATE_FAILED_SOFT, tr("Xbox Live is not available in your country. You've been blocked.")); + return true; + } + case 2148916238: { + emit finished( + AccountTaskState::STATE_FAILED_SOFT, + tr("This Microsoft account is underaged and is not linked to a family.\n\nPlease set up your account according to %1.") + .arg("help.minecraft.net")); + return true; + } + // the following codes where copied from: https://github.com/PrismarineJS/prismarine-auth/pull/44 + case 2148916236: { + emit finished(AccountTaskState::STATE_FAILED_SOFT, + tr("This Microsoft account requires proof of age to play. Please login to %1 to provide proof of age.") + .arg("login.live.com")); + return true; + } + case 2148916237: + emit finished(AccountTaskState::STATE_FAILED_SOFT, tr("This Microsoft account has reached its limit for playtime. This " + "Microsoft account has been blocked from logging in.")); + return true; + case 2148916227: { + emit finished(AccountTaskState::STATE_FAILED_SOFT, tr("This Microsoft account was banned by Xbox for violating one or more " + "Community Standards for Xbox and is unable to be used.")); + return true; + } + case 2148916229: { + emit finished(AccountTaskState::STATE_FAILED_SOFT, + tr("This Microsoft account is currently restricted and your guardian has not given you permission to play " + "online. Login to %1 and have your guardian change your permissions.") + .arg("account.microsoft.com")); + return true; + } + case 2148916234: { + emit finished(AccountTaskState::STATE_FAILED_SOFT, + tr("This Microsoft account has not accepted Xbox's Terms of Service. Please login and accept them.")); + return true; + } + default: { + emit finished(AccountTaskState::STATE_FAILED_SOFT, + tr("XSTS authentication ended with unrecognized error(s):\n\n%1").arg(errorCode)); + return true; + } + } + } + return false; +} diff --git a/launcher/minecraft/auth/steps/XboxAuthorizationStep.h b/launcher/minecraft/auth/steps/XboxAuthorizationStep.h new file mode 100644 index 0000000..9f424c0 --- /dev/null +++ b/launcher/minecraft/auth/steps/XboxAuthorizationStep.h @@ -0,0 +1,32 @@ +#pragma once +#include + +#include "minecraft/auth/AuthStep.h" +#include "net/NetJob.h" +#include "net/Upload.h" + +class XboxAuthorizationStep : public AuthStep { + Q_OBJECT + + public: + explicit XboxAuthorizationStep(AccountData* data, Token* token, QString relyingParty, QString authorizationKind); + virtual ~XboxAuthorizationStep() noexcept = default; + + void perform() override; + + QString describe() override; + + private: + bool processSTSError(const QByteArray& response); + + private slots: + void onRequestDone(QByteArray* response); + + private: + Token* m_token; + QString m_relyingParty; + QString m_authorizationKind; + + Net::Upload::Ptr m_request; + NetJob::Ptr m_task; +}; diff --git a/launcher/minecraft/auth/steps/XboxUserStep.cpp b/launcher/minecraft/auth/steps/XboxUserStep.cpp new file mode 100644 index 0000000..97544d0 --- /dev/null +++ b/launcher/minecraft/auth/steps/XboxUserStep.cpp @@ -0,0 +1,75 @@ +#include "XboxUserStep.h" + +#include + +#include "Application.h" +#include "minecraft/auth/Parsers.h" +#include "net/NetUtils.h" +#include "net/RawHeaderProxy.h" + +XboxUserStep::XboxUserStep(AccountData* data) : AuthStep(data) {} + +QString XboxUserStep::describe() +{ + return tr("Logging in as an Xbox user."); +} + +void XboxUserStep::perform() +{ + QString xbox_auth_template = R"XXX( +{ + "Properties": { + "AuthMethod": "RPS", + "SiteName": "user.auth.xboxlive.com", + "RpsTicket": "d=%1" + }, + "RelyingParty": "http://auth.xboxlive.com", + "TokenType": "JWT" +} +)XXX"; + auto xbox_auth_data = xbox_auth_template.arg(m_data->msaToken.token); + + QUrl url("https://user.auth.xboxlive.com/user/authenticate"); + auto headers = QList{ + { "Content-Type", "application/json" }, + { "Accept", "application/json" }, + // set contract-version header (prevent err 400 bad-request?) + // https://learn.microsoft.com/en-us/gaming/gdk/_content/gc/reference/live/rest/additional/httpstandardheaders + { "x-xbl-contract-version", "1" } + }; + auto [request, response] = Net::Upload::makeByteArray(url, xbox_auth_data.toUtf8()); + m_request = request; + m_request->addHeaderProxy(std::make_unique(headers)); + m_request->enableAutoRetry(true); + + m_task.reset(new NetJob("XboxUserStep", APPLICATION->network())); + m_task->setAskRetry(false); + m_task->addNetAction(m_request); + + connect(m_task.get(), &Task::finished, this, [this, response] { onRequestDone(response); }); + + m_task->start(); + qDebug() << "First layer of Xbox auth ... commencing."; +} + +void XboxUserStep::onRequestDone(QByteArray* response) +{ + if (m_request->error() != QNetworkReply::NoError) { + qWarning() << "Reply error:" << m_request->error(); + if (Net::isApplicationError(m_request->error())) { + emit finished(AccountTaskState::STATE_FAILED_SOFT, tr("Xbox user authentication failed: %1").arg(m_request->errorString())); + } else { + emit finished(AccountTaskState::STATE_OFFLINE, tr("Xbox user authentication failed: %1").arg(m_request->errorString())); + } + return; + } + + Token temp; + if (!Parsers::parseXTokenResponse(*response, temp, "UToken")) { + qWarning() << "Could not parse user authentication response..."; + emit finished(AccountTaskState::STATE_FAILED_SOFT, tr("Xbox user authentication response could not be understood.")); + return; + } + m_data->userToken = temp; + emit finished(AccountTaskState::STATE_WORKING, tr("Got Xbox user token")); +} diff --git a/launcher/minecraft/auth/steps/XboxUserStep.h b/launcher/minecraft/auth/steps/XboxUserStep.h new file mode 100644 index 0000000..b6330a4 --- /dev/null +++ b/launcher/minecraft/auth/steps/XboxUserStep.h @@ -0,0 +1,25 @@ +#pragma once +#include + +#include "minecraft/auth/AuthStep.h" +#include "net/NetJob.h" +#include "net/Upload.h" + +class XboxUserStep : public AuthStep { + Q_OBJECT + + public: + explicit XboxUserStep(AccountData* data); + virtual ~XboxUserStep() noexcept = default; + + void perform() override; + + QString describe() override; + + private slots: + void onRequestDone(QByteArray* response); + + private: + Net::Upload::Ptr m_request; + NetJob::Ptr m_task; +}; diff --git a/launcher/minecraft/launch/AutoInstallJava.cpp b/launcher/minecraft/launch/AutoInstallJava.cpp new file mode 100644 index 0000000..f60780f --- /dev/null +++ b/launcher/minecraft/launch/AutoInstallJava.cpp @@ -0,0 +1,251 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023-2024 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "AutoInstallJava.h" +#include +#include +#include + +#include "Application.h" +#include "FileSystem.h" +#include "MessageLevel.h" +#include "QObjectPtr.h" +#include "SysInfo.h" +#include "java/JavaInstall.h" +#include "java/JavaInstallList.h" +#include "java/JavaUtils.h" +#include "java/JavaVersion.h" +#include "java/download/ArchiveDownloadTask.h" +#include "java/download/ManifestDownloadTask.h" +#include "java/download/SymlinkTask.h" +#include "meta/Index.h" +#include "minecraft/MinecraftInstance.h" +#include "minecraft/PackProfile.h" +#include "net/Mode.h" +#include "tasks/SequentialTask.h" + +AutoInstallJava::AutoInstallJava(LaunchTask* parent) + : LaunchStep(parent), m_instance(m_parent->instance()), m_supported_arch(SysInfo::getSupportedJavaArchitecture()) {}; + +void AutoInstallJava::executeTask() +{ + auto settings = m_instance->settings(); + if (!APPLICATION->settings()->get("AutomaticJavaSwitch").toBool() || + (settings->get("OverrideJavaLocation").toBool() && QFileInfo::exists(settings->get("JavaPath").toString()))) { + emitSucceeded(); + return; + } + auto packProfile = m_instance->getPackProfile(); + if (!APPLICATION->settings()->get("AutomaticJavaDownload").toBool()) { + auto javas = APPLICATION->javalist(); + m_current_task = javas->getLoadTask(); + connect(m_current_task.get(), &Task::finished, this, [this, javas, packProfile] { + for (auto i = 0; i < javas->count(); i++) { + auto java = std::dynamic_pointer_cast(javas->at(i)); + if (java && packProfile->getProfile()->getCompatibleJavaMajors().contains(java->id.major())) { + if (!java->is_64bit) { + emit logLine(tr("The automatic Java mechanism detected a 32-bit installation of Java."), MessageLevel::Launcher); + } + setJavaPath(java->path); + return; + } + } + emit logLine(tr("No compatible Java version was found. Using the default one."), MessageLevel::Warning); + emitSucceeded(); + }); + connect(m_current_task.get(), &Task::progress, this, &AutoInstallJava::setProgress); + connect(m_current_task.get(), &Task::stepProgress, this, &AutoInstallJava::propagateStepProgress); + connect(m_current_task.get(), &Task::status, this, &AutoInstallJava::setStatus); + connect(m_current_task.get(), &Task::details, this, &AutoInstallJava::setDetails); + emit progressReportingRequest(); + return; + } + if (m_supported_arch.isEmpty()) { + emit logLine(tr("Your system (%1-%2) is not compatible with automatic Java installation. Using the default Java path.") + .arg(SysInfo::currentSystem(), SysInfo::useQTForArch()), + MessageLevel::Warning); + emitSucceeded(); + return; + } + auto wantedJavaName = packProfile->getProfile()->getCompatibleJavaName(); + if (wantedJavaName.isEmpty()) { + emit logLine(tr("Your meta information is out of date or doesn't have the information necessary to determine what installation of " + "Java should be used. " + "Using the default Java path."), + MessageLevel::Warning); + emitSucceeded(); + return; + } + QDir javaDir(APPLICATION->javaPath()); + auto relativeBinary = FS::PathCombine(wantedJavaName, "bin", JavaUtils::javaExecutable); + auto wantedJavaPath = javaDir.absoluteFilePath(relativeBinary); + if (QFileInfo::exists(wantedJavaPath)) { + setJavaPathFromPartial(); + return; + } + auto versionList = APPLICATION->metadataIndex()->get("net.minecraft.java"); + m_current_task = versionList->getLoadTask(); + connect(m_current_task.get(), &Task::succeeded, this, &AutoInstallJava::tryNextMajorJava); + connect(m_current_task.get(), &Task::failed, this, &AutoInstallJava::emitFailed); + connect(m_current_task.get(), &Task::progress, this, &AutoInstallJava::setProgress); + connect(m_current_task.get(), &Task::stepProgress, this, &AutoInstallJava::propagateStepProgress); + connect(m_current_task.get(), &Task::status, this, &AutoInstallJava::setStatus); + connect(m_current_task.get(), &Task::details, this, &AutoInstallJava::setDetails); + if (!m_current_task->isRunning()) { + m_current_task->start(); + } + emit progressReportingRequest(); +} + +void AutoInstallJava::setJavaPath(QString path) +{ + auto settings = m_instance->settings(); + settings->set("OverrideJavaLocation", true); + settings->set("JavaPath", path); + settings->set("AutomaticJava", true); + emit logLine(tr("Compatible Java found at: %1.").arg(path), MessageLevel::Launcher); + emitSucceeded(); +} + +void AutoInstallJava::setJavaPathFromPartial() +{ + auto packProfile = m_instance->getPackProfile(); + auto javaName = packProfile->getProfile()->getCompatibleJavaName(); + QDir javaDir(APPLICATION->javaPath()); + // just checking if the executable is there should suffice + // but if needed this can be achieved through refreshing the javalist + // and retrieving the path that contains the java name + auto relativeBinary = FS::PathCombine(javaName, "bin", JavaUtils::javaExecutable); + auto finalPath = javaDir.absoluteFilePath(relativeBinary); + if (QFileInfo::exists(finalPath)) { + setJavaPath(finalPath); + } else { + emit logLine(tr("No compatible Java version was found (the binary file does not exist). Using the default one."), + MessageLevel::Warning); + emitSucceeded(); + } + return; +} + +void AutoInstallJava::downloadJava(Meta::Version::Ptr version, QString javaName) +{ + auto runtimes = version->data()->runtimes; + for (auto java : runtimes) { + if (java->runtimeOS == m_supported_arch && java->name() == javaName) { + QDir javaDir(APPLICATION->javaPath()); + auto final_path = javaDir.absoluteFilePath(java->m_name); + auto deletePath = [final_path] { FS::deletePath(final_path); }; + switch (java->downloadType) { + case Java::DownloadType::Manifest: + m_current_task = makeShared(java->url, final_path, java->checksumType, java->checksumHash); + break; + case Java::DownloadType::Archive: + m_current_task = makeShared(java->url, final_path, java->checksumType, java->checksumHash); + break; + case Java::DownloadType::Unknown: + deletePath(); + emitFailed(tr("Could not determine Java download type!")); + return; + } +#if defined(Q_OS_MACOS) + auto seq = makeShared(tr("Install Java")); + seq->addTask(m_current_task); + seq->addTask(makeShared(final_path)); + m_current_task = seq; +#endif + connect(m_current_task.get(), &Task::failed, this, [this, deletePath](QString reason) { + deletePath(); + emitFailed(reason); + }); + connect(m_current_task.get(), &Task::aborted, this, deletePath); + connect(m_current_task.get(), &Task::succeeded, this, &AutoInstallJava::setJavaPathFromPartial); + connect(m_current_task.get(), &Task::failed, this, &AutoInstallJava::tryNextMajorJava); + connect(m_current_task.get(), &Task::progress, this, &AutoInstallJava::setProgress); + connect(m_current_task.get(), &Task::stepProgress, this, &AutoInstallJava::propagateStepProgress); + connect(m_current_task.get(), &Task::status, this, &AutoInstallJava::setStatus); + connect(m_current_task.get(), &Task::details, this, &AutoInstallJava::setDetails); + m_current_task->start(); + return; + } + } + tryNextMajorJava(); +} + +void AutoInstallJava::tryNextMajorJava() +{ + if (!isRunning()) + return; + auto versionList = APPLICATION->metadataIndex()->get("net.minecraft.java"); + auto packProfile = m_instance->getPackProfile(); + auto wantedJavaName = packProfile->getProfile()->getCompatibleJavaName(); + auto majorJavaVersions = packProfile->getProfile()->getCompatibleJavaMajors(); + if (m_majorJavaVersionIndex >= majorJavaVersions.length()) { + emit logLine( + tr("No versions of Java were found for your operating system: %1-%2").arg(SysInfo::currentSystem(), SysInfo::useQTForArch()), + MessageLevel::Warning); + emit logLine(tr("No compatible version of Java was found. Using the default one."), MessageLevel::Warning); + emitSucceeded(); + return; + } + auto majorJavaVersion = majorJavaVersions[m_majorJavaVersionIndex]; + m_majorJavaVersionIndex++; + + auto javaMajor = versionList->getVersion(QString("java%1").arg(majorJavaVersion)); + + if (javaMajor->isLoaded()) { + downloadJava(javaMajor, wantedJavaName); + } else { + m_current_task = APPLICATION->metadataIndex()->loadVersion("net.minecraft.java", javaMajor->version(), Net::Mode::Online); + connect(m_current_task.get(), &Task::succeeded, this, + [this, javaMajor, wantedJavaName] { downloadJava(javaMajor, wantedJavaName); }); + connect(m_current_task.get(), &Task::failed, this, &AutoInstallJava::tryNextMajorJava); + connect(m_current_task.get(), &Task::progress, this, &AutoInstallJava::setProgress); + connect(m_current_task.get(), &Task::stepProgress, this, &AutoInstallJava::propagateStepProgress); + connect(m_current_task.get(), &Task::status, this, &AutoInstallJava::setStatus); + connect(m_current_task.get(), &Task::details, this, &AutoInstallJava::setDetails); + if (!m_current_task->isRunning()) { + m_current_task->start(); + } + } +} +bool AutoInstallJava::abort() +{ + if (m_current_task && m_current_task->canAbort()) { + auto status = m_current_task->abort(); + emitAborted(); + return status; + } + return Task::abort(); +} diff --git a/launcher/minecraft/launch/AutoInstallJava.h b/launcher/minecraft/launch/AutoInstallJava.h new file mode 100644 index 0000000..a4ffdff --- /dev/null +++ b/launcher/minecraft/launch/AutoInstallJava.h @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023-2024 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include "meta/Version.h" +#include "minecraft/MinecraftInstance.h" +#include "tasks/Task.h" + +class AutoInstallJava : public LaunchStep { + Q_OBJECT + + public: + explicit AutoInstallJava(LaunchTask* parent); + ~AutoInstallJava() override = default; + + void executeTask() override; + bool canAbort() const override { return m_current_task ? m_current_task->canAbort() : false; } + bool abort() override; + + protected: + void setJavaPath(QString path); + void setJavaPathFromPartial(); + void downloadJava(Meta::Version::Ptr version, QString javaName); + void tryNextMajorJava(); + + private: + MinecraftInstance* m_instance; + Task::Ptr m_current_task; + + qsizetype m_majorJavaVersionIndex = 0; + const QString m_supported_arch; +}; diff --git a/launcher/minecraft/launch/ClaimAccount.cpp b/launcher/minecraft/launch/ClaimAccount.cpp new file mode 100644 index 0000000..1b375ab --- /dev/null +++ b/launcher/minecraft/launch/ClaimAccount.cpp @@ -0,0 +1,26 @@ +#include "ClaimAccount.h" +#include + +#include "Application.h" +#include "minecraft/auth/AccountList.h" + +ClaimAccount::ClaimAccount(LaunchTask* parent, AuthSessionPtr session) : LaunchStep(parent) +{ + if (session->launchMode == LaunchMode::Normal) { + auto accounts = APPLICATION->accounts(); + m_account = accounts->getAccountByProfileName(session->player_name); + } +} + +void ClaimAccount::executeTask() +{ + if (m_account) { + lock.reset(new UseLock(m_account.get())); + } + emitSucceeded(); +} + +void ClaimAccount::finalize() +{ + lock.reset(); +} diff --git a/launcher/minecraft/launch/ClaimAccount.h b/launcher/minecraft/launch/ClaimAccount.h new file mode 100644 index 0000000..561f0e8 --- /dev/null +++ b/launcher/minecraft/launch/ClaimAccount.h @@ -0,0 +1,34 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +class ClaimAccount : public LaunchStep { + Q_OBJECT + public: + explicit ClaimAccount(LaunchTask* parent, AuthSessionPtr session); + virtual ~ClaimAccount() = default; + + void executeTask() override; + void finalize() override; + bool canAbort() const override { return false; } + + private: + std::unique_ptr lock; + MinecraftAccountPtr m_account; +}; diff --git a/launcher/minecraft/launch/CreateGameFolders.cpp b/launcher/minecraft/launch/CreateGameFolders.cpp new file mode 100644 index 0000000..07bdbb6 --- /dev/null +++ b/launcher/minecraft/launch/CreateGameFolders.cpp @@ -0,0 +1,23 @@ +#include "CreateGameFolders.h" +#include "FileSystem.h" +#include "launch/LaunchTask.h" +#include "minecraft/MinecraftInstance.h" + +CreateGameFolders::CreateGameFolders(LaunchTask* parent) : LaunchStep(parent) {} + +void CreateGameFolders::executeTask() +{ + auto instance = m_parent->instance(); + + if (!FS::ensureFolderPathExists(instance->gameRoot())) { + emit logLine("Couldn't create the main game folder", MessageLevel::Error); + emitFailed(tr("Couldn't create the main game folder")); + return; + } + + // HACK: this is a workaround for MCL-3732 - 'server-resource-packs' folder is created. + if (!FS::ensureFolderPathExists(FS::PathCombine(instance->gameRoot(), "server-resource-packs"))) { + emit logLine("Couldn't create the 'server-resource-packs' folder", MessageLevel::Error); + } + emitSucceeded(); +} diff --git a/launcher/minecraft/launch/CreateGameFolders.h b/launcher/minecraft/launch/CreateGameFolders.h new file mode 100644 index 0000000..b44762d --- /dev/null +++ b/launcher/minecraft/launch/CreateGameFolders.h @@ -0,0 +1,31 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +// Create the main .minecraft for the instance and any other necessary folders +class CreateGameFolders : public LaunchStep { + Q_OBJECT + public: + explicit CreateGameFolders(LaunchTask* parent); + virtual ~CreateGameFolders() {}; + + virtual void executeTask(); + virtual bool canAbort() const { return false; } +}; diff --git a/launcher/minecraft/launch/EnsureAvailableMemory.cpp b/launcher/minecraft/launch/EnsureAvailableMemory.cpp new file mode 100644 index 0000000..a0e1567 --- /dev/null +++ b/launcher/minecraft/launch/EnsureAvailableMemory.cpp @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2026 Octol1ttle + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "EnsureAvailableMemory.h" + +#include "HardwareInfo.h" +#include "ui/dialogs/CustomMessageBox.h" + +EnsureAvailableMemory::EnsureAvailableMemory(LaunchTask* parent, MinecraftInstance* instance) : LaunchStep(parent), m_instance(instance) {} + +void EnsureAvailableMemory::executeTask() +{ + const uint64_t available = HardwareInfo::availableRamMiB(); + const uint64_t min = m_instance->settings()->get("MinMemAlloc").toUInt(); + const uint64_t max = m_instance->settings()->get("MaxMemAlloc").toUInt(); + const uint64_t required = std::max(min, max); + + if (static_cast(required) * 0.9 > static_cast(available)) { + bool shouldAbort = false; + + if (m_instance->settings()->get("LowMemWarning").toBool()) { + auto* dialog = CustomMessageBox::selectable( + nullptr, tr("Not enough RAM"), + tr("There is not enough RAM available to launch this instance with the current memory settings.\n\n" + "Required: %1 MiB\nAvailable: %2 MiB\n\n" + "Continue anyway? This may cause slowdowns in the game and your system.") + .arg(required) + .arg(available), + QMessageBox::Icon::Warning, QMessageBox::StandardButton::Yes | QMessageBox::StandardButton::No, + QMessageBox::StandardButton::No); + + shouldAbort = dialog->exec() == QMessageBox::No; + dialog->deleteLater(); + } + + const auto message = tr("Not enough RAM available to launch this instance"); + if (shouldAbort) { + emit logLine(message, MessageLevel::Fatal); + emitFailed(message); + return; + } + + emit logLine(message, MessageLevel::Warning); + } + + emitSucceeded(); +} diff --git a/launcher/minecraft/launch/EnsureAvailableMemory.h b/launcher/minecraft/launch/EnsureAvailableMemory.h new file mode 100644 index 0000000..3074a3f --- /dev/null +++ b/launcher/minecraft/launch/EnsureAvailableMemory.h @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2026 Octol1ttle + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include "launch/LaunchStep.h" +#include "minecraft/MinecraftInstance.h" + +class EnsureAvailableMemory : public LaunchStep { + Q_OBJECT + + public: + explicit EnsureAvailableMemory(LaunchTask* parent, MinecraftInstance* instance); + ~EnsureAvailableMemory() override = default; + + void executeTask() override; + bool canAbort() const override { return false; } + + private: + MinecraftInstance* m_instance; +}; diff --git a/launcher/minecraft/launch/EnsureOfflineLibraries.cpp b/launcher/minecraft/launch/EnsureOfflineLibraries.cpp new file mode 100644 index 0000000..0165fbd --- /dev/null +++ b/launcher/minecraft/launch/EnsureOfflineLibraries.cpp @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2026 Octol1ttle + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "EnsureOfflineLibraries.h" + +#include "minecraft/PackProfile.h" + +EnsureOfflineLibraries::EnsureOfflineLibraries(LaunchTask* parent, MinecraftInstance* instance) : LaunchStep(parent), m_instance(instance) +{} + +void EnsureOfflineLibraries::executeTask() +{ + const auto profile = m_instance->getPackProfile()->getProfile(); + QStringList allJars; + profile->getLibraryFiles(m_instance->runtimeContext(), allJars, allJars, m_instance->getLocalLibraryPath(), m_instance->binRoot(), + false); + + QStringList missing; + for (const auto& jar : allJars) { + if (!QFileInfo::exists(jar)) { + missing.append(jar); + } + } + + if (missing.isEmpty()) { + emitSucceeded(); + return; + } + + emit logLine("Missing libraries:", MessageLevel::Error); + for (const auto& jar : missing) { + emit logLine(" " + jar, MessageLevel::Error); + } + emit logLine(tr("\nThis instance cannot be launched because some libraries are missing or have not been downloaded yet. Please " + "try again in online mode with a working Internet connection"), + MessageLevel::Fatal); + emitFailed("Required libraries are missing"); +} diff --git a/launcher/minecraft/launch/EnsureOfflineLibraries.h b/launcher/minecraft/launch/EnsureOfflineLibraries.h new file mode 100644 index 0000000..87c0536 --- /dev/null +++ b/launcher/minecraft/launch/EnsureOfflineLibraries.h @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2026 Octol1ttle + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include "launch/LaunchStep.h" +#include "minecraft/MinecraftInstance.h" + +class EnsureOfflineLibraries : public LaunchStep { + Q_OBJECT + + public: + explicit EnsureOfflineLibraries(LaunchTask* parent, MinecraftInstance* instance); + ~EnsureOfflineLibraries() override = default; + + void executeTask() override; + bool canAbort() const override { return false; } + + private: + MinecraftInstance* m_instance; +}; diff --git a/launcher/minecraft/launch/ExtractNatives.cpp b/launcher/minecraft/launch/ExtractNatives.cpp new file mode 100644 index 0000000..17d1a8c --- /dev/null +++ b/launcher/minecraft/launch/ExtractNatives.cpp @@ -0,0 +1,90 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ExtractNatives.h" +#include +#include + +#include +#include "FileSystem.h" +#include "archive/ArchiveReader.h" +#include "archive/ArchiveWriter.h" + +#ifdef major +#undef major +#endif +#ifdef minor +#undef minor +#endif + +static QString replaceSuffix(QString target, const QString& suffix, const QString& replacement) +{ + if (!target.endsWith(suffix)) { + return target; + } + target.resize(target.length() - suffix.length()); + return target + replacement; +} + +static bool unzipNatives(QString source, QString targetFolder, bool applyJnilibHack) +{ + MMCZip::ArchiveReader zip(source); + QDir directory(targetFolder); + + auto extPtr = MMCZip::ArchiveWriter::createDiskWriter(); + auto ext = extPtr.get(); + + return zip.parse([applyJnilibHack, directory, ext](MMCZip::ArchiveReader::File* f) { + QString name = f->filename(); + auto lowercase = name.toLower(); + if (applyJnilibHack) { + name = replaceSuffix(name, ".jnilib", ".dylib"); + } + QString absFilePath = directory.absoluteFilePath(name); + return f->writeFile(ext, absFilePath, directory); + }); +} + +void ExtractNatives::executeTask() +{ + auto instance = m_parent->instance(); + auto toExtract = instance->getNativeJars(); + if (toExtract.isEmpty()) { + emitSucceeded(); + return; + } + + auto outputPath = instance->getNativePath(); + FS::ensureFolderPathExists(outputPath); + auto javaVersion = instance->getJavaVersion(); + bool jniHackEnabled = javaVersion.major() >= 8; + for (const auto& source : toExtract) { + if (!unzipNatives(source, outputPath, jniHackEnabled)) { + const char* reason = QT_TR_NOOP("Couldn't extract native jar '%1' to destination '%2'"); + emit logLine(QString(reason).arg(source, outputPath), MessageLevel::Fatal); + emitFailed(tr(reason).arg(source, outputPath)); + return; + } + } + emitSucceeded(); +} + +void ExtractNatives::finalize() +{ + auto instance = m_parent->instance(); + QString target_dir = FS::PathCombine(instance->instanceRoot(), "natives/"); + QDir dir(target_dir); + dir.removeRecursively(); +} diff --git a/launcher/minecraft/launch/ExtractNatives.h b/launcher/minecraft/launch/ExtractNatives.h new file mode 100644 index 0000000..1ad9a41 --- /dev/null +++ b/launcher/minecraft/launch/ExtractNatives.h @@ -0,0 +1,30 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +// FIXME: temporary wrapper for existing task. +class ExtractNatives : public LaunchStep { + Q_OBJECT + public: + explicit ExtractNatives(LaunchTask* parent) : LaunchStep(parent) {}; + virtual ~ExtractNatives() {}; + + void executeTask() override; + bool canAbort() const override { return false; } + void finalize() override; +}; diff --git a/launcher/minecraft/launch/LauncherPartLaunch.cpp b/launcher/minecraft/launch/LauncherPartLaunch.cpp new file mode 100644 index 0000000..a2c400e --- /dev/null +++ b/launcher/minecraft/launch/LauncherPartLaunch.cpp @@ -0,0 +1,240 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "LauncherPartLaunch.h" + +#include +#include + +#include "Application.h" +#include "Commandline.h" +#include "FileSystem.h" +#include "launch/LaunchTask.h" +#include "minecraft/MinecraftInstance.h" + +#ifdef Q_OS_LINUX +#include "gamemode_client.h" +#endif + +LauncherPartLaunch::LauncherPartLaunch(LaunchTask* parent) + : LaunchStep(parent) + , m_process(parent->instance()->getJavaVersion().defaultsToUtf8() ? QStringConverter::Utf8 : QStringConverter::System) +{ + if (parent->instance()->settings()->get("CloseAfterLaunch").toBool()) { + static const QRegularExpression s_settingUser(".*Setting user.+", QRegularExpression::CaseInsensitiveOption); + std::shared_ptr connection{ new QMetaObject::Connection }; + *connection = + connect(&m_process, &LoggedProcess::log, this, [connection](const QStringList& lines, [[maybe_unused]] MessageLevel level) { + qDebug() << lines; + if (lines.filter(s_settingUser).length() != 0) { + APPLICATION->closeAllWindows(); + disconnect(*connection); + } + }); + } + + connect(&m_process, &LoggedProcess::log, this, &LauncherPartLaunch::logLines); + connect(&m_process, &LoggedProcess::stateChanged, this, &LauncherPartLaunch::on_state); +} + +void LauncherPartLaunch::executeTask() +{ + QString jarPath = APPLICATION->getJarPath("NewLaunch.jar"); + if (jarPath.isEmpty()) { + const char* reason = QT_TR_NOOP("Launcher library could not be found. Please check your installation."); + emit logLine(tr(reason), MessageLevel::Fatal); + emitFailed(tr(reason)); + return; + } + + auto instance = m_parent->instance(); + + QString legacyJarPath; + if (instance->getLauncher() == "legacy" || instance->shouldApplyOnlineFixes()) { + legacyJarPath = APPLICATION->getJarPath("NewLaunchLegacy.jar"); + if (legacyJarPath.isEmpty()) { + const char* reason = QT_TR_NOOP("Legacy launcher library could not be found. Please check your installation."); + emit logLine(tr(reason), MessageLevel::Fatal); + emitFailed(tr(reason)); + return; + } + } + + m_launchScript = instance->createLaunchScript(m_session, m_targetToJoin); + QStringList args = instance->javaArguments(); + QString allArgs = args.join(" "); + emit logLine("Java arguments:\n " + m_parent->censorPrivateInfo(allArgs) + "\n", MessageLevel::Launcher); + + auto javaPath = FS::ResolveExecutable(instance->settings()->get("JavaPath").toString()); + + m_process.setProcessEnvironment(instance->createLaunchEnvironment()); + + // make detachable - this will keep the process running even if the object is destroyed + m_process.setDetachable(true); + + auto classPath = instance->getClassPath(); + classPath.prepend(jarPath); + + if (!legacyJarPath.isEmpty()) + classPath.prepend(legacyJarPath); + + auto natPath = instance->getNativePath(); +#ifdef Q_OS_WIN + natPath = FS::getPathNameInLocal8bit(natPath); +#endif + args << "-Djava.library.path=" + natPath; + + args << "-cp"; +#ifdef Q_OS_WIN + QStringList processed; + for (auto& item : classPath) { + processed << FS::getPathNameInLocal8bit(item); + } + args << processed.join(';'); +#else + args << classPath.join(':'); +#endif + args << "org.prismlauncher.EntryPoint"; + + qDebug() << args.join(' '); + + QString wrapperCommandStr = instance->getWrapperCommand().trimmed(); + if (!wrapperCommandStr.isEmpty()) { + wrapperCommandStr = m_parent->substituteVariables(wrapperCommandStr); + auto wrapperArgs = Commandline::splitArgs(wrapperCommandStr); + auto wrapperCommand = wrapperArgs.takeFirst(); + auto realWrapperCommand = QStandardPaths::findExecutable(wrapperCommand); + if (realWrapperCommand.isEmpty()) { + const char* reason = QT_TR_NOOP("The wrapper command \"%1\" couldn't be found."); + emit logLine(QString(reason).arg(wrapperCommand), MessageLevel::Fatal); + emitFailed(tr(reason).arg(wrapperCommand)); + return; + } + emit logLine("Wrapper command is:\n" + wrapperCommandStr + "\n\n", MessageLevel::Launcher); + args.prepend(javaPath); + m_process.start(wrapperCommand, wrapperArgs + args); + } else { + m_process.start(javaPath, args); + } + +#ifdef Q_OS_LINUX + if (instance->settings()->get("EnableFeralGamemode").toBool() && APPLICATION->capabilities() & Application::SupportsGameMode) { + auto pid = m_process.processId(); + if (pid) { + gamemode_request_start_for(pid); + } + } +#endif +} + +void LauncherPartLaunch::on_state(LoggedProcess::State state) +{ + switch (state) { + case LoggedProcess::FailedToStart: { + //: Error message displayed if instace can't start + const char* reason = QT_TR_NOOP("Could not launch Minecraft!"); + emit logLine(reason, MessageLevel::Fatal); + emitFailed(tr(reason)); + return; + } + case LoggedProcess::Aborted: + case LoggedProcess::Crashed: { + m_parent->setPid(-1); + m_parent->instance()->setMinecraftRunning(false); + emitFailed(tr("Game crashed.")); + return; + } + case LoggedProcess::Finished: { + auto instance = m_parent->instance(); + if (instance->settings()->get("CloseAfterLaunch").toBool()) + APPLICATION->showMainWindow(); + + m_parent->setPid(-1); + m_parent->instance()->setMinecraftRunning(false); + // if the exit code wasn't 0, report this as a crash + auto exitCode = m_process.exitCode(); + if (exitCode != 0) { + emitFailed(tr("Game crashed.")); + return; + } + // FIXME: make this work again + // m_postlaunchprocess.processEnvironment().insert("INST_EXITCODE", QString(exitCode)); + // run post-exit + emitSucceeded(); + break; + } + case LoggedProcess::Running: + emit logLine(QString("Minecraft process ID: %1\n\n").arg(m_process.processId()), MessageLevel::Launcher); + m_parent->setPid(m_process.processId()); + // send the launch script to the launcher part + m_process.write(m_launchScript.toUtf8()); + + mayProceed = true; + emit readyForLaunch(); + break; + default: + break; + } +} + +void LauncherPartLaunch::setWorkingDirectory(const QString& wd) +{ + m_process.setWorkingDirectory(wd); +} + +void LauncherPartLaunch::proceed() +{ + if (mayProceed) { + m_parent->instance()->setMinecraftRunning(true); + QString launchString("launch\n"); + m_process.write(launchString.toUtf8()); + mayProceed = false; + } +} + +bool LauncherPartLaunch::abort() +{ + if (mayProceed) { + mayProceed = false; + QString launchString("abort\n"); + m_process.write(launchString.toUtf8()); + } else { + auto state = m_process.state(); + if (state == LoggedProcess::Running || state == LoggedProcess::Starting) { + m_process.kill(); + } + } + return true; +} diff --git a/launcher/minecraft/launch/LauncherPartLaunch.h b/launcher/minecraft/launch/LauncherPartLaunch.h new file mode 100644 index 0000000..ea125aa --- /dev/null +++ b/launcher/minecraft/launch/LauncherPartLaunch.h @@ -0,0 +1,50 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +#include "MinecraftTarget.h" + +class LauncherPartLaunch : public LaunchStep { + Q_OBJECT + public: + explicit LauncherPartLaunch(LaunchTask* parent); + virtual ~LauncherPartLaunch() = default; + + virtual void executeTask(); + virtual bool abort(); + virtual void proceed(); + virtual bool canAbort() const { return true; } + void setWorkingDirectory(const QString& wd); + void setAuthSession(AuthSessionPtr session) { m_session = session; } + + void setTargetToJoin(MinecraftTarget::Ptr targetToJoin) { m_targetToJoin = std::move(targetToJoin); } + + private slots: + void on_state(LoggedProcess::State state); + + private: + LoggedProcess m_process; + QString m_command; + AuthSessionPtr m_session; + QString m_launchScript; + MinecraftTarget::Ptr m_targetToJoin; + + bool mayProceed = false; +}; diff --git a/launcher/minecraft/launch/MinecraftTarget.cpp b/launcher/minecraft/launch/MinecraftTarget.cpp new file mode 100644 index 0000000..ba9f875 --- /dev/null +++ b/launcher/minecraft/launch/MinecraftTarget.cpp @@ -0,0 +1,65 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "MinecraftTarget.h" + +#include + +// FIXME: the way this is written, it can't ever do any sort of validation and can accept total junk +MinecraftTarget MinecraftTarget::parse(const QString& fullAddress, bool useWorld) +{ + if (useWorld) { + MinecraftTarget target; + target.world = fullAddress; + return target; + } + QStringList split = fullAddress.split(":"); + + // The logic below replicates the exact logic minecraft uses for parsing server addresses. + // While the conversion is not lossless and eats errors, it ensures the same behavior + // within Minecraft and Prism Launcher when entering server addresses. + if (fullAddress.startsWith("[")) { + int bracket = fullAddress.indexOf("]"); + if (bracket > 0) { + QString ipv6 = fullAddress.mid(1, bracket - 1); + QString port = fullAddress.mid(bracket + 1).trimmed(); + + if (port.startsWith(":") && !ipv6.isEmpty()) { + port = port.mid(1); + split = QStringList({ ipv6, port }); + } else { + split = QStringList({ ipv6 }); + } + } + } + + if (split.size() > 2) { + split = QStringList({ fullAddress }); + } + + QString realAddress = split[0]; + + quint16 realPort = 25565; + if (split.size() > 1) { + bool ok; + realPort = split[1].toUInt(&ok); + + if (!ok) { + realPort = 25565; + } + } + + return MinecraftTarget{ realAddress, realPort }; +} diff --git a/launcher/minecraft/launch/MinecraftTarget.h b/launcher/minecraft/launch/MinecraftTarget.h new file mode 100644 index 0000000..7f8b268 --- /dev/null +++ b/launcher/minecraft/launch/MinecraftTarget.h @@ -0,0 +1,29 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +#include + +struct MinecraftTarget { + QString address; + quint16 port; + + QString world; + static MinecraftTarget parse(const QString& fullAddress, bool useWorld); + using Ptr = std::shared_ptr; +}; diff --git a/launcher/minecraft/launch/ModMinecraftJar.cpp b/launcher/minecraft/launch/ModMinecraftJar.cpp new file mode 100644 index 0000000..204f32f --- /dev/null +++ b/launcher/minecraft/launch/ModMinecraftJar.cpp @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ModMinecraftJar.h" +#include "FileSystem.h" +#include "MMCZip.h" +#include "launch/LaunchTask.h" +#include "minecraft/MinecraftInstance.h" +#include "minecraft/PackProfile.h" + +void ModMinecraftJar::executeTask() +{ + auto m_inst = m_parent->instance(); + + if (!m_inst->getJarMods().size()) { + emitSucceeded(); + return; + } + // nuke obsolete stripped jar(s) if needed + if (!FS::ensureFolderPathExists(m_inst->binRoot())) { + emitFailed(tr("Couldn't create the bin folder for Minecraft.jar")); + return; + } + + auto finalJarPath = QDir(m_inst->binRoot()).absoluteFilePath("minecraft.jar"); + if (!removeJar()) { + emitFailed(tr("Couldn't remove stale jar file: %1").arg(finalJarPath)); + return; + } + + // create temporary modded jar, if needed + auto components = m_inst->getPackProfile(); + auto profile = components->getProfile(); + auto jarMods = m_inst->getJarMods(); + if (jarMods.size()) { + auto mainJar = profile->getMainJar(); + QStringList jars, temp1, temp2, temp3, temp4; + mainJar->getApplicableFiles(m_inst->runtimeContext(), jars, temp1, temp2, temp3, m_inst->getLocalLibraryPath()); + auto sourceJarPath = jars[0]; + if (!MMCZip::createModdedJar(sourceJarPath, finalJarPath, jarMods)) { + emitFailed(tr("Failed to create the custom Minecraft jar file.")); + return; + } + } + emitSucceeded(); +} + +void ModMinecraftJar::finalize() +{ + removeJar(); +} + +bool ModMinecraftJar::removeJar() +{ + auto m_inst = m_parent->instance(); + auto finalJarPath = QDir(m_inst->binRoot()).absoluteFilePath("minecraft.jar"); + QFile finalJar(finalJarPath); + if (finalJar.exists()) { + if (!finalJar.remove()) { + return false; + } + } + return true; +} diff --git a/launcher/minecraft/launch/ModMinecraftJar.h b/launcher/minecraft/launch/ModMinecraftJar.h new file mode 100644 index 0000000..6fc2a8a --- /dev/null +++ b/launcher/minecraft/launch/ModMinecraftJar.h @@ -0,0 +1,33 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +class ModMinecraftJar : public LaunchStep { + Q_OBJECT + public: + explicit ModMinecraftJar(LaunchTask* parent) : LaunchStep(parent) {}; + virtual ~ModMinecraftJar() {}; + + virtual void executeTask() override; + virtual bool canAbort() const override { return false; } + void finalize() override; + + private: + bool removeJar(); +}; diff --git a/launcher/minecraft/launch/PrintInstanceInfo.cpp b/launcher/minecraft/launch/PrintInstanceInfo.cpp new file mode 100644 index 0000000..7bfe737 --- /dev/null +++ b/launcher/minecraft/launch/PrintInstanceInfo.cpp @@ -0,0 +1,79 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include + +#include +#include "PrintInstanceInfo.h" + +#include "HardwareInfo.h" + +#if defined(Q_OS_FREEBSD) +namespace { +void runSysctlHwModel(QStringList& log) +{ + char buff[512]; + FILE* hwmodel = popen("sysctl hw.model", "r"); + while (fgets(buff, 512, hwmodel) != NULL) { + log << QString::fromUtf8(buff); + break; + } + pclose(hwmodel); +} + +void runPciconf(QStringList& log) +{ + char buff[512]; + std::string strcard; + FILE* pciconf = popen("pciconf -lv -a vgapci0", "r"); + while (fgets(buff, 512, pciconf) != NULL) { + if (strncmp(buff, " vendor", 10) == 0) { + std::string str(buff); + strcard.append(str.substr(str.find_first_of("'") + 1, str.find_last_not_of("'") - (str.find_first_of("'") + 2))); + strcard.append(" "); + } else if (strncmp(buff, " device", 10) == 0) { + std::string str2(buff); + strcard.append(str2.substr(str2.find_first_of("'") + 1, str2.find_last_not_of("'") - (str2.find_first_of("'") + 2))); + } + log << QString::fromStdString(strcard); + break; + } + pclose(pciconf); +} +} // namespace +#endif + +void PrintInstanceInfo::executeTask() +{ + auto instance = m_parent->instance(); + QStringList log; + + log << ""; + log << "OS: " + QString("%1 | %2 | %3").arg(QSysInfo::prettyProductName(), QSysInfo::kernelType(), QSysInfo::kernelVersion()); +#ifdef Q_OS_FREEBSD + ::runSysctlHwModel(log); + ::runPciconf(log); +#else + log << "CPU: " + HardwareInfo::cpuInfo(); + log << QString("RAM: %1 MiB (available: %2 MiB)").arg(HardwareInfo::totalRamMiB()).arg(HardwareInfo::availableRamMiB()); +#endif + log.append(HardwareInfo::gpuInfo()); + log << ""; + + logLines(log, MessageLevel::Launcher); + logLines(instance->verboseDescription(m_session, m_targetToJoin), MessageLevel::Launcher); + emitSucceeded(); +} diff --git a/launcher/minecraft/launch/PrintInstanceInfo.h b/launcher/minecraft/launch/PrintInstanceInfo.h new file mode 100644 index 0000000..4138c0c --- /dev/null +++ b/launcher/minecraft/launch/PrintInstanceInfo.h @@ -0,0 +1,36 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include "minecraft/auth/AuthSession.h" +#include "minecraft/launch/MinecraftTarget.h" + +// FIXME: temporary wrapper for existing task. +class PrintInstanceInfo : public LaunchStep { + Q_OBJECT + public: + explicit PrintInstanceInfo(LaunchTask* parent, AuthSessionPtr session, MinecraftTarget::Ptr targetToJoin) + : LaunchStep(parent), m_session(session), m_targetToJoin(targetToJoin) {}; + virtual ~PrintInstanceInfo() = default; + + virtual void executeTask(); + virtual bool canAbort() const { return false; } + + private: + AuthSessionPtr m_session; + MinecraftTarget::Ptr m_targetToJoin; +}; diff --git a/launcher/minecraft/launch/ReconstructAssets.cpp b/launcher/minecraft/launch/ReconstructAssets.cpp new file mode 100644 index 0000000..21ae395 --- /dev/null +++ b/launcher/minecraft/launch/ReconstructAssets.cpp @@ -0,0 +1,34 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ReconstructAssets.h" +#include "launch/LaunchTask.h" +#include "minecraft/AssetsUtils.h" +#include "minecraft/MinecraftInstance.h" +#include "minecraft/PackProfile.h" + +void ReconstructAssets::executeTask() +{ + auto instance = m_parent->instance(); + auto components = instance->getPackProfile(); + auto profile = components->getProfile(); + auto assets = profile->getMinecraftAssets(); + + if (!AssetsUtils::reconstructAssets(assets->id, instance->resourcesDir())) { + emit logLine("Failed to reconstruct Minecraft assets.", MessageLevel::Error); + } + + emitSucceeded(); +} diff --git a/launcher/minecraft/launch/ReconstructAssets.h b/launcher/minecraft/launch/ReconstructAssets.h new file mode 100644 index 0000000..2c910c5 --- /dev/null +++ b/launcher/minecraft/launch/ReconstructAssets.h @@ -0,0 +1,29 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +class ReconstructAssets : public LaunchStep { + Q_OBJECT + public: + explicit ReconstructAssets(LaunchTask* parent) : LaunchStep(parent) {}; + virtual ~ReconstructAssets() {}; + + void executeTask() override; + bool canAbort() const override { return false; } +}; diff --git a/launcher/minecraft/launch/ScanModFolders.cpp b/launcher/minecraft/launch/ScanModFolders.cpp new file mode 100644 index 0000000..cbe1599 --- /dev/null +++ b/launcher/minecraft/launch/ScanModFolders.cpp @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ScanModFolders.h" +#include "FileSystem.h" +#include "MMCZip.h" +#include "launch/LaunchTask.h" +#include "minecraft/MinecraftInstance.h" +#include "minecraft/mod/ModFolderModel.h" + +void ScanModFolders::executeTask() +{ + auto m_inst = m_parent->instance(); + + auto loaders = m_inst->loaderModList(); + connect(loaders, &ModFolderModel::updateFinished, this, &ScanModFolders::modsDone); + if (!loaders->update()) { + m_modsDone = true; + } + + auto cores = m_inst->coreModList(); + connect(cores, &ModFolderModel::updateFinished, this, &ScanModFolders::coreModsDone); + if (!cores->update()) { + m_coreModsDone = true; + } + + auto nils = m_inst->nilModList(); + connect(nils, &ModFolderModel::updateFinished, this, &ScanModFolders::nilModsDone); + if (!nils->update()) { + m_nilModsDone = true; + } + checkDone(); +} + +void ScanModFolders::modsDone() +{ + m_modsDone = true; + checkDone(); +} + +void ScanModFolders::coreModsDone() +{ + m_coreModsDone = true; + checkDone(); +} + +void ScanModFolders::nilModsDone() +{ + m_nilModsDone = true; + checkDone(); +} + +void ScanModFolders::checkDone() +{ + if (m_modsDone && m_coreModsDone && m_nilModsDone) { + emitSucceeded(); + } +} diff --git a/launcher/minecraft/launch/ScanModFolders.h b/launcher/minecraft/launch/ScanModFolders.h new file mode 100644 index 0000000..5d93509 --- /dev/null +++ b/launcher/minecraft/launch/ScanModFolders.h @@ -0,0 +1,41 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +class ScanModFolders : public LaunchStep { + Q_OBJECT + public: + explicit ScanModFolders(LaunchTask* parent) : LaunchStep(parent) {}; + virtual ~ScanModFolders() {}; + + virtual void executeTask() override; + virtual bool canAbort() const override { return false; } + private slots: + void coreModsDone(); + void modsDone(); + void nilModsDone(); + + private: + void checkDone(); + + private: // DATA + bool m_modsDone = false; + bool m_nilModsDone = false; + bool m_coreModsDone = false; +}; diff --git a/launcher/minecraft/launch/VerifyJavaInstall.cpp b/launcher/minecraft/launch/VerifyJavaInstall.cpp new file mode 100644 index 0000000..9a1d144 --- /dev/null +++ b/launcher/minecraft/launch/VerifyJavaInstall.cpp @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "VerifyJavaInstall.h" +#include + +#include "Application.h" +#include "MessageLevel.h" +#include "java/JavaInstall.h" +#include "java/JavaInstallList.h" +#include "java/JavaVersion.h" +#include "minecraft/MinecraftInstance.h" +#include "minecraft/PackProfile.h" + +void VerifyJavaInstall::executeTask() +{ + auto instance = m_parent->instance(); + auto packProfile = instance->getPackProfile(); + auto settings = instance->settings(); + auto storedVersion = settings->get("JavaVersion").toString(); + auto ignoreCompatibility = settings->get("IgnoreJavaCompatibility").toBool(); + auto javaArchitecture = settings->get("JavaArchitecture").toString(); + auto maxMemAlloc = settings->get("MaxMemAlloc").toInt(); + + if (javaArchitecture == "32" && maxMemAlloc > 2048) { + emit logLine(tr("Max memory allocation exceeds the supported value.\n" + "The selected installation of Java is 32-bit and doesn't support more than 2048MiB of RAM.\n" + "The instance may not start due to this."), + MessageLevel::Error); + } + + auto compatibleMajors = packProfile->getProfile()->getCompatibleJavaMajors(); + + JavaVersion javaVersion(storedVersion); + + if (compatibleMajors.isEmpty() || compatibleMajors.contains(javaVersion.major())) { + emitSucceeded(); + return; + } + + if (ignoreCompatibility) { + emit logLine(tr("Java major version is incompatible. Things might break.\n"), MessageLevel::Warning); + emitSucceeded(); + return; + } + + emit logLine(tr("This instance is not compatible with Java version %1.\n" + "Please switch to one of the following Java versions for this instance:") + .arg(javaVersion.major()), + MessageLevel::Error); + for (auto major : compatibleMajors) { + emit logLine(tr("Java version %1").arg(major), MessageLevel::Error); + } + emit logLine(tr("Go to instance Java settings to change your Java version or disable the Java compatibility check if you know what " + "you're doing."), + MessageLevel::Error); + + emitFailed(QString("Incompatible Java major version")); +} diff --git a/launcher/minecraft/launch/VerifyJavaInstall.h b/launcher/minecraft/launch/VerifyJavaInstall.h new file mode 100644 index 0000000..3591ce6 --- /dev/null +++ b/launcher/minecraft/launch/VerifyJavaInstall.h @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +class VerifyJavaInstall : public LaunchStep { + Q_OBJECT + + public: + explicit VerifyJavaInstall(LaunchTask* parent) : LaunchStep(parent) {}; + ~VerifyJavaInstall() override = default; + + void executeTask() override; + bool canAbort() const override { return false; } +}; diff --git a/launcher/minecraft/mod/DataPack.cpp b/launcher/minecraft/mod/DataPack.cpp new file mode 100644 index 0000000..140596a --- /dev/null +++ b/launcher/minecraft/mod/DataPack.cpp @@ -0,0 +1,295 @@ +// SPDX-FileCopyrightText: 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> +// +// SPDX-License-Identifier: GPL-3.0-only + +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "DataPack.h" + +#include +#include +#include +#include + +#include "MTPixmapCache.h" +#include "Version.h" +#include "minecraft/mod/tasks/LocalDataPackParseTask.h" + +// Values taken from: +// https://minecraft.wiki/w/Pack_format#List_of_data_pack_formats +static const QMap, std::pair> s_pack_format_versions = { + { { 4, 0 }, { Version("1.13"), Version("1.14.4") } }, + { { 5, 0 }, { Version("1.15"), Version("1.16.1") } }, + { { 6, 0 }, { Version("1.16.2"), Version("1.16.5") } }, + { { 7, 0 }, { Version("1.17"), Version("1.17.1") } }, + { { 8, 0 }, { Version("1.18"), Version("1.18.1") } }, + { { 9, 0 }, { Version("1.18.2"), Version("1.18.2") } }, + { { 10, 0 }, { Version("1.19"), Version("1.19.3") } }, + { { 11, 0 }, { Version("23w03a"), Version("23w05a") } }, + { { 12, 0 }, { Version("1.19.4"), Version("1.19.4") } }, + { { 13, 0 }, { Version("23w12a"), Version("23w14a") } }, + { { 14, 0 }, { Version("23w16a"), Version("23w17a") } }, + { { 15, 0 }, { Version("1.20"), Version("1.20.1") } }, + { { 16, 0 }, { Version("23w31a"), Version("23w31a") } }, + { { 17, 0 }, { Version("23w32a"), Version("23w35a") } }, + { { 18, 0 }, { Version("1.20.2"), Version("1.20.2") } }, + { { 19, 0 }, { Version("23w40a"), Version("23w40a") } }, + { { 20, 0 }, { Version("23w41a"), Version("23w41a") } }, + { { 21, 0 }, { Version("23w42a"), Version("23w42a") } }, + { { 22, 0 }, { Version("23w43a"), Version("23w43b") } }, + { { 23, 0 }, { Version("23w44a"), Version("23w44a") } }, + { { 24, 0 }, { Version("23w45a"), Version("23w45a") } }, + { { 25, 0 }, { Version("23w46a"), Version("23w46a") } }, + { { 26, 0 }, { Version("1.20.3"), Version("1.20.4") } }, + { { 27, 0 }, { Version("23w51a"), Version("23w51b") } }, + { { 28, 0 }, { Version("24w03a"), Version("24w03b") } }, + { { 29, 0 }, { Version("24w04a"), Version("24w04a") } }, + { { 30, 0 }, { Version("24w05a"), Version("24w05b") } }, + { { 31, 0 }, { Version("24w06a"), Version("24w06a") } }, + { { 32, 0 }, { Version("24w07a"), Version("24w07a") } }, + { { 33, 0 }, { Version("24w09a"), Version("24w09a") } }, + { { 34, 0 }, { Version("24w10a"), Version("24w10a") } }, + { { 35, 0 }, { Version("24w11a"), Version("24w11a") } }, + { { 36, 0 }, { Version("24w12a"), Version("24w12a") } }, + { { 37, 0 }, { Version("24w13a"), Version("24w13a") } }, + { { 38, 0 }, { Version("24w14a"), Version("24w14a") } }, + { { 39, 0 }, { Version("1.20.5-pre1"), Version("1.20.5-pre1") } }, + { { 40, 0 }, { Version("1.20.5-pre2"), Version("1.20.5-pre2") } }, + { { 41, 0 }, { Version("1.20.5"), Version("1.20.6") } }, + { { 42, 0 }, { Version("24w18a"), Version("24w18a") } }, + { { 43, 0 }, { Version("24w19a"), Version("24w19b") } }, + { { 44, 0 }, { Version("24w20a"), Version("24w20a") } }, + { { 45, 0 }, { Version("24w21a"), Version("24w21b") } }, + { { 46, 0 }, { Version("1.21-pre1"), Version("1.21-pre1") } }, + { { 47, 0 }, { Version("1.21-pre2"), Version("1.21-pre2") } }, + { { 48, 0 }, { Version("1.21"), Version("1.21.1") } }, + { { 49, 0 }, { Version("24w33a"), Version("24w33a") } }, + { { 50, 0 }, { Version("24w34a"), Version("24w34a") } }, + { { 51, 0 }, { Version("24w35a"), Version("24w35a") } }, + { { 52, 0 }, { Version("24w36a"), Version("24w36a") } }, + { { 53, 0 }, { Version("24w37a"), Version("24w37a") } }, + { { 54, 0 }, { Version("24w38a"), Version("24w38a") } }, + { { 55, 0 }, { Version("24w39a"), Version("24w39a") } }, + { { 56, 0 }, { Version("24w40a"), Version("24w40a") } }, + { { 57, 0 }, { Version("1.21.2"), Version("1.21.3") } }, + { { 58, 0 }, { Version("24w44a"), Version("24w44a") } }, + { { 59, 0 }, { Version("24w45a"), Version("24w45a") } }, + { { 60, 0 }, { Version("24w46a"), Version("1.21.4-pre1") } }, + { { 61, 0 }, { Version("1.21.4"), Version("1.21.4") } }, + { { 62, 0 }, { Version("25w02a"), Version("25w02a") } }, + { { 63, 0 }, { Version("25w03a"), Version("25w03a") } }, + { { 64, 0 }, { Version("25w04a"), Version("25w04a") } }, + { { 65, 0 }, { Version("25w05a"), Version("25w05a") } }, + { { 66, 0 }, { Version("25w06a"), Version("25w06a") } }, + { { 67, 0 }, { Version("25w07a"), Version("25w07a") } }, + { { 68, 0 }, { Version("25w08a"), Version("25w08a") } }, + { { 69, 0 }, { Version("25w09a"), Version("25w09b") } }, + { { 70, 0 }, { Version("25w10a"), Version("1.21.5-pre1") } }, + { { 71, 0 }, { Version("1.21.5"), Version("1.21.5") } }, + { { 72, 0 }, { Version("25w15a"), Version("25w15a") } }, + { { 73, 0 }, { Version("25w16a"), Version("25w16a") } }, + { { 74, 0 }, { Version("25w17a"), Version("25w17a") } }, + { { 75, 0 }, { Version("25w18a"), Version("25w18a") } }, + { { 76, 0 }, { Version("25w19a"), Version("25w19a") } }, + { { 77, 0 }, { Version("25w20a"), Version("25w20a") } }, + { { 78, 0 }, { Version("25w21a"), Version("25w21a") } }, + { { 79, 0 }, { Version("1.21.6-pre1"), Version("1.21.6-pre2") } }, + { { 80, 0 }, { Version("1.21.6"), Version("1.21.6") } }, + { { 81, 0 }, { Version("1.21.7"), Version("1.21.8") } }, + { { 82, 0 }, { Version("25w31a"), Version("25w31a") } }, + { { 83, 0 }, { Version("25w32a"), Version("25w32a") } }, + { { 83, 1 }, { Version("25w33a"), Version("25w33a") } }, + { { 84, 0 }, { Version("25w34a"), Version("25w34b") } }, + { { 85, 0 }, { Version("25w35a"), Version("25w35a") } }, + { { 86, 0 }, { Version("25w36a"), Version("25w36b") } }, + { { 87, 0 }, { Version("25w37a"), Version("1.21.9-pre1") } }, + { { 87, 1 }, { Version("1.21.9-pre1"), Version("1.21.9-pre1") } }, + { { 88, 0 }, { Version("1.21.9"), Version("1.21.10") } }, + { { 89, 0 }, { Version("25w41a"), Version("25w41a") } }, + { { 90, 0 }, { Version("25w42a"), Version("25w42a") } }, + { { 91, 0 }, { Version("25w43a"), Version("25w43a") } }, + { { 92, 0 }, { Version("25w44a"), Version("25w44a") } }, + { { 93, 0 }, { Version("25w45a"), Version("25w45a") } }, + { { 93, 1 }, { Version("25w46a"), Version("25w46a") } }, + { { 94, 0 }, { Version("1.21.11-pre1"), Version("1.21.11-pre3") } }, + { { 94, 1 }, { Version("1.21.11-pre4"), Version("1.21.11") } }, + { { 95, 0 }, { Version("26.1-snap1"), Version("26.1-snap1") } }, + { { 96, 0 }, { Version("26.1-snap2"), Version("26.1-snap2") } }, + { { 97, 0 }, { Version("26.1-snap3"), Version("26.1-snap3") } }, + { { 97, 1 }, { Version("26.1-snap4"), Version("26.1-snap4") } }, + { { 98, 0 }, { Version("26.1-snap5"), Version("26.1-snap5") } }, + { { 99, 0 }, { Version("26.1-snap6"), Version("26.1-snap6") } }, + { { 99, 1 }, { Version("26.1-snap7"), Version("26.1-snap7") } }, + { { 99, 2 }, { Version("26.1-snap8"), Version("26.1-snap9") } }, + { { 99, 3 }, { Version("26.1-snap10"), Version("26.1-snap10") } }, + { { 100, 0 }, { Version("26.1-snap11"), Version("26.1-snap11") } }, +}; + +void DataPack::setPackFormat(int new_format_id, std::pair min_format, std::pair max_format) +{ + QMutexLocker locker(&m_data_lock); + + m_pack_format = new_format_id; + m_min_format = min_format; + m_max_format = max_format; +} + +void DataPack::setDescription(QString new_description) +{ + QMutexLocker locker(&m_data_lock); + + m_description = new_description; +} + +void DataPack::setImage(QImage new_image) const +{ + QMutexLocker locker(&m_data_lock); + + Q_ASSERT(!new_image.isNull()); + + if (m_pack_image_cache_key.key.isValid()) + PixmapCache::instance().remove(m_pack_image_cache_key.key); + + // scale the image to avoid flooding the pixmapcache + auto pixmap = + QPixmap::fromImage(new_image.scaled({ 64, 64 }, Qt::AspectRatioMode::KeepAspectRatioByExpanding, Qt::SmoothTransformation)); + + m_pack_image_cache_key.key = PixmapCache::instance().insert(pixmap); + m_pack_image_cache_key.was_ever_used = true; + + // This can happen if the pixmap is too big to fit in the cache :c + if (!m_pack_image_cache_key.key.isValid()) { + qWarning() << "Could not insert a image cache entry! Ignoring it."; + m_pack_image_cache_key.was_ever_used = false; + } +} + +QPixmap DataPack::image(QSize size, Qt::AspectRatioMode mode) const +{ + QPixmap cached_image; + if (PixmapCache::instance().find(m_pack_image_cache_key.key, &cached_image)) { + if (size.isNull()) + return cached_image; + return cached_image.scaled(size, mode, Qt::SmoothTransformation); + } + + // No valid image we can get + if (!m_pack_image_cache_key.was_ever_used) { + return {}; + } else { + qDebug() << "Data Pack" << name() << "Had it's image evicted from the cache. reloading..."; + PixmapCache::markCacheMissByEviciton(); + } + + // Imaged got evicted from the cache. Re-process it and retry. + DataPackUtils::processPackPNG(this); + return image(size); +} + +static std::pair map(std::pair format, const QMap, std::pair>& versions) +{ + if (format.first == 0 || !versions.contains(format)) { + return { {}, {} }; + } + return versions.constFind(format).value(); +} +static std::pair map(int format, const QMap, std::pair>& versions) +{ + return map({ format, 0 }, versions); +} + +int DataPack::compare(const Resource& other, SortType type) const +{ + const auto& cast_other = static_cast(other); + if (type == SortType::PACK_FORMAT) { + auto this_ver = packFormat(); + auto other_ver = cast_other.packFormat(); + + if (this_ver > other_ver) + return 1; + if (this_ver < other_ver) + return -1; + } else { + return Resource::compare(other, type); + } + return 0; +} + +bool DataPack::applyFilter(QRegularExpression filter) const +{ + if (filter.match(description()).hasMatch()) { + return true; + } + + if (filter.match(QString::number(packFormat())).hasMatch()) { + return true; + } + + auto versions = { map(m_pack_format, mappings()), map(m_min_format, mappings()), map(m_max_format, mappings()) }; + for (const auto& version : versions) { + if (!version.first.isEmpty()) { + if (filter.match(version.first.toString()).hasMatch()) { + return true; + } + if (filter.match(version.second.toString()).hasMatch()) { + return true; + } + } + } + return Resource::applyFilter(filter); +} + +bool DataPack::valid() const +{ + return m_pack_format != 0 || (m_min_format.first != 0 && m_max_format.first != 0); +} + +QMap, std::pair> DataPack::mappings() const +{ + return s_pack_format_versions; +} + +QString DataPack::packFormatStr() const +{ + if (m_pack_format != 0) { + auto version_bounds = map(m_pack_format, mappings()); + if (version_bounds.first.toString().isEmpty()) { + return QString::number(m_pack_format); + } + return QString("%1 (%2 - %3)") + .arg(QString::number(m_pack_format), version_bounds.first.toString(), version_bounds.second.toString()); + } + auto min_bound = map(m_min_format, mappings()); + auto max_bound = map(m_max_format, mappings()); + auto min_version = min_bound.first; + auto max_version = max_bound.second; + if (min_version.isEmpty() || max_version.isEmpty()) { + return tr("Unrecognized"); + } + auto str = QString("[") + QString::number(m_min_format.first); + if (m_min_format.second != 0) { + str += "." + QString::number(m_min_format.second); + } + + str += QString(" - ") + QString::number(m_max_format.first); + if (m_max_format.second != 0) { + str += "." + QString::number(m_max_format.second); + } + + return str + QString(" (%2 - %3)").arg(min_version.toString(), max_version.toString()); +} diff --git a/launcher/minecraft/mod/DataPack.h b/launcher/minecraft/mod/DataPack.h new file mode 100644 index 0000000..89da017 --- /dev/null +++ b/launcher/minecraft/mod/DataPack.h @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> +// +// SPDX-License-Identifier: GPL-3.0-only + +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include "Resource.h" + +#include +#include +#include + +class Version; + +/* TODO: + * + * Store localized descriptions + * */ + +class DataPack : public Resource { + Q_OBJECT + public: + DataPack(QObject* parent = nullptr) : Resource(parent) {} + DataPack(QFileInfo file_info) : Resource(file_info) {} + + /** Gets the numerical ID of the pack format. */ + int packFormat() const { return m_pack_format; } + + /** Gets the description of the data pack. */ + QString description() const { return m_description; } + + /** Gets the image of the data pack, converted to a QPixmap for drawing, and scaled to size. */ + QPixmap image(QSize size, Qt::AspectRatioMode mode = Qt::AspectRatioMode::IgnoreAspectRatio) const; + + /** Thread-safe. */ + void setPackFormat(int new_format_id, std::pair min_format, std::pair max_format); + + /** Thread-safe. */ + void setDescription(QString new_description); + + /** Thread-safe. */ + void setImage(QImage new_image) const; + + bool valid() const override; + + [[nodiscard]] int compare(const Resource& other, SortType type) const override; + [[nodiscard]] bool applyFilter(QRegularExpression filter) const override; + + QString packFormatStr() const; + + protected: + virtual QMap, std::pair> mappings() const; + + protected: + mutable QMutex m_data_lock; + + /* The 'version' of a data pack, as defined in the pack.mcmeta file. + * See https://minecraft.wiki/w/Data_pack#pack.mcmeta + */ + int m_pack_format = 0; + std::pair m_min_format; + std::pair m_max_format; + + /** The data pack's description, as defined in the pack.mcmeta file. + */ + QString m_description; + + /** The data pack's image file cache key, for access in the QPixmapCache global instance. + * + * The 'was_ever_used' state simply identifies whether the key was never inserted on the cache (true), + * so as to tell whether a cache entry is inexistent or if it was just evicted from the cache. + */ + struct { + QPixmapCache::Key key; + bool was_ever_used = false; + } mutable m_pack_image_cache_key; +}; diff --git a/launcher/minecraft/mod/DataPackFolderModel.cpp b/launcher/minecraft/mod/DataPackFolderModel.cpp new file mode 100644 index 0000000..f1497b8 --- /dev/null +++ b/launcher/minecraft/mod/DataPackFolderModel.cpp @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "DataPackFolderModel.h" + +#include +#include + +#include "Version.h" + +#include "minecraft/mod/tasks/LocalDataPackParseTask.h" + +DataPackFolderModel::DataPackFolderModel(const QString& dir, BaseInstance* instance, bool is_indexed, bool create_dir, QObject* parent) + : ResourceFolderModel(QDir(dir), instance, is_indexed, create_dir, parent) +{ + m_column_names = QStringList({ "Enable", "Image", "Name", "Pack Format", "Last Modified" }); + m_column_names_translated = QStringList({ tr("Enable"), tr("Image"), tr("Name"), tr("Pack Format"), tr("Last Modified") }); + m_column_sort_keys = { SortType::ENABLED, SortType::NAME, SortType::NAME, SortType::PACK_FORMAT, SortType::DATE }; + m_column_resize_modes = { QHeaderView::Interactive, QHeaderView::Interactive, QHeaderView::Stretch, QHeaderView::Interactive, + QHeaderView::Interactive }; + m_columnsHideable = { false, true, false, true, true }; +} + +QVariant DataPackFolderModel::data(const QModelIndex& index, int role) const +{ + if (!validateIndex(index)) + return {}; + + int row = index.row(); + int column = index.column(); + + switch (role) { + case Qt::BackgroundRole: + return rowBackground(row); + case Qt::DisplayRole: + switch (column) { + case PackFormatColumn: { + const auto& resource = at(row); + return resource.packFormatStr(); + } + } + break; + case Qt::DecorationRole: { + if (column == ImageColumn) { + return at(row).image({ 32, 32 }, Qt::AspectRatioMode::KeepAspectRatioByExpanding); + } + break; + } + case Qt::ToolTipRole: { + if (column == PackFormatColumn) { + //: The string being explained by this is in the format: ID (Lower version - Upper version) + return tr("The data pack format ID, as well as the Minecraft versions it was designed for."); + } + break; + } + case Qt::SizeHintRole: + if (column == ImageColumn) { + return QSize(32, 32); + } + break; + } + + // map the columns to the base equivilents + QModelIndex mappedIndex; + switch (column) { + case ActiveColumn: + mappedIndex = index.siblingAtColumn(ResourceFolderModel::ActiveColumn); + break; + case NameColumn: + mappedIndex = index.siblingAtColumn(ResourceFolderModel::NameColumn); + break; + case DateColumn: + mappedIndex = index.siblingAtColumn(ResourceFolderModel::DateColumn); + break; + case ProviderColumn: + mappedIndex = index.siblingAtColumn(ResourceFolderModel::ProviderColumn); + break; + // FIXME: there is no size column due to an oversight + } + + if (mappedIndex.isValid()) { + return ResourceFolderModel::data(mappedIndex, role); + } + + return {}; +} + +QVariant DataPackFolderModel::headerData(int section, [[maybe_unused]] Qt::Orientation orientation, int role) const +{ + switch (role) { + case Qt::DisplayRole: + switch (section) { + case ActiveColumn: + case NameColumn: + case PackFormatColumn: + case DateColumn: + case ImageColumn: + return columnNames().at(section); + default: + return {}; + } + + case Qt::ToolTipRole: + switch (section) { + case ActiveColumn: + return tr("Is the data pack enabled? (Only valid for ZIPs)"); + case NameColumn: + return tr("The name of the data pack."); + case PackFormatColumn: + //: The string being explained by this is in the format: ID (Lower version - Upper version) + return tr("The data pack format ID, as well as the Minecraft versions it was designed for."); + case DateColumn: + return tr("The date and time this data pack was last changed (or added)."); + default: + return {}; + } + case Qt::SizeHintRole: + if (section == ImageColumn) { + return QSize(64, 0); + } + return {}; + default: + return {}; + } +} + +int DataPackFolderModel::columnCount(const QModelIndex& parent) const +{ + return parent.isValid() ? 0 : NUM_COLUMNS; +} + +Resource* DataPackFolderModel::createResource(const QFileInfo& file) +{ + return new DataPack(file); +} + +Task* DataPackFolderModel::createParseTask(Resource& resource) +{ + return new LocalDataPackParseTask(m_next_resolution_ticket, static_cast(&resource)); +} diff --git a/launcher/minecraft/mod/DataPackFolderModel.h b/launcher/minecraft/mod/DataPackFolderModel.h new file mode 100644 index 0000000..2b90e1a --- /dev/null +++ b/launcher/minecraft/mod/DataPackFolderModel.h @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "ResourceFolderModel.h" + +#include "DataPack.h" +#include "ResourcePack.h" + +class DataPackFolderModel : public ResourceFolderModel { + Q_OBJECT + public: + enum Columns { ActiveColumn = 0, ImageColumn, NameColumn, PackFormatColumn, DateColumn, NUM_COLUMNS }; + + explicit DataPackFolderModel(const QString& dir, BaseInstance* instance, bool is_indexed, bool create_dir, QObject* parent = nullptr); + + virtual QString id() const override { return "datapacks"; } + + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + + QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; + int columnCount(const QModelIndex& parent) const override; + + [[nodiscard]] Resource* createResource(const QFileInfo& file) override; + [[nodiscard]] Task* createParseTask(Resource&) override; + + RESOURCE_HELPERS(DataPack) +}; diff --git a/launcher/minecraft/mod/MetadataHandler.h b/launcher/minecraft/mod/MetadataHandler.h new file mode 100644 index 0000000..5f12348 --- /dev/null +++ b/launcher/minecraft/mod/MetadataHandler.h @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include "modplatform/packwiz/Packwiz.h" + +namespace Metadata { +using ModStruct = Packwiz::V1::Mod; + +inline ModStruct create(const QDir& index_dir, ModPlatform::IndexedPack& mod_pack, ModPlatform::IndexedVersion& mod_version) +{ + return Packwiz::V1::createModFormat(index_dir, mod_pack, mod_version); +} + +inline void update(const QDir& index_dir, ModStruct& mod) +{ + Packwiz::V1::updateModIndex(index_dir, mod); +} + +inline void remove(const QDir& index_dir, QString mod_slug) +{ + Packwiz::V1::deleteModIndex(index_dir, mod_slug); +} + +inline ModStruct get(const QDir& index_dir, QString mod_slug) +{ + return Packwiz::V1::getIndexForMod(index_dir, std::move(mod_slug)); +} + +inline ModStruct get(const QDir& index_dir, QVariant& mod_id) +{ + return Packwiz::V1::getIndexForMod(index_dir, mod_id); +} + +}; // namespace Metadata diff --git a/launcher/minecraft/mod/Mod.cpp b/launcher/minecraft/mod/Mod.cpp new file mode 100644 index 0000000..661192d --- /dev/null +++ b/launcher/minecraft/mod/Mod.cpp @@ -0,0 +1,321 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "Mod.h" + +#include +#include +#include + +#include "MTPixmapCache.h" +#include "MetadataHandler.h" +#include "Resource.h" +#include "Version.h" +#include "minecraft/mod/ModDetails.h" +#include "minecraft/mod/tasks/LocalModParseTask.h" +#include "modplatform/ModIndex.h" + +Mod::Mod(const QFileInfo& file) : Resource(file), m_local_details() +{ + m_enabled = (file.suffix() != "disabled"); +} + +void Mod::setDetails(const ModDetails& details) +{ + m_local_details = details; +} + +int Mod::compare(const Resource& other, SortType type) const +{ + auto cast_other = dynamic_cast(&other); + if (!cast_other) + return Resource::compare(other, type); + + switch (type) { + default: + case SortType::ENABLED: + case SortType::NAME: + case SortType::DATE: + case SortType::SIZE: + return Resource::compare(other, type); + case SortType::VERSION: { + auto this_ver = Version(version()); + auto other_ver = Version(cast_other->version()); + if (this_ver > other_ver) + return 1; + if (this_ver < other_ver) + return -1; + break; + } + case SortType::SIDE: { + auto compare_result = QString::compare(side(), cast_other->side(), Qt::CaseInsensitive); + if (compare_result != 0) + return compare_result; + break; + } + case SortType::MC_VERSIONS: { + auto compare_result = QString::compare(mcVersions(), cast_other->mcVersions(), Qt::CaseInsensitive); + if (compare_result != 0) + return compare_result; + break; + } + case SortType::LOADERS: { + auto compare_result = QString::compare(loaders(), cast_other->loaders(), Qt::CaseInsensitive); + if (compare_result != 0) + return compare_result; + break; + } + case SortType::RELEASE_TYPE: { + auto compare_result = QString::compare(releaseType(), cast_other->releaseType(), Qt::CaseInsensitive); + if (compare_result != 0) + return compare_result; + break; + } + case SortType::REQUIRED_BY: { + if (requiredByCount() > cast_other->requiredByCount()) + return 1; + if (requiredByCount() < cast_other->requiredByCount()) + return -1; + break; + } + case SortType::REQUIRES: { + if (requiresCount() > cast_other->requiresCount()) + return 1; + if (requiresCount() < cast_other->requiresCount()) + return -1; + break; + } + } + return 0; +} + +bool Mod::applyFilter(QRegularExpression filter) const +{ + if (filter.match(description()).hasMatch()) + return true; + + for (auto& author : authors()) { + if (filter.match(author).hasMatch()) { + return true; + } + } + + return Resource::applyFilter(filter); +} + +auto Mod::details() const -> const ModDetails& +{ + return m_local_details; +} + +auto Mod::name() const -> QString +{ + auto d_name = details().name; + if (!d_name.isEmpty()) + return d_name; + + return Resource::name(); +} + +auto Mod::mod_id() const -> QString +{ + auto d_mod_id = details().mod_id; + if (!d_mod_id.isEmpty()) + return d_mod_id; + + return Resource::name(); +} + +auto Mod::version() const -> QString +{ + return details().version; +} + +auto Mod::homepage() const -> QString +{ + QString metaUrl = Resource::homepage(); + + if (metaUrl.isEmpty()) + return details().homeurl; + else + return metaUrl; +} + +auto Mod::loaders() const -> QString +{ + if (metadata()) { + QStringList loaders; + auto modLoaders = metadata()->loaders; + for (auto loader : ModPlatform::modLoaderTypesToList(modLoaders)) { + loaders << getModLoaderAsString(loader); + } + return loaders.join(", "); + } + + return {}; +} + +auto Mod::side() const -> QString +{ + if (metadata()) + return ModPlatform::SideUtils::toString(metadata()->side); + + return ModPlatform::SideUtils::toString(ModPlatform::Side::UniversalSide); +} + +auto Mod::mcVersions() const -> QString +{ + if (metadata()) + return metadata()->mcVersions.join(", "); + + return {}; +} + +auto Mod::releaseType() const -> QString +{ + if (metadata()) + return metadata()->releaseType.toString(); + + return ModPlatform::IndexedVersionType(ModPlatform::IndexedVersionType::Unknown).toString(); +} + +auto Mod::description() const -> QString +{ + return details().description; +} + +auto Mod::authors() const -> QStringList +{ + return details().authors; +} + +void Mod::finishResolvingWithDetails(ModDetails&& details) +{ + m_is_resolving = false; + m_is_resolved = true; + + m_local_details = std::move(details); + if (!iconPath().isEmpty()) { + m_packImageCacheKey.wasReadAttempt = false; + } +} + +auto Mod::licenses() const -> const QList& +{ + return details().licenses; +} + +auto Mod::issueTracker() const -> QString +{ + return details().issue_tracker; +} + +QPixmap Mod::setIcon(QImage new_image) const +{ + QMutexLocker locker(&m_data_lock); + + Q_ASSERT(!new_image.isNull()); + + if (m_packImageCacheKey.key.isValid()) + PixmapCache::remove(m_packImageCacheKey.key); + + // scale the image to avoid flooding the pixmapcache + auto pixmap = + QPixmap::fromImage(new_image.scaled({ 64, 64 }, Qt::AspectRatioMode::KeepAspectRatioByExpanding, Qt::SmoothTransformation)); + + m_packImageCacheKey.key = PixmapCache::insert(pixmap); + m_packImageCacheKey.wasEverUsed = true; + m_packImageCacheKey.wasReadAttempt = true; + return pixmap; +} + +QPixmap Mod::icon(QSize size, Qt::AspectRatioMode mode) const +{ + auto pixmap_transform = [&size, &mode](QPixmap pixmap) { + if (size.isNull()) + return pixmap; + return pixmap.scaled(size, mode, Qt::SmoothTransformation); + }; + + QPixmap cached_image; + if (PixmapCache::find(m_packImageCacheKey.key, &cached_image)) { + return pixmap_transform(cached_image); + } + + // No valid image we can get + if ((!m_packImageCacheKey.wasEverUsed && m_packImageCacheKey.wasReadAttempt) || iconPath().isEmpty()) + return {}; + + if (m_packImageCacheKey.wasEverUsed) { + qDebug() << "Mod" << name() << "Had it's icon evicted from the cache. reloading..."; + PixmapCache::markCacheMissByEviciton(); + } + // Image got evicted from the cache or an attempt to load it has not been made. load it and retry. + m_packImageCacheKey.wasReadAttempt = true; + if (ModUtils::loadIconFile(*this, &cached_image)) { + return pixmap_transform(cached_image); + } + // Image failed to load + return {}; +} + +bool Mod::valid() const +{ + return !m_local_details.mod_id.isEmpty(); +} + +QStringList Mod::dependencies() const +{ + return details().dependencies; +} + +int Mod::requiredByCount() const +{ + return m_requiredByCount; +} +int Mod::requiresCount() const +{ + return m_requiresCount; +} +void Mod::setRequiredByCount(int value) +{ + m_requiredByCount = value; +} +void Mod::setRequiresCount(int value) +{ + m_requiresCount = value; +} diff --git a/launcher/minecraft/mod/Mod.h b/launcher/minecraft/mod/Mod.h new file mode 100644 index 0000000..0d24409 --- /dev/null +++ b/launcher/minecraft/mod/Mod.h @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "ModDetails.h" +#include "Resource.h" + +class Mod : public Resource { + Q_OBJECT + public: + using Ptr = shared_qobject_ptr; + using WeakPtr = QPointer; + + Mod() = default; + Mod(const QFileInfo& file); + Mod(QString file_path) : Mod(QFileInfo(file_path)) {} + + auto details() const -> const ModDetails&; + auto name() const -> QString override; + auto mod_id() const -> QString; + auto version() const -> QString; + auto homepage() const -> QString override; + auto description() const -> QString; + auto authors() const -> QStringList; + auto licenses() const -> const QList&; + auto issueTracker() const -> QString; + auto side() const -> QString; + auto loaders() const -> QString; + auto mcVersions() const -> QString; + auto releaseType() const -> QString; + QStringList dependencies() const; + + int requiredByCount() const; + int requiresCount() const; + + void setRequiredByCount(int value); + void setRequiresCount(int value); + + /** Get the intneral path to the mod's icon file*/ + QString iconPath() const { return m_local_details.icon_file; } + /** Gets the icon of the mod, converted to a QPixmap for drawing, and scaled to size. */ + QPixmap icon(QSize size, Qt::AspectRatioMode mode = Qt::AspectRatioMode::IgnoreAspectRatio) const; + /** Thread-safe. */ + QPixmap setIcon(QImage new_image) const; + + void setDetails(const ModDetails& details); + + bool valid() const override; + + [[nodiscard]] int compare(const Resource& other, SortType type) const override; + [[nodiscard]] bool applyFilter(QRegularExpression filter) const override; + + // Delete all the files of this mod + auto destroy(QDir& index_dir, bool preserve_metadata = false, bool attempt_trash = true) -> bool; + // Delete the metadata only + void destroyMetadata(QDir& index_dir); + + void finishResolvingWithDetails(ModDetails&& details); + + protected: + ModDetails m_local_details; + + mutable QMutex m_data_lock; + + struct { + QPixmapCache::Key key; + bool wasEverUsed = false; + bool wasReadAttempt = false; + } mutable m_packImageCacheKey; + + int m_requiredByCount = 0; + int m_requiresCount = 0; +}; diff --git a/launcher/minecraft/mod/ModDetails.h b/launcher/minecraft/mod/ModDetails.h new file mode 100644 index 0000000..02cf42a --- /dev/null +++ b/launcher/minecraft/mod/ModDetails.h @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +struct ModLicense { + QString name = {}; + QString id = {}; + QString url = {}; + QString description = {}; + + ModLicense() {} + + ModLicense(const QString license) + { + // FIXME: come up with a better license parsing. + // handle SPDX identifiers? https://spdx.org/licenses/ + auto parts = license.split(' '); + QStringList notNameParts = {}; + for (auto part : parts) { + auto _url = QUrl(part); + if (part.startsWith("(") && part.endsWith(")")) + _url = QUrl(part.mid(1, part.size() - 2)); + + if (_url.isValid() && !_url.scheme().isEmpty() && !_url.host().isEmpty()) { + this->url = _url.toString(); + notNameParts.append(part); + continue; + } + } + + for (auto part : notNameParts) { + parts.removeOne(part); + } + + auto licensePart = parts.join(' '); + this->name = licensePart; + this->description = licensePart; + + if (parts.size() == 1) { + this->id = parts.first(); + } + } + + ModLicense(const QString& name_, const QString& id_, const QString& url_, const QString& description_) + : name(name_), id(id_), url(url_), description(description_) + {} + + ModLicense(const ModLicense& other) : name(other.name), id(other.id), url(other.url), description(other.description) {} + + ModLicense& operator=(const ModLicense& other) + { + this->name = other.name; + this->id = other.id; + this->url = other.url; + this->description = other.description; + + return *this; + } + + ModLicense& operator=(const ModLicense&& other) + { + this->name = other.name; + this->id = other.id; + this->url = other.url; + this->description = other.description; + + return *this; + } + + bool isEmpty() { return this->name.isEmpty() && this->id.isEmpty() && this->url.isEmpty() && this->description.isEmpty(); } +}; + +struct ModDetails { + /* Mod ID as defined in the ModLoader-specific metadata */ + QString mod_id = {}; + + /* Human-readable name */ + QString name = {}; + + /* Human-readable mod version */ + QString version = {}; + + /* Human-readable minecraft version */ + QString mcversion = {}; + + /* URL for mod's home page */ + QString homeurl = {}; + + /* Human-readable description */ + QString description = {}; + + /* List of the author's names */ + QStringList authors = {}; + + /* Issue Tracker URL */ + QString issue_tracker = {}; + + /* License */ + QList licenses = {}; + + /* Path of mod logo */ + QString icon_file = {}; + + QStringList dependencies = {}; + + ModDetails() = default; + + /** Metadata should be handled manually to properly set the mod status. */ + ModDetails(const ModDetails& other) + : mod_id(other.mod_id) + , name(other.name) + , version(other.version) + , mcversion(other.mcversion) + , homeurl(other.homeurl) + , description(other.description) + , authors(other.authors) + , issue_tracker(other.issue_tracker) + , licenses(other.licenses) + , icon_file(other.icon_file) + , dependencies(other.dependencies) + {} + + ModDetails& operator=(const ModDetails& other) = default; + + ModDetails& operator=(ModDetails&& other) = default; +}; diff --git a/launcher/minecraft/mod/ModFolderModel.cpp b/launcher/minecraft/mod/ModFolderModel.cpp new file mode 100644 index 0000000..4d54be9 --- /dev/null +++ b/launcher/minecraft/mod/ModFolderModel.cpp @@ -0,0 +1,515 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ModFolderModel.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "minecraft/Component.h" +#include "minecraft/mod/Resource.h" +#include "minecraft/mod/ResourceFolderModel.h" +#include "minecraft/mod/tasks/LocalModParseTask.h" +#include "modplatform/ModIndex.h" +#include "ui/dialogs/CustomMessageBox.h" + +ModFolderModel::ModFolderModel(const QDir& dir, BaseInstance* instance, bool is_indexed, bool create_dir, QObject* parent) + : ResourceFolderModel(QDir(dir), instance, is_indexed, create_dir, parent) +{ + m_column_names = QStringList({ "Enable", "Image", "Name", "Version", "Last Modified", "Provider", "Size", "Side", "Loaders", + "Minecraft Versions", "Release Type", "Requires", "Required By" }); + m_column_names_translated = + QStringList({ tr("Enable"), tr("Image"), tr("Name"), tr("Version"), tr("Last Modified"), tr("Provider"), tr("Size"), tr("Side"), + tr("Loaders"), tr("Minecraft Versions"), tr("Release Type"), tr("Requires"), tr("Required By") }); + m_column_sort_keys = { SortType::ENABLED, SortType::NAME, SortType::NAME, SortType::VERSION, SortType::DATE, + SortType::PROVIDER, SortType::SIZE, SortType::SIDE, SortType::LOADERS, SortType::MC_VERSIONS, + SortType::RELEASE_TYPE, SortType::REQUIRES, SortType::REQUIRED_BY }; + m_column_resize_modes = { QHeaderView::Interactive, QHeaderView::Interactive, QHeaderView::Stretch, QHeaderView::Interactive, + QHeaderView::Interactive, QHeaderView::Interactive, QHeaderView::Interactive, QHeaderView::Interactive, + QHeaderView::Interactive, QHeaderView::Interactive, QHeaderView::Interactive, QHeaderView::Interactive, + QHeaderView::Interactive }; + m_columnsHideable = { false, true, false, true, true, true, true, true, true, true, true, true, true }; + + connect(this, &ModFolderModel::parseFinished, this, &ModFolderModel::onParseFinished); +} + +QVariant ModFolderModel::data(const QModelIndex& index, int role) const +{ + if (!validateIndex(index)) + return {}; + + int row = index.row(); + int column = index.column(); + + switch (role) { + case Qt::BackgroundRole: + return rowBackground(row); + case Qt::DisplayRole: + switch (column) { + case VersionColumn: { + switch (at(row).type()) { + case ResourceType::FOLDER: + return tr("Folder"); + case ResourceType::SINGLEFILE: + return tr("File"); + default: + return at(row).version(); + } + } + case SideColumn: { + return at(row).side(); + } + case LoadersColumn: { + return at(row).loaders(); + } + case McVersionsColumn: { + return at(row).mcVersions(); + } + case ReleaseTypeColumn: { + return at(row).releaseType(); + } + case RequiredByColumn: { + return at(row).requiredByCount(); + } + case RequiresColumn: { + return at(row).requiresCount(); + } + } + break; + case Qt::DecorationRole: { + if (column == ImageColumn) { + return at(row).icon({ 32, 32 }, Qt::AspectRatioMode::KeepAspectRatioByExpanding); + } + break; + } + case Qt::SizeHintRole: + if (column == ImageColumn) { + return QSize(32, 32); + } + break; + default: + break; + } + + // map the columns to the base equivilents + QModelIndex mappedIndex; + switch (column) { + case ActiveColumn: + mappedIndex = index.siblingAtColumn(ResourceFolderModel::ActiveColumn); + break; + case NameColumn: + mappedIndex = index.siblingAtColumn(ResourceFolderModel::NameColumn); + break; + case DateColumn: + mappedIndex = index.siblingAtColumn(ResourceFolderModel::DateColumn); + break; + case ProviderColumn: + mappedIndex = index.siblingAtColumn(ResourceFolderModel::ProviderColumn); + break; + case SizeColumn: + mappedIndex = index.siblingAtColumn(ResourceFolderModel::SizeColumn); + break; + } + + if (mappedIndex.isValid()) { + return ResourceFolderModel::data(mappedIndex, role); + } + + return {}; +} + +QVariant ModFolderModel::headerData(int section, [[maybe_unused]] Qt::Orientation orientation, int role) const +{ + switch (role) { + case Qt::DisplayRole: + switch (section) { + case ActiveColumn: + case NameColumn: + case VersionColumn: + case DateColumn: + case ProviderColumn: + case ImageColumn: + case SideColumn: + case LoadersColumn: + case McVersionsColumn: + case ReleaseTypeColumn: + case SizeColumn: + case RequiredByColumn: + case RequiresColumn: + return columnNames().at(section); + default: + return QVariant(); + } + + case Qt::ToolTipRole: + switch (section) { + case ActiveColumn: + return tr("Is the mod enabled?"); + case NameColumn: + return tr("The name of the mod."); + case VersionColumn: + return tr("The version of the mod."); + case DateColumn: + return tr("The date and time this mod was last changed (or added)."); + case ProviderColumn: + return tr("The source provider of the mod."); + case SideColumn: + return tr("On what environment the mod is running."); + case LoadersColumn: + return tr("The mod loader."); + case McVersionsColumn: + return tr("The supported minecraft versions."); + case ReleaseTypeColumn: + return tr("The release type."); + case SizeColumn: + return tr("The size of the mod."); + case RequiredByColumn: + return tr("For each mod, the number of other mods which depend on it."); + case RequiresColumn: + return tr("For each mod, the number of other mods it depends on."); + default: + return QVariant(); + } + default: + return QVariant(); + } +} + +int ModFolderModel::columnCount(const QModelIndex& parent) const +{ + return parent.isValid() ? 0 : NUM_COLUMNS; +} + +Task* ModFolderModel::createParseTask(Resource& resource) +{ + return new LocalModParseTask(m_next_resolution_ticket, resource.type(), resource.fileinfo()); +} + +bool ModFolderModel::isValid() +{ + return m_dir.exists() && m_dir.isReadable(); +} + +void ModFolderModel::onParseSucceeded(int ticket, QString mod_id) +{ + auto iter = m_active_parse_tasks.constFind(ticket); + if (iter == m_active_parse_tasks.constEnd()) + return; + + int row = m_resources_index[mod_id]; + + auto parse_task = *iter; + auto cast_task = static_cast(parse_task.get()); + + Q_ASSERT(cast_task->token() == ticket); + + auto resource = find(mod_id); + + auto result = cast_task->result(); + if (result && resource) { + auto* mod = static_cast(resource.get()); + mod->finishResolvingWithDetails(std::move(result->details)); + + } + emit dataChanged(index(row, RequiresColumn), index(row, RequiredByColumn)); +} + +Mod* findById(QSet mods, QString modId) +{ + auto found = std::find_if(mods.begin(), mods.end(), [modId](Mod* m) { return m->mod_id() == modId; }); + return found != mods.end() ? *found : nullptr; +} + +void ModFolderModel::onParseFinished() +{ + if (hasPendingParseTasks()) { + return; + } + auto modsList = allMods(); + auto mods = QSet(modsList.begin(), modsList.end()); + + m_requires.clear(); + m_requiredBy.clear(); + + auto findByProjectID = [mods](QVariant modId, ModPlatform::ResourceProvider provider) -> Mod* { + auto found = std::find_if(mods.begin(), mods.end(), [modId, provider](Mod* m) { + return m->metadata() && m->metadata()->provider == provider && m->metadata()->project_id == modId; + }); + return found != mods.end() ? *found : nullptr; + }; + for (auto mod : mods) { + auto id = mod->mod_id(); + for (auto dep : mod->dependencies()) { + auto d = findById(mods, dep); + if (d) { + m_requires[id] << d; + m_requiredBy[d->mod_id()] << mod; + } + } + if (mod->metadata()) { + for (auto dep : mod->metadata()->dependencies) { + if (dep.type == ModPlatform::DependencyType::REQUIRED) { + auto d = findByProjectID(dep.addonId, mod->metadata()->provider); + if (d) { + m_requires[id] << d; + m_requiredBy[d->mod_id()] << mod; + } + } + } + } + } + for (auto mod : mods) { + auto id = mod->mod_id(); + if (mod->requiredByCount() != m_requiredBy[id].count() || mod->requiresCount() != m_requires[id].count()) { + mod->setRequiredByCount(m_requiredBy[id].count()); + mod->setRequiresCount(m_requires[id].count()); + int row = m_resources_index[mod->internal_id()]; + emit dataChanged(index(row), index(row, columnCount(QModelIndex()) - 1)); + } + } +} + +QSet collectMods(QSet mods, QHash> relation, std::set& seen, bool shouldBeEnabled) +{ + QSet affectedList = {}; + QSet needToCheck = {}; + for (auto mod : mods) { + auto id = mod->mod_id(); + if (seen.count(id) == 0) { + seen.insert(id); + for (auto affected : relation[id]) { + auto affectedId = affected->mod_id(); + + if (findById(mods, affectedId) == nullptr && seen.count(affectedId) == 0) { + seen.insert(affectedId); + if (shouldBeEnabled != affected->enabled()) { + affectedList << affected; + } + needToCheck << affected; + } + } + } + } + // collect the affected mods until all of them are included in the list + if (!needToCheck.isEmpty()) { + affectedList += collectMods(needToCheck, relation, seen, shouldBeEnabled); + } + return affectedList; +} + +QModelIndexList ModFolderModel::getAffectedMods(const QModelIndexList& indexes, EnableAction action) +{ + if (indexes.isEmpty()) + return {}; + + QModelIndexList affectedList = {}; + auto affectedModsList = selectedMods(indexes); + auto affectedMods = QSet(affectedModsList.begin(), affectedModsList.end()); + std::set seen; + + switch (action) { + case EnableAction::ENABLE: { + affectedMods = collectMods(affectedMods, m_requires, seen, true); + break; + } + case EnableAction::DISABLE: { + affectedMods = collectMods(affectedMods, m_requiredBy, seen, false); + break; + } + case EnableAction::TOGGLE: { + return {}; // this function should not be called with TOGGLE + } + } + for (auto affected : affectedMods) { + auto affectedId = affected->mod_id(); + auto row = m_resources_index[affected->internal_id()]; + affectedList << index(row, 0); + } + return affectedList; +} + +bool ModFolderModel::setResourceEnabled(const QModelIndexList& indexes, EnableAction action) +{ + if (indexes.isEmpty()) + return {}; + + auto indexedModsList = selectedMods(indexes); + auto indexedMods = QSet(indexedModsList.begin(), indexedModsList.end()); + + QSet toEnable = {}; + QSet toDisable = {}; + std::set seen; + + switch (action) { + case EnableAction::ENABLE: { + toEnable = indexedMods; + break; + } + case EnableAction::DISABLE: { + toDisable = indexedMods; + break; + } + case EnableAction::TOGGLE: { + for (auto mod : indexedMods) { + if (mod->enabled()) { + toDisable << mod; + } else { + toEnable << mod; + } + } + break; + } + } + + auto requiredToEnable = collectMods(toEnable, m_requires, seen, true); + auto requiredToDisable = collectMods(toDisable, m_requiredBy, seen, false); + + toDisable.removeIf([toEnable](Mod* m) { return toEnable.contains(m); }); + auto toList = [this](QSet mods) { + QModelIndexList list; + for (auto mod : mods) { + auto row = m_resources_index[mod->internal_id()]; + list << index(row, 0); + } + return list; + }; + + if (requiredToEnable.size() > 0 || requiredToDisable.size() > 0) { + QString title; + QString message; + QString noButton; + QString yesButton; + if (requiredToEnable.size() > 0 && requiredToDisable.size() > 0) { + title = tr("Confirm toggle"); + message = tr("Toggling these mod(s) will cause changes to other mods.\n") + + tr("%n mod(s) will be enabled\n", "", requiredToEnable.size()) + + tr("%n mod(s) will be disabled\n", "", requiredToDisable.size()) + + tr("Do you want to automatically apply these related changes?\nIgnoring them may break the game."); + noButton = tr("Only Toggle Selected"); + yesButton = tr("Toggle Required Mods"); + } else if (requiredToEnable.size() > 0) { + title = tr("Confirm enable"); + message = tr("The enabled mod(s) require %n mod(s).\n", "", requiredToEnable.size()) + + tr("Would you like to enable them as well?\nIgnoring them may break the game."); + noButton = tr("Only Enable Selected"); + yesButton = tr("Enable Required"); + } else { + title = tr("Confirm disable"); + message = tr("The disabled mod(s) are required by %n mod(s).\n", "", requiredToDisable.size()) + + tr("Would you like to disable them as well?\nIgnoring them may break the game."); + noButton = tr("Only Disable Selected"); + yesButton = tr("Disable Required"); + } + + auto box = CustomMessageBox::selectable(nullptr, title, message, QMessageBox::Warning, + QMessageBox::Yes | QMessageBox::No | QMessageBox::Cancel, QMessageBox::No); + box->button(QMessageBox::No)->setText(noButton); + box->button(QMessageBox::Yes)->setText(yesButton); + auto response = box->exec(); + + if (response == QMessageBox::Yes) { + toEnable |= requiredToEnable; + toDisable |= requiredToDisable; + } else if (response == QMessageBox::Cancel) { + return false; + } + } + + auto disableStatus = ResourceFolderModel::setResourceEnabled(toList(toDisable), EnableAction::DISABLE); + auto enableStatus = ResourceFolderModel::setResourceEnabled(toList(toEnable), EnableAction::ENABLE); + return disableStatus && enableStatus; +} + +QStringList reqToList(QSet l) +{ + QStringList req; + for (auto m : l) { + req << m->name(); + } + return req; +} + +QStringList ModFolderModel::requiresList(QString id) +{ + return reqToList(m_requires[id]); +} + +QStringList ModFolderModel::requiredByList(QString id) +{ + return reqToList(m_requiredBy[id]); +} + +bool ModFolderModel::deleteResources(const QModelIndexList& indexes) +{ + auto deleteInvalid = [](QSet& mods) { + for (auto it = mods.begin(); it != mods.end();) { + auto mod = *it; + // the QFileInfo::exists is used instead of mod->fileinfo().exists + // because the later somehow caches that the file exists + if (!mod || !QFileInfo::exists(mod->fileinfo().absoluteFilePath())) { + it = mods.erase(it); + } else { + ++it; + } + } + }; + auto rsp = ResourceFolderModel::deleteResources(indexes); + for (auto mod : allMods()) { + auto id = mod->mod_id(); + deleteInvalid(m_requiredBy[id]); + deleteInvalid(m_requires[id]); + if (mod->requiredByCount() != m_requiredBy[id].count() || mod->requiresCount() != m_requires[id].count()) { + mod->setRequiredByCount(m_requiredBy[id].count()); + mod->setRequiresCount(m_requires[id].count()); + int row = m_resources_index[mod->internal_id()]; + emit dataChanged(index(row, RequiresColumn), index(row, RequiredByColumn)); + } + } + return rsp; +} diff --git a/launcher/minecraft/mod/ModFolderModel.h b/launcher/minecraft/mod/ModFolderModel.h new file mode 100644 index 0000000..4de875a --- /dev/null +++ b/launcher/minecraft/mod/ModFolderModel.h @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "Mod.h" +#include "ResourceFolderModel.h" +#include "minecraft/Component.h" +#include "minecraft/mod/Resource.h" + +class BaseInstance; +class QFileSystemWatcher; + +/** + * A legacy mod list. + * Backed by a folder. + */ +class ModFolderModel : public ResourceFolderModel { + Q_OBJECT + public: + enum Columns { + ActiveColumn = 0, + ImageColumn, + NameColumn, + VersionColumn, + DateColumn, + ProviderColumn, + SizeColumn, + SideColumn, + LoadersColumn, + McVersionsColumn, + ReleaseTypeColumn, + RequiresColumn, + RequiredByColumn, + NUM_COLUMNS + }; + ModFolderModel(const QDir& dir, BaseInstance* instance, bool is_indexed, bool create_dir, QObject* parent = nullptr); + + virtual QString id() const override { return "mods"; } + + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + + QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; + int columnCount(const QModelIndex& parent) const override; + + [[nodiscard]] Resource* createResource(const QFileInfo& file) override { return new Mod(file); } + [[nodiscard]] Task* createParseTask(Resource&) override; + + bool isValid(); + + bool setResourceEnabled(const QModelIndexList& indexes, EnableAction action) override; + bool deleteResources(const QModelIndexList& indexes) override; + + QModelIndexList getAffectedMods(const QModelIndexList& indexes, EnableAction action); + + RESOURCE_HELPERS(Mod) + + public: + QStringList requiresList(QString id); + QStringList requiredByList(QString id); + + private slots: + void onParseSucceeded(int ticket, QString resource_id) override; + void onParseFinished(); + + private: + QHash> m_requiredBy; + QHash> m_requires; +}; diff --git a/launcher/minecraft/mod/Resource.cpp b/launcher/minecraft/mod/Resource.cpp new file mode 100644 index 0000000..6926225 --- /dev/null +++ b/launcher/minecraft/mod/Resource.cpp @@ -0,0 +1,342 @@ +#include "Resource.h" + +#include +#include +#include +#include + +#include "FileSystem.h" +#include "StringUtils.h" +#include "minecraft/MinecraftInstance.h" +#include "minecraft/PackProfile.h" + +Resource::Resource(QObject* parent) : QObject(parent) {} + +Resource::Resource(QFileInfo file_info) : QObject() +{ + setFile(file_info); +} + +void Resource::setFile(QFileInfo file_info) +{ + m_file_info = file_info; + parseFile(); +} + +static std::tuple calculateFileSize(const QFileInfo& file) +{ + if (file.isDir()) { + auto dir = QDir(file.absoluteFilePath()); + dir.setFilter(QDir::AllEntries | QDir::NoDotAndDotDot); + auto count = dir.count(); + auto str = QObject::tr("item"); + if (count != 1) + str = QObject::tr("items"); + return { QString("%1 %2").arg(QString::number(count), str), count }; + } + return { StringUtils::humanReadableFileSize(file.size(), true), file.size() }; +} + +void Resource::parseFile() +{ + QString file_name{ m_file_info.fileName() }; + + m_type = ResourceType::UNKNOWN; + + m_internal_id = file_name; + + std::tie(m_size_str, m_size_info) = calculateFileSize(m_file_info); + if (m_file_info.isDir()) { + m_type = ResourceType::FOLDER; + m_name = file_name; + } else if (m_file_info.isFile()) { + if (file_name.endsWith(".disabled")) { + file_name.chop(9); + m_enabled = false; + } + + if (file_name.endsWith(".zip") || file_name.endsWith(".jar")) { + m_type = ResourceType::ZIPFILE; + file_name.chop(4); + } else if (file_name.endsWith(".nilmod")) { + m_type = ResourceType::ZIPFILE; + file_name.chop(7); + } else if (file_name.endsWith(".litemod")) { + m_type = ResourceType::LITEMOD; + file_name.chop(8); + } else { + m_type = ResourceType::SINGLEFILE; + } + + m_name = file_name; + } + + m_changed_date_time = m_file_info.lastModified(); +} + +auto Resource::name() const -> QString +{ + if (metadata()) + return metadata()->name; + + return m_name; +} + +static void removeThePrefix(QString& string) +{ + static const QRegularExpression s_regex(QStringLiteral("^(?:the|teh) +"), QRegularExpression::CaseInsensitiveOption); + string.remove(s_regex); + string = string.trimmed(); +} + +auto Resource::provider() const -> QString +{ + if (metadata()) + return ModPlatform::ProviderCapabilities::readableName(metadata()->provider); + + return tr("Unknown"); +} + +auto Resource::homepage() const -> QString +{ + if (metadata()) + return ModPlatform::getMetaURL(metadata()->provider, metadata()->project_id); + + return {}; +} + +void Resource::setMetadata(std::shared_ptr&& metadata) +{ + if (status() == ResourceStatus::NO_METADATA) + setStatus(ResourceStatus::INSTALLED); + + m_metadata = metadata; +} + +QStringList Resource::issues() const +{ + QStringList result; + result.reserve(m_issues.length()); + + for (const char* issue : m_issues) { + result.append(tr(issue)); + } + + return result; +} + +void Resource::updateIssues(const BaseInstance* inst) +{ + m_issues.clear(); + + if (m_metadata == nullptr) { + return; + } + + auto mcInst = dynamic_cast(inst); + if (mcInst == nullptr) { + return; + } + + auto profile = mcInst->getPackProfile(); + QString mcVersion = profile->getComponentVersion("net.minecraft"); + + if (!m_metadata->mcVersions.empty() && !m_metadata->mcVersions.contains(mcVersion)) { + // delay translation until issues() is called + m_issues.append(QT_TR_NOOP("Not marked as compatible with the instance's game version.")); + } +} + +int Resource::compare(const Resource& other, SortType type) const +{ + switch (type) { + default: + case SortType::ENABLED: + if (enabled() && !other.enabled()) + return 1; + if (!enabled() && other.enabled()) + return -1; + break; + case SortType::NAME: { + QString this_name{ name() }; + QString other_name{ other.name() }; + + // TODO do we need this? it could result in 0 being returned + removeThePrefix(this_name); + removeThePrefix(other_name); + + return QString::compare(this_name, other_name, Qt::CaseInsensitive); + } + case SortType::DATE: + if (dateTimeChanged() > other.dateTimeChanged()) + return 1; + if (dateTimeChanged() < other.dateTimeChanged()) + return -1; + break; + case SortType::SIZE: { + if (this->type() != other.type()) { + if (this->type() == ResourceType::FOLDER) + return -1; + if (other.type() == ResourceType::FOLDER) + return 1; + } + + if (sizeInfo() > other.sizeInfo()) + return 1; + if (sizeInfo() < other.sizeInfo()) + return -1; + break; + } + case SortType::PROVIDER: { + auto compare_result = QString::compare(provider(), other.provider(), Qt::CaseInsensitive); + if (compare_result != 0) + return compare_result; + break; + } + } + + return 0; +} + +bool Resource::applyFilter(QRegularExpression filter) const +{ + return filter.match(name()).hasMatch(); +} + +bool Resource::enable(EnableAction action) +{ + if (m_type == ResourceType::UNKNOWN || m_type == ResourceType::FOLDER) + return false; + + QString path = m_file_info.absoluteFilePath(); + QFile file(path); + + bool enable = true; + switch (action) { + case EnableAction::ENABLE: + enable = true; + break; + case EnableAction::DISABLE: + enable = false; + break; + case EnableAction::TOGGLE: + default: + enable = !enabled(); + break; + } + + if (m_enabled == enable) + return false; + + if (enable) { + // m_enabled is false, but there's no '.disabled' suffix. + // TODO: Report error? + if (!path.endsWith(".disabled")) + return false; + path.chop(9); + } else { + path += ".disabled"; + if (QFile::exists(path)) { + path = FS::getUniqueResourceName(path); + } + } + if (!file.rename(path)) + return false; + + setFile(QFileInfo(path)); + + m_enabled = enable; + return true; +} + +auto Resource::destroy(const QDir& index_dir, bool preserve_metadata, bool attempt_trash) -> bool +{ + m_type = ResourceType::UNKNOWN; + + if (!preserve_metadata) { + qDebug() << QString("Destroying metadata for '%1' on purpose").arg(name()); + destroyMetadata(index_dir); + } + + return (attempt_trash && FS::trash(m_file_info.filePath())) || FS::deletePath(m_file_info.filePath()); +} + +auto Resource::destroyMetadata(const QDir& index_dir) -> void +{ + if (metadata()) { + Metadata::remove(index_dir, metadata()->slug); + } else { + auto n = name(); + Metadata::remove(index_dir, n); + } + m_metadata = nullptr; +} + +bool Resource::isSymLinkUnder(const QString& instPath) const +{ + if (isSymLink()) + return true; + + auto instDir = QDir(instPath); + + auto relAbsPath = instDir.relativeFilePath(m_file_info.absoluteFilePath()); + auto relCanonPath = instDir.relativeFilePath(m_file_info.canonicalFilePath()); + + return relAbsPath != relCanonPath; +} + +bool Resource::isMoreThanOneHardLink() const +{ + return FS::hardLinkCount(m_file_info.absoluteFilePath()) > 1; +} + +auto Resource::getOriginalFileName() const -> QString +{ + auto fileName = m_file_info.fileName(); + if (!m_enabled) + fileName.chop(9); + return fileName; +} + +QDebug operator<<(QDebug debug, ResourceType type) +{ + switch (type) { + case ResourceType::ZIPFILE: + debug << "ZIPFILE"; + break; + case ResourceType::SINGLEFILE: + debug << "SINGLEFILE"; + break; + case ResourceType::FOLDER: + debug << "FOLDER"; + break; + case ResourceType::LITEMOD: + debug << "LITEMOD"; + break; + case ResourceType::UNKNOWN: + default: + debug << "UNKNOWN"; + break; + }; + return debug; +} + +QDebug operator<<(QDebug debug, ResourceStatus status) +{ + switch (status) { + case ResourceStatus::INSTALLED: + debug << "INSTALLED"; + break; + case ResourceStatus::NOT_INSTALLED: + debug << "NOT_INSTALLED"; + break; + case ResourceStatus::NO_METADATA: + debug << "NO_METADATA"; + break; + case ResourceStatus::UNKNOWN: + default: + debug << "UNKNOWN"; + break; + }; + return debug; +} diff --git a/launcher/minecraft/mod/Resource.h b/launcher/minecraft/mod/Resource.h new file mode 100644 index 0000000..485405b --- /dev/null +++ b/launcher/minecraft/mod/Resource.h @@ -0,0 +1,213 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include + +#include "MetadataHandler.h" +#include "QObjectPtr.h" + +class BaseInstance; + +enum class ResourceType { + UNKNOWN, //!< Indicates an unspecified resource type. + ZIPFILE, //!< The resource is a zip file containing the resource's class files. + SINGLEFILE, //!< The resource is a single file (not a zip file). + FOLDER, //!< The resource is in a folder on the filesystem. + LITEMOD, //!< The resource is a litemod +}; + +QDebug operator<<(QDebug debug, ResourceType type); + +enum class ResourceStatus { + INSTALLED, // Both JAR and Metadata are present + NOT_INSTALLED, // Only the Metadata is present + NO_METADATA, // Only the JAR is present + UNKNOWN, // Default status +}; + +QDebug operator<<(QDebug debug, ResourceStatus status); + +enum class SortType { + NAME, + DATE, + VERSION, + ENABLED, + PACK_FORMAT, + PROVIDER, + SIZE, + SIDE, + MC_VERSIONS, + LOADERS, + RELEASE_TYPE, + REQUIRES, + REQUIRED_BY, +}; + +enum class EnableAction { ENABLE, DISABLE, TOGGLE }; + +/** General class for managed resources. It mirrors a file in disk, with some more info + * for display and house-keeping purposes. + * + * Subclass it to add additional data / behavior, such as Mods or Resource packs. + */ +class Resource : public QObject { + Q_OBJECT + Q_DISABLE_COPY(Resource) + public: + using Ptr = shared_qobject_ptr; + using WeakPtr = QPointer; + + Resource(QObject* parent = nullptr); + Resource(QFileInfo file_info); + Resource(QString file_path) : Resource(QFileInfo(file_path)) {} + + ~Resource() override = default; + + void setFile(QFileInfo file_info); + void parseFile(); + + auto fileinfo() const -> QFileInfo { return m_file_info; } + auto dateTimeChanged() const -> QDateTime { return m_changed_date_time; } + auto internal_id() const -> QString { return m_internal_id; } + auto type() const -> ResourceType { return m_type; } + bool enabled() const { return m_enabled; } + auto getOriginalFileName() const -> QString; + QString sizeStr() const { return m_size_str; } + qint64 sizeInfo() const { return m_size_info; } + + virtual auto name() const -> QString; + virtual bool valid() const { return m_type != ResourceType::UNKNOWN; } + + auto status() const -> ResourceStatus { return m_status; }; + auto metadata() -> std::shared_ptr { return m_metadata; } + auto metadata() const -> std::shared_ptr { return m_metadata; } + auto provider() const -> QString; + virtual auto homepage() const -> QString; + + void setStatus(ResourceStatus status) { m_status = status; } + void setMetadata(std::shared_ptr&& metadata); + void setMetadata(const Metadata::ModStruct& metadata) { setMetadata(std::make_shared(metadata)); } + + /** + * Returns compatibility issues with the resource and the instance. + * This is initially empty, and may be updated when calling updateIssues. + */ + QStringList issues() const; + void updateIssues(const BaseInstance* inst); + bool hasIssues() const { return !m_issues.empty(); } + + /** Compares two Resources, for sorting purposes, considering a ascending order, returning: + * > 0: 'this' comes after 'other' + * = 0: 'this' is equal to 'other' + * < 0: 'this' comes before 'other' + */ + virtual int compare(const Resource& other, SortType type = SortType::NAME) const; + + /** Returns whether the given filter should filter out 'this' (false), + * or if such filter includes the Resource (true). + */ + virtual bool applyFilter(QRegularExpression filter) const; + + /** Changes the enabled property, according to 'action'. + * + * Returns whether a change was applied to the Resource's properties. + */ + bool enable(EnableAction action); + + auto shouldResolve() const -> bool { return !m_is_resolving && !m_is_resolved; } + auto isResolving() const -> bool { return m_is_resolving; } + auto isResolved() const -> bool { return m_is_resolved; } + auto resolutionTicket() const -> int { return m_resolution_ticket; } + + void setResolving(bool resolving, int resolutionTicket) + { + m_is_resolving = resolving; + m_resolution_ticket = resolutionTicket; + } + + // Delete all files of this resource. + auto destroy(const QDir& index_dir, bool preserve_metadata = false, bool attempt_trash = true) -> bool; + // Delete the metadata only. + auto destroyMetadata(const QDir& index_dir) -> void; + + auto isSymLink() const -> bool { return m_file_info.isSymLink(); } + + /** + * @brief Take a instance path, checks if the file pointed to by the resource is a symlink or under a symlink in that instance + * + * @param instPath path to an instance directory + * @return true + * @return false + */ + bool isSymLinkUnder(const QString& instPath) const; + + bool isMoreThanOneHardLink() const; + + protected: + /* The file corresponding to this resource. */ + QFileInfo m_file_info; + /* The cached date when this file was last changed. */ + QDateTime m_changed_date_time; + + /* Internal ID for internal purposes. Properties such as human-readability should not be assumed. */ + QString m_internal_id; + /* Name as reported via the file name. In the absence of a better name, this is shown to the user. */ + QString m_name; + + /* The type of file we're dealing with. */ + ResourceType m_type = ResourceType::UNKNOWN; + + /* Installation status of the resource. */ + ResourceStatus m_status = ResourceStatus::UNKNOWN; + + std::shared_ptr m_metadata = nullptr; + + /* Whether the resource is enabled (e.g. shows up in the game) or not. */ + bool m_enabled = true; + + QList m_issues; + + /* Used to keep trach of pending / concluded actions on the resource. */ + bool m_is_resolving = false; + bool m_is_resolved = false; + int m_resolution_ticket = 0; + QString m_size_str; + qint64 m_size_info; +}; diff --git a/launcher/minecraft/mod/ResourceFolderModel.cpp b/launcher/minecraft/mod/ResourceFolderModel.cpp new file mode 100644 index 0000000..9d3f3e7 --- /dev/null +++ b/launcher/minecraft/mod/ResourceFolderModel.cpp @@ -0,0 +1,954 @@ +#include "ResourceFolderModel.h" +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Application.h" +#include "FileSystem.h" + +#include "minecraft/mod/tasks/ResourceFolderLoadTask.h" + +#include "Json.h" +#include "minecraft/mod/tasks/LocalResourceUpdateTask.h" +#include "modplatform/flame/FlameAPI.h" +#include "modplatform/flame/FlameModIndex.h" +#include "settings/Setting.h" +#include "tasks/SequentialTask.h" +#include "tasks/Task.h" +#include "ui/dialogs/CustomMessageBox.h" + +ResourceFolderModel::ResourceFolderModel(const QDir& dir, BaseInstance* instance, bool is_indexed, bool create_dir, QObject* parent) + : QAbstractListModel(parent), m_dir(dir), m_instance(instance), m_watcher(this), m_is_indexed(is_indexed) +{ + if (create_dir) { + FS::ensureFolderPathExists(m_dir.absolutePath()); + } + + m_dir.setFilter(QDir::Readable | QDir::NoDotAndDotDot | QDir::Files | QDir::Dirs); + m_dir.setSorting(QDir::Name | QDir::IgnoreCase | QDir::LocaleAware); + + connect(&m_watcher, &QFileSystemWatcher::directoryChanged, this, &ResourceFolderModel::directoryChanged); + connect(&m_resourceResolver, &ConcurrentTask::finished, this, [this] { + m_resourceResolver.clear(); + m_resourceResolverRunning = false; + }); + if (APPLICATION_DYN) { // in tests the application macro doesn't work + m_resourceResolver.setMaxConcurrent(APPLICATION->settings()->get("NumberOfConcurrentTasks").toInt()); + } +} + +ResourceFolderModel::~ResourceFolderModel() +{ + while (!QThreadPool::globalInstance()->waitForDone(100)) + QCoreApplication::processEvents(); +} + +bool ResourceFolderModel::startWatching(const QStringList& paths) +{ + // Remove orphaned metadata next time + m_first_folder_load = true; + + if (m_is_watching) + return false; + + auto couldnt_be_watched = m_watcher.addPaths(paths); + for (auto path : paths) { + if (couldnt_be_watched.contains(path)) + qDebug() << "Failed to start watching" << path; + else + qDebug() << "Started watching" << path; + } + + update(); + + m_is_watching = !m_is_watching; + return m_is_watching; +} + +bool ResourceFolderModel::stopWatching(const QStringList& paths) +{ + if (!m_is_watching) + return false; + + auto couldnt_be_stopped = m_watcher.removePaths(paths); + for (auto path : paths) { + if (couldnt_be_stopped.contains(path)) + qDebug() << "Failed to stop watching" << path; + else + qDebug() << "Stopped watching" << path; + } + + m_is_watching = !m_is_watching; + return !m_is_watching; +} + +bool ResourceFolderModel::installResource(QString original_path) +{ + // NOTE: fix for GH-1178: remove trailing slash to avoid issues with using the empty result of QFileInfo::fileName + original_path = FS::NormalizePath(original_path); + QFileInfo file_info(original_path); + + if (!file_info.exists() || !file_info.isReadable()) { + qWarning() << "Caught attempt to install non-existing file or file-like object:" << original_path; + return false; + } + qDebug() << "Installing:" << file_info.absoluteFilePath(); + + Resource resource(file_info); + if (!resource.valid()) { + qWarning() << original_path << "is not a valid resource. Ignoring it."; + return false; + } + + auto new_path = FS::NormalizePath(m_dir.filePath(file_info.fileName())); + if (original_path == new_path) { + qWarning() << "Overwriting the mod (" << original_path << ") with itself makes no sense..."; + return false; + } + + switch (resource.type()) { + case ResourceType::SINGLEFILE: + case ResourceType::ZIPFILE: + case ResourceType::LITEMOD: { + if (QFile::exists(new_path) || QFile::exists(new_path + QString(".disabled"))) { + if (!FS::deletePath(new_path)) { + qCritical() << "Cleaning up new location (" << new_path << ") was unsuccessful!"; + return false; + } + qDebug() << new_path << "has been deleted."; + } + + if (!QFile::copy(original_path, new_path)) { + qCritical() << "Copy from" << original_path << "to" << new_path << "has failed."; + return false; + } + + FS::updateTimestamp(new_path); + + QFileInfo new_path_file_info(new_path); + resource.setFile(new_path_file_info); + + if (!m_is_watching) + return update(); + + return true; + } + case ResourceType::FOLDER: { + if (QFile::exists(new_path)) { + qDebug() << "Ignoring folder '" << original_path << "', it would merge with" << new_path; + return false; + } + + if (!FS::copy(original_path, new_path)()) { + qWarning() << "Copy of folder from" << original_path << "to" << new_path << "has (potentially partially) failed."; + return false; + } + + QFileInfo newpathInfo(new_path); + resource.setFile(newpathInfo); + + if (!m_is_watching) + return update(); + + return true; + } + default: + break; + } + return false; +} + +void ResourceFolderModel::installResourceWithFlameMetadata(QString path, ModPlatform::IndexedVersion& vers) +{ + auto install = [this, path] { installResource(std::move(path)); }; + if (vers.addonId.isValid()) { + ModPlatform::IndexedPack pack{ + vers.addonId, + ModPlatform::ResourceProvider::FLAME, + }; + + auto [job, response] = FlameAPI().getProject(vers.addonId.toString()); + connect(job.get(), &Task::failed, this, install); + connect(job.get(), &Task::aborted, this, install); + connect(job.get(), &Task::succeeded, [response, this, &vers, install, &pack] { + QJsonParseError parse_error{}; + QJsonDocument doc = QJsonDocument::fromJson(*response, &parse_error); + if (parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response for mod info at" << parse_error.offset + << "reason:" << parse_error.errorString(); + qDebug() << *response; + return; + } + try { + auto obj = Json::requireObject(Json::requireObject(doc), "data"); + FlameMod::loadIndexedPack(pack, obj); + } catch (const JSONValidationError& e) { + qDebug() << doc; + qWarning() << "Error while reading mod info:" << e.cause(); + } + LocalResourceUpdateTask update_metadata(indexDir(), pack, vers); + connect(&update_metadata, &Task::finished, this, install); + update_metadata.start(); + }); + + job->start(); + } else { + install(); + } +} + +bool ResourceFolderModel::uninstallResource(const QString& file_name, bool preserve_metadata) +{ + for (auto& resource : m_resources) { + auto resourceFileInfo = resource->fileinfo(); + auto resourceFileName = resource->fileinfo().fileName(); + if (!resource->enabled() && resourceFileName.endsWith(".disabled")) { + resourceFileName.chop(9); + } + + if (resourceFileName == file_name) { + auto res = resource->destroy(indexDir(), preserve_metadata, false); + + update(); + + return res; + } + } + return false; +} + +bool ResourceFolderModel::deleteResources(const QModelIndexList& indexes) +{ + if (indexes.isEmpty()) + return true; + + for (auto i : indexes) { + if (i.column() != 0) + continue; + + auto& resource = m_resources.at(i.row()); + resource->destroy(indexDir()); + } + + update(); + + return true; +} + +void ResourceFolderModel::deleteMetadata(const QModelIndexList& indexes) +{ + if (indexes.isEmpty()) + return; + + for (auto i : indexes) { + if (i.column() != 0) + continue; + + auto& resource = m_resources.at(i.row()); + resource->destroyMetadata(indexDir()); + } + + update(); +} + +bool ResourceFolderModel::setResourceEnabled(const QModelIndexList& indexes, EnableAction action) +{ + if (m_instance != nullptr && m_instance->isRunning()) { + auto response = + CustomMessageBox::selectable(nullptr, tr("Confirm toggle"), + tr("If you enable/disable this resource while the game is running it may crash your game.\n" + "Are you sure you want to do this?"), + QMessageBox::Warning, QMessageBox::Yes | QMessageBox::No, QMessageBox::No) + ->exec(); + + if (response != QMessageBox::Yes) + return false; + } + + if (indexes.isEmpty()) + return true; + + bool succeeded = true; + for (auto const& idx : indexes) { + if (!validateIndex(idx) || idx.column() != 0) + continue; + + int row = idx.row(); + + auto& resource = m_resources[row]; + + // Preserve the row, but change its ID + auto old_id = resource->internal_id(); + if (!resource->enable(action)) { + succeeded = false; + continue; + } + + auto new_id = resource->internal_id(); + + m_resources_index.remove(old_id); + m_resources_index[new_id] = row; + + emit dataChanged(index(row, 0), index(row, columnCount(QModelIndex()) - 1)); + } + + return succeeded; +} + +static QMutex s_update_task_mutex; +bool ResourceFolderModel::update() +{ + // We hold a lock here to prevent race conditions on the m_current_update_task reset. + QMutexLocker lock(&s_update_task_mutex); + + // Already updating, so we schedule a future update and return. + if (m_current_update_task) { + m_scheduled_update = true; + return false; + } + + m_current_update_task.reset(createUpdateTask()); + if (!m_current_update_task) + return false; + + connect(m_current_update_task.get(), &Task::succeeded, this, &ResourceFolderModel::onUpdateSucceeded, + Qt::ConnectionType::QueuedConnection); + connect(m_current_update_task.get(), &Task::failed, this, &ResourceFolderModel::onUpdateFailed, Qt::ConnectionType::QueuedConnection); + connect( + m_current_update_task.get(), &Task::finished, this, + [this] { + m_current_update_task.reset(); + if (m_scheduled_update) { + m_scheduled_update = false; + update(); + } else { + emit updateFinished(); + } + }, + Qt::ConnectionType::QueuedConnection); + + Task::Ptr preUpdate{ createPreUpdateTask() }; + + if (preUpdate != nullptr) { + auto task = new SequentialTask("ResourceFolderModel::update"); + + task->addTask(preUpdate); + task->addTask(m_current_update_task); + + connect(task, &Task::finished, [task] { task->deleteLater(); }); + + QThreadPool::globalInstance()->start(task); + } else { + QThreadPool::globalInstance()->start(m_current_update_task.get()); + } + + return true; +} + +void ResourceFolderModel::resolveResource(Resource::Ptr res) +{ + if (!res->shouldResolve()) { + return; + } + + Task::Ptr task{ createParseTask(*res) }; + if (!task) + return; + + int ticket = m_next_resolution_ticket.fetch_add(1); + + res->setResolving(true, ticket); + m_active_parse_tasks.insert(ticket, task); + + connect( + task.get(), &Task::succeeded, this, [this, ticket, res] { onParseSucceeded(ticket, res->internal_id()); }, + Qt::ConnectionType::QueuedConnection); + connect( + task.get(), &Task::failed, this, [this, ticket, res] { onParseFailed(ticket, res->internal_id()); }, + Qt::ConnectionType::QueuedConnection); + connect( + task.get(), &Task::finished, this, + [this, ticket] { + m_active_parse_tasks.remove(ticket); + emit parseFinished(); + }, + Qt::ConnectionType::QueuedConnection); + + m_resourceResolver.addTask(task); + + if (!m_resourceResolverRunning) { + QThreadPool::globalInstance()->start(&m_resourceResolver); + m_resourceResolverRunning = true; + } +} + +void ResourceFolderModel::onUpdateSucceeded() +{ + auto update_results = static_cast(m_current_update_task.get())->result(); + + auto& new_resources = update_results->resources; + + auto current_list = m_resources_index.keys(); + QSet current_set(current_list.begin(), current_list.end()); + + auto new_list = new_resources.keys(); + QSet new_set(new_list.begin(), new_list.end()); + + applyUpdates(current_set, new_set, new_resources); +} + +void ResourceFolderModel::onParseSucceeded(int ticket, QString resource_id) +{ + auto iter = m_active_parse_tasks.constFind(ticket); + if (iter == m_active_parse_tasks.constEnd() || !m_resources_index.contains(resource_id)) + return; + + int row = m_resources_index[resource_id]; + emit dataChanged(index(row), index(row, columnCount(QModelIndex()) - 1)); +} + +Task* ResourceFolderModel::createUpdateTask() +{ + auto index_dir = indexDir(); + auto task = new ResourceFolderLoadTask(dir(), index_dir, m_is_indexed, m_first_folder_load, + [this](const QFileInfo& file) { return createResource(file); }); + m_first_folder_load = false; + return task; +} + +bool ResourceFolderModel::hasPendingParseTasks() const +{ + return !m_active_parse_tasks.isEmpty(); +} + +void ResourceFolderModel::directoryChanged(QString path) +{ + update(); +} + +Qt::DropActions ResourceFolderModel::supportedDropActions() const +{ + // copy from outside, move from within and other resource lists + return Qt::CopyAction | Qt::MoveAction; +} + +Qt::ItemFlags ResourceFolderModel::flags(const QModelIndex& index) const +{ + Qt::ItemFlags defaultFlags = QAbstractListModel::flags(index); + auto flags = defaultFlags | Qt::ItemIsDropEnabled; + if (index.isValid()) + flags |= Qt::ItemIsUserCheckable; + return flags; +} + +QStringList ResourceFolderModel::mimeTypes() const +{ + QStringList types; + types << "text/uri-list"; + return types; +} + +bool ResourceFolderModel::dropMimeData(const QMimeData* data, Qt::DropAction action, int, int, const QModelIndex&) +{ + if (action == Qt::IgnoreAction) { + return true; + } + + // check if the action is supported + if (!data || !(action & supportedDropActions())) { + return false; + } + + // files dropped from outside? + if (data->hasUrls()) { + auto urls = data->urls(); + for (auto url : urls) { + // only local files may be dropped... + if (!url.isLocalFile()) { + continue; + } + // TODO: implement not only copy, but also move + // FIXME: handle errors here + installResource(url.toLocalFile()); + } + return true; + } + return false; +} + +bool ResourceFolderModel::validateIndex(const QModelIndex& index) const +{ + if (!index.isValid()) + return false; + + int row = index.row(); + if (row < 0 || row >= m_resources.size()) + return false; + + return true; +} + +// HACK: all subclasses need to call this to have the whole row painted +// and they only delegate to the superclass for compatible columns +QBrush ResourceFolderModel::rowBackground(int row) const +{ + if (APPLICATION->settings()->get("ShowModIncompat").toBool() && m_resources[row]->hasIssues()) { + return { QColor(255, 0, 0, 40) }; + } else { + return {}; + } +} + +QVariant ResourceFolderModel::data(const QModelIndex& index, int role) const +{ + if (!validateIndex(index)) + return {}; + + int row = index.row(); + int column = index.column(); + + switch (role) { + case Qt::BackgroundRole: + return rowBackground(row); + case Qt::DisplayRole: + switch (column) { + case NameColumn: + return m_resources[row]->name(); + case DateColumn: + return m_resources[row]->dateTimeChanged(); + case ProviderColumn: + return m_resources[row]->provider(); + case SizeColumn: + return m_resources[row]->sizeStr(); + default: + return {}; + } + case Qt::ToolTipRole: { + QString tooltip = m_resources[row]->internal_id(); + + if (column == NameColumn) { + if (APPLICATION->settings()->get("ShowModIncompat").toBool()) { + for (const QString& issue : at(row).issues()) { + tooltip += "\n" + issue; + } + } + + if (at(row).isSymLinkUnder(instDirPath())) { + tooltip += + m_resources[row]->internal_id() + + tr("\nWarning: This resource is symbolically linked from elsewhere. Editing it will also change the original." + "\nCanonical Path: %1") + .arg(at(row).fileinfo().canonicalFilePath()); + } + + if (at(row).isMoreThanOneHardLink()) { + tooltip += tr("\nWarning: This resource is hard linked elsewhere. Editing it will also change the original."); + } + } + + return tooltip; + } + case Qt::DecorationRole: { + if (column == NameColumn) { + if (APPLICATION->settings()->get("ShowModIncompat").toBool() && at(row).hasIssues()) { + return QIcon::fromTheme("status-bad"); + } else if (at(row).isSymLinkUnder(instDirPath()) || at(row).isMoreThanOneHardLink()) { + return QIcon::fromTheme("status-yellow"); + } + } + + return {}; + } + case Qt::CheckStateRole: + if (column == ActiveColumn) + return m_resources[row]->enabled() ? Qt::Checked : Qt::Unchecked; + return {}; + default: + return {}; + } +} + +bool ResourceFolderModel::setData(const QModelIndex& index, [[maybe_unused]] const QVariant& value, int role) +{ + int row = index.row(); + if (row < 0 || row >= rowCount(index.parent()) || !index.isValid()) + return false; + + if (role == Qt::CheckStateRole) { + return setResourceEnabled({ index }, EnableAction::TOGGLE); + } + + return false; +} + +QVariant ResourceFolderModel::headerData(int section, [[maybe_unused]] Qt::Orientation orientation, int role) const +{ + switch (role) { + case Qt::DisplayRole: + switch (section) { + case ActiveColumn: + case NameColumn: + case DateColumn: + case ProviderColumn: + case SizeColumn: + return columnNames().at(section); + default: + return {}; + } + case Qt::ToolTipRole: { + //: Here, resource is a generic term for external resources, like Mods, Resource Packs, Shader Packs, etc. + switch (section) { + case ActiveColumn: + return tr("Is the resource enabled?"); + case NameColumn: + return tr("The name of the resource."); + case DateColumn: + return tr("The date and time this resource was last changed (or added)."); + case ProviderColumn: + return tr("The source provider of the resource."); + case SizeColumn: + return tr("The size of the resource."); + default: + return {}; + } + } + default: + break; + } + + return {}; +} + +void ResourceFolderModel::setupHeaderAction(QAction* act, int column) +{ + Q_ASSERT(act); + + act->setText(columnNames().at(column)); +} + +void ResourceFolderModel::saveColumns(QTreeView* tree) +{ + auto const stateSettingName = QString("UI/%1_Page/Columns").arg(id()); + auto const overrideSettingName = QString("UI/%1_Page/ColumnsOverride").arg(id()); + auto const visibilitySettingName = QString("UI/%1_Page/ColumnsVisibility").arg(id()); + + auto stateSetting = m_instance->settings()->getSetting(stateSettingName); + stateSetting->set(QString::fromUtf8(tree->header()->saveState().toBase64())); + + // neither passthrough nor override settings works for this usecase as I need to only set the global when the gate is false + auto settings = m_instance->settings(); + if (!settings->get(overrideSettingName).toBool()) { + settings = APPLICATION->settings(); + } + auto visibility = Json::toMap(settings->get(visibilitySettingName).toString()); + for (auto i = 0; i < m_column_names.size(); ++i) { + if (m_columnsHideable[i]) { + auto name = m_column_names[i]; + visibility[name] = !tree->isColumnHidden(i); + } + } + settings->set(visibilitySettingName, Json::fromMap(visibility)); +} + +void ResourceFolderModel::loadColumns(QTreeView* tree) +{ + auto const stateSettingName = QString("UI/%1_Page/Columns").arg(id()); + auto const overrideSettingName = QString("UI/%1_Page/ColumnsOverride").arg(id()); + auto const visibilitySettingName = QString("UI/%1_Page/ColumnsVisibility").arg(id()); + + auto stateSetting = m_instance->settings()->getOrRegisterSetting(stateSettingName, ""); + tree->header()->restoreState(QByteArray::fromBase64(stateSetting->get().toString().toUtf8())); + + auto setVisible = [this, tree](QVariant value) { + auto visibility = Json::toMap(value.toString()); + for (auto i = 0; i < m_column_names.size(); ++i) { + if (m_columnsHideable[i]) { + auto name = m_column_names[i]; + tree->setColumnHidden(i, !visibility.value(name, false).toBool()); + } + } + }; + + auto const defaultValue = Json::fromMap({ + { "Image", true }, + { "Version", true }, + { "Last Modified", true }, + { "Provider", true }, + { "Pack Format", true }, + }); + // neither passthrough nor override settings works for this usecase as I need to only set the global when the gate is false + auto settings = m_instance->settings(); + if (!settings->getOrRegisterSetting(overrideSettingName, false)->get().toBool()) { + settings = APPLICATION->settings(); + } + auto visibility = settings->getOrRegisterSetting(visibilitySettingName, defaultValue); + setVisible(visibility->get()); + + // allways connect the signal in case the setting is toggled on and off + auto gSetting = APPLICATION->settings()->getOrRegisterSetting(visibilitySettingName, defaultValue); + connect(gSetting.get(), &Setting::SettingChanged, tree, [this, setVisible, overrideSettingName](const Setting&, QVariant value) { + if (!m_instance->settings()->get(overrideSettingName).toBool()) { + setVisible(value); + } + }); +} + +QMenu* ResourceFolderModel::createHeaderContextMenu(QTreeView* tree) +{ + auto menu = new QMenu(tree); + + { // action to decide if the visibility is per instance or not + auto act = new QAction(tr("Override Columns Visibility"), menu); + auto const overrideSettingName = QString("UI/%1_Page/ColumnsOverride").arg(id()); + + act->setCheckable(true); + act->setChecked(m_instance->settings()->getOrRegisterSetting(overrideSettingName, false)->get().toBool()); + + connect(act, &QAction::toggled, tree, [this, tree, overrideSettingName](bool toggled) { + m_instance->settings()->set(overrideSettingName, toggled); + saveColumns(tree); + }); + + menu->addAction(act); + } + menu->addSeparator()->setText(tr("Show / Hide Columns")); + + for (int col = 0; col < columnCount(); ++col) { + // Skip creating actions for columns that should not be hidden + if (!m_columnsHideable.at(col)) + continue; + auto act = new QAction(menu); + setupHeaderAction(act, col); + + act->setCheckable(true); + act->setChecked(!tree->isColumnHidden(col)); + + connect(act, &QAction::toggled, tree, [this, col, tree](bool toggled) { + tree->setColumnHidden(col, !toggled); + for (int c = 0; c < columnCount(); ++c) { + if (m_column_resize_modes.at(c) == QHeaderView::ResizeToContents) + tree->resizeColumnToContents(c); + } + saveColumns(tree); + }); + + menu->addAction(act); + } + + return menu; +} + +QSortFilterProxyModel* ResourceFolderModel::createFilterProxyModel(QObject* parent) +{ + return new ProxyModel(parent); +} + +SortType ResourceFolderModel::columnToSortKey(size_t column) const +{ + Q_ASSERT(m_column_sort_keys.size() == columnCount()); + return m_column_sort_keys.at(column); +} + +/* Standard Proxy Model for createFilterProxyModel */ +bool ResourceFolderModel::ProxyModel::filterAcceptsRow(int source_row, [[maybe_unused]] const QModelIndex& source_parent) const +{ + auto* model = qobject_cast(sourceModel()); + if (!model) + return true; + + const auto& resource = model->at(source_row); + + return resource.applyFilter(filterRegularExpression()); +} + +bool ResourceFolderModel::ProxyModel::lessThan(const QModelIndex& source_left, const QModelIndex& source_right) const +{ + auto* model = qobject_cast(sourceModel()); + if (!model || !source_left.isValid() || !source_right.isValid() || source_left.column() != source_right.column()) { + return QSortFilterProxyModel::lessThan(source_left, source_right); + } + + // we are now guaranteed to have two valid indexes in the same column... we love the provided invariants unconditionally and + // proceed. + + auto column_sort_key = model->columnToSortKey(source_left.column()); + auto const& resource_left = model->at(source_left.row()); + auto const& resource_right = model->at(source_right.row()); + + auto compare_result = resource_left.compare(resource_right, column_sort_key); + if (compare_result == 0) + return QSortFilterProxyModel::lessThan(source_left, source_right); + + return compare_result < 0; +} + +QString ResourceFolderModel::instDirPath() const +{ + return QFileInfo(m_instance->instanceRoot()).absoluteFilePath(); +} + +void ResourceFolderModel::onParseFailed(int ticket, QString resource_id) +{ + auto iter = m_active_parse_tasks.constFind(ticket); + if (iter == m_active_parse_tasks.constEnd() || !m_resources_index.contains(resource_id)) + return; + + auto removed_index = m_resources_index[resource_id]; + auto removed_it = m_resources.begin() + removed_index; + Q_ASSERT(removed_it != m_resources.end()); + + beginRemoveRows(QModelIndex(), removed_index, removed_index); + m_resources.erase(removed_it); + + // update index + m_resources_index.clear(); + int idx = 0; + for (auto const& mod : qAsConst(m_resources)) { + m_resources_index[mod->internal_id()] = idx; + idx++; + } + endRemoveRows(); +} + +void ResourceFolderModel::applyUpdates(QSet& current_set, QSet& new_set, QMap& new_resources) +{ + // see if the kept resources changed in some way + { + QSet kept_set = current_set; + kept_set.intersect(new_set); + + for (auto const& kept : kept_set) { + auto row_it = m_resources_index.constFind(kept); + Q_ASSERT(row_it != m_resources_index.constEnd()); + auto row = row_it.value(); + + auto& new_resource = new_resources[kept]; + auto const& current_resource = m_resources.at(row); + + if (new_resource->dateTimeChanged() == current_resource->dateTimeChanged()) { + // no significant change + bool hadIssues = !current_resource->hasIssues(); + current_resource->updateIssues(m_instance); + + if (hadIssues != current_resource->hasIssues()) { + emit dataChanged(index(row, 0), index(row, columnCount({}) - 1)); + } + continue; + } + + // If the resource is resolving, but something about it changed, we don't want to + // continue the resolving. + if (current_resource->isResolving()) { + auto ticket = current_resource->resolutionTicket(); + if (m_active_parse_tasks.contains(ticket)) { + auto task = (*m_active_parse_tasks.find(ticket)).get(); + task->abort(); + } + } + + m_resources[row].reset(new_resource); + new_resource->updateIssues(m_instance); + + resolveResource(m_resources.at(row)); + emit dataChanged(index(row, 0), index(row, columnCount(QModelIndex()) - 1)); + } + } + + // remove resources no longer present + { + QSet removed_set = current_set; + removed_set.subtract(new_set); + + QList removed_rows; + for (auto& removed : removed_set) + removed_rows.append(m_resources_index[removed]); + + std::sort(removed_rows.begin(), removed_rows.end(), std::greater()); + + for (auto& removed_index : removed_rows) { + auto removed_it = m_resources.begin() + removed_index; + + Q_ASSERT(removed_it != m_resources.end()); + + if ((*removed_it)->isResolving()) { + auto ticket = (*removed_it)->resolutionTicket(); + if (m_active_parse_tasks.contains(ticket)) { + auto task = (*m_active_parse_tasks.find(ticket)).get(); + task->abort(); + } + } + + beginRemoveRows(QModelIndex(), removed_index, removed_index); + m_resources.erase(removed_it); + endRemoveRows(); + } + } + + // add new resources to the end + { + QSet added_set = new_set; + added_set.subtract(current_set); + + // When you have a Qt build with assertions turned on, proceeding here will abort the application + if (added_set.size() > 0) { + beginInsertRows(QModelIndex(), static_cast(m_resources.size()), + static_cast(m_resources.size() + added_set.size() - 1)); + + for (auto& added : added_set) { + auto res = new_resources[added]; + res->updateIssues(m_instance); + m_resources.append(res); + resolveResource(m_resources.last()); + } + + endInsertRows(); + } + } + + // update index + { + m_resources_index.clear(); + int idx = 0; + for (auto const& mod : qAsConst(m_resources)) { + m_resources_index[mod->internal_id()] = idx; + idx++; + } + } +} +Resource::Ptr ResourceFolderModel::find(QString id) +{ + auto iter = + std::find_if(m_resources.constBegin(), m_resources.constEnd(), [&](Resource::Ptr const& r) { return r->internal_id() == id; }); + if (iter == m_resources.constEnd()) + return nullptr; + return *iter; +} +QList ResourceFolderModel::allResources() +{ + QList result; + result.reserve(m_resources.size()); + for (const Resource ::Ptr& resource : m_resources) + result.append((resource.get())); + return result; +} + +QList ResourceFolderModel::selectedResources(const QModelIndexList& indexes) +{ + QList result; + for (const QModelIndex& index : indexes) { + if (index.column() != 0) + continue; + result.append(&at(index.row())); + } + return result; +} diff --git a/launcher/minecraft/mod/ResourceFolderModel.h b/launcher/minecraft/mod/ResourceFolderModel.h new file mode 100644 index 0000000..81bc6f5 --- /dev/null +++ b/launcher/minecraft/mod/ResourceFolderModel.h @@ -0,0 +1,271 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Resource.h" + +#include "BaseInstance.h" + +#include "tasks/ConcurrentTask.h" +#include "tasks/Task.h" + +class QSortFilterProxyModel; + +/* A macro to define useful functions to handle Resource* -> T* more easily on derived classes */ +#define RESOURCE_HELPERS(T) \ + T& at(int index) \ + { \ + return *static_cast(m_resources[index].get()); \ + } \ + const T& at(int index) const \ + { \ + return *static_cast(m_resources.at(index).get()); \ + } \ + QList selected##T##s(const QModelIndexList& indexes) \ + { \ + QList result; \ + for (const QModelIndex& index : indexes) { \ + if (index.column() != 0) \ + continue; \ + \ + result.append(&at(index.row())); \ + } \ + return result; \ + } \ + QList all##T##s() \ + { \ + QList result; \ + result.reserve(m_resources.size()); \ + \ + for (const Resource::Ptr& resource : m_resources) \ + result.append(static_cast(resource.get())); \ + \ + return result; \ + } + +/** A basic model for external resources. + * + * This model manages a list of resources. As such, external users of such resources do not own them, + * and the resource's lifetime is contingent on the model's lifetime. + * + * TODO: Make the resources unique pointers accessible through weak pointers. + */ +class ResourceFolderModel : public QAbstractListModel { + Q_OBJECT + public: + ResourceFolderModel(const QDir& dir, BaseInstance* instance, bool is_indexed, bool create_dir, QObject* parent = nullptr); + ~ResourceFolderModel() override; + + virtual QString id() const { return "resource"; } + + /** Starts watching the paths for changes. + * + * Returns whether starting to watch all the paths was successful. + * If one or more fails, it returns false. + */ + bool startWatching(const QStringList& paths); + + /** Stops watching the paths for changes. + * + * Returns whether stopping to watch all the paths was successful. + * If one or more fails, it returns false. + */ + bool stopWatching(const QStringList& paths); + + /* Helper methods for subclasses, using a predetermined list of paths. */ + virtual bool startWatching() { return startWatching({ indexDir().absolutePath(), m_dir.absolutePath() }); } + virtual bool stopWatching() { return stopWatching({ indexDir().absolutePath(), m_dir.absolutePath() }); } + + virtual QDir indexDir() const { return { QString("%1/.index").arg(dir().absolutePath()) }; } + + /** Given a path in the system, install that resource, moving it to its place in the + * instance file hierarchy. + * + * Returns whether the installation was succcessful. + */ + virtual bool installResource(QString path); + + virtual void installResourceWithFlameMetadata(QString path, ModPlatform::IndexedVersion& vers); + + /** Uninstall (i.e. remove all data about it) a resource, given its file name. + * + * Returns whether the removal was successful. + */ + virtual bool uninstallResource(const QString& file_name, bool preserve_metadata = false); + virtual bool deleteResources(const QModelIndexList&); + virtual void deleteMetadata(const QModelIndexList&); + + /** Applies the given 'action' to the resources in 'indexes'. + * + * Returns whether the action was successfully applied to all resources. + */ + virtual bool setResourceEnabled(const QModelIndexList& indexes, EnableAction action); + + /** Creates a new update task and start it. Returns false if no update was done, like when an update is already underway. */ + virtual bool update(); + + /** Creates a new parse task, if needed, for 'res' and start it.*/ + virtual void resolveResource(Resource::Ptr res); + + qsizetype size() const { return m_resources.size(); } + [[nodiscard]] bool empty() const { return size() == 0; } + + Resource& at(int index) { return *m_resources[index].get(); } + const Resource& at(int index) const { return *m_resources.at(index).get(); } + QList selectedResources(const QModelIndexList& indexes); + QList allResources(); + + Resource::Ptr find(QString id); + + QDir const& dir() const { return m_dir; } + + /** Checks whether there's any parse tasks being done. + * + * Since they can be quite expensive, and are usually done in a separate thread, if we were to destroy the model while having + * such tasks would introduce an undefined behavior, most likely resulting in a crash. + */ + bool hasPendingParseTasks() const; + + /* Qt behavior */ + + /* Basic columns */ + enum Columns { ActiveColumn = 0, NameColumn, DateColumn, ProviderColumn, SizeColumn, NUM_COLUMNS }; + + QStringList columnNames(bool translated = true) const { return translated ? m_column_names_translated : m_column_names; } + + int rowCount(const QModelIndex& parent = {}) const override { return parent.isValid() ? 0 : static_cast(size()); } + int columnCount(const QModelIndex& parent = {}) const override { return parent.isValid() ? 0 : NUM_COLUMNS; } + + Qt::DropActions supportedDropActions() const override; + + /// flags, mostly to support drag&drop + Qt::ItemFlags flags(const QModelIndex& index) const override; + QStringList mimeTypes() const override; + [[nodiscard]] bool dropMimeData(const QMimeData* data, Qt::DropAction action, int row, int column, const QModelIndex& parent) override; + + [[nodiscard]] bool validateIndex(const QModelIndex& index) const; + + QBrush rowBackground(int row) const; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + bool setData(const QModelIndex& index, const QVariant& value, int role = Qt::EditRole) override; + + QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; + + void setupHeaderAction(QAction* act, int column); + void saveColumns(QTreeView* tree); + void loadColumns(QTreeView* tree); + QMenu* createHeaderContextMenu(QTreeView* tree); + + /** This creates a proxy model to filter / sort the model for a UI. + * + * The actual comparisons and filtering are done directly by the Resource, so to modify behavior go there instead! + */ + QSortFilterProxyModel* createFilterProxyModel(QObject* parent = nullptr); + + SortType columnToSortKey(size_t column) const; + QList columnResizeModes() const { return m_column_resize_modes; } + + class ProxyModel : public QSortFilterProxyModel { + public: + explicit ProxyModel(QObject* parent = nullptr) : QSortFilterProxyModel(parent) {} + + protected: + bool filterAcceptsRow(int source_row, const QModelIndex& source_parent) const override; + bool lessThan(const QModelIndex& source_left, const QModelIndex& source_right) const override; + }; + + QString instDirPath() const; + + signals: + void updateFinished(); + void parseFinished(); + + protected: + [[nodiscard]] virtual Task* createPreUpdateTask() { return nullptr; } + /** This creates a new update task to be executed by update(). + * + * The task should load and parse all resources necessary, and provide a way of accessing such results. + * + * This Task is normally executed when opening a page, so it shouldn't contain much heavy work. + * If such work is needed, try using it in the Task create by createParseTask() instead! + */ + [[nodiscard]] Task* createUpdateTask(); + + [[nodiscard]] virtual Resource* createResource(const QFileInfo& info) { return new Resource(info); } + + /** This creates a new parse task to be executed by onUpdateSucceeded(). + * + * This task should load and parse all heavy info needed by a resource, such as parsing a manifest. It gets executed + * in the background, so it slowly updates the UI as tasks get done. + */ + [[nodiscard]] virtual Task* createParseTask(Resource&) { return nullptr; } + + /** Standard implementation of the model update logic. + * + * It uses set operations to find differences between the current state and the updated state, + * to act only on those disparities. + * + */ + void applyUpdates(QSet& current_set, QSet& new_set, QMap& new_resources); + + protected slots: + void directoryChanged(QString); + + /** Called when the update task is successful. + * + * This usually calls static_cast on the specific Task type returned by createUpdateTask, + * so care must be taken in such cases. + * TODO: Figure out a way to express this relationship better without templated classes (Q_OBJECT macro disallows that). + */ + virtual void onUpdateSucceeded(); + virtual void onUpdateFailed() {} + + /** Called when the parse task with the given ticket is successful. + * + * This is just a simple reference implementation. You probably want to override it with your own logic in a subclass + * if the resource is complex and has more stuff to parse. + */ + virtual void onParseSucceeded(int ticket, QString resource_id); + virtual void onParseFailed(int ticket, QString resource_id); + + protected: + // Represents the relationship between a column's index (represented by the list index), and it's sorting key. + // As such, the order in with they appear is very important! + QList m_column_sort_keys = { SortType::ENABLED, SortType::NAME, SortType::DATE, SortType::PROVIDER, SortType::SIZE }; + QStringList m_column_names = { "Enable", "Name", "Last Modified", "Provider", "Size" }; + QStringList m_column_names_translated = { tr("Enable"), tr("Name"), tr("Last Modified"), tr("Provider"), tr("Size") }; + QList m_column_resize_modes = { QHeaderView::Interactive, QHeaderView::Stretch, QHeaderView::Interactive, + QHeaderView::Interactive, QHeaderView::Interactive }; + QList m_columnsHideable = { false, false, true, true, true }; + + QDir m_dir; + BaseInstance* m_instance; + QFileSystemWatcher m_watcher; + bool m_is_watching = false; + + bool m_is_indexed; + bool m_first_folder_load = true; + + Task::Ptr m_current_update_task = nullptr; + bool m_scheduled_update = false; + + QList m_resources; + + // Represents the relationship between a resource's internal ID and it's row position on the model. + QMap m_resources_index; + + // Runs off-thread + ConcurrentTask m_resourceResolver; + bool m_resourceResolverRunning = false; + + QMap m_active_parse_tasks; + std::atomic m_next_resolution_ticket = 0; +}; diff --git a/launcher/minecraft/mod/ResourcePack.cpp b/launcher/minecraft/mod/ResourcePack.cpp new file mode 100644 index 0000000..2d4295b --- /dev/null +++ b/launcher/minecraft/mod/ResourcePack.cpp @@ -0,0 +1,103 @@ +#include "ResourcePack.h" + +#include +#include +#include +#include +#include "MTPixmapCache.h" +#include "Version.h" + +// Values taken from: +// https://minecraft.wiki/w/Pack_format#List_of_resource_pack_formats +static const QMap, std::pair> s_pack_format_versions = { + { { 1, 0 }, { Version("1.6.1"), Version("1.8.9") } }, + { { 2, 0 }, { Version("1.9"), Version("1.10.2") } }, + { { 3, 0 }, { Version("1.11"), Version("1.12.2") } }, + { { 4, 0 }, { Version("1.13"), Version("1.14.4") } }, + { { 5, 0 }, { Version("1.15"), Version("1.16.1") } }, + { { 6, 0 }, { Version("1.16.2"), Version("1.16.5") } }, + { { 7, 0 }, { Version("1.17"), Version("1.17.1") } }, + { { 8, 0 }, { Version("1.18"), Version("1.18.2") } }, + { { 9, 0 }, { Version("1.19"), Version("1.19.2") } }, + { { 11, 0 }, { Version("22w42a"), Version("22w44a") } }, + { { 12, 0 }, { Version("1.19.3"), Version("1.19.3") } }, + { { 13, 0 }, { Version("1.19.4"), Version("1.19.4") } }, + { { 14, 0 }, { Version("23w14a"), Version("23w16a") } }, + { { 15, 0 }, { Version("1.20"), Version("1.20.1") } }, + { { 16, 0 }, { Version("23w31a"), Version("23w31a") } }, + { { 17, 0 }, { Version("23w32a"), Version("1.20.2-pre1") } }, + { { 18, 0 }, { Version("1.20.2"), Version("1.20.2") } }, + { { 19, 0 }, { Version("23w42a"), Version("23w42a") } }, + { { 20, 0 }, { Version("23w43a"), Version("23w44a") } }, + { { 21, 0 }, { Version("23w45a"), Version("23w46a") } }, + { { 22, 0 }, { Version("1.20.3"), Version("1.20.4") } }, + { { 24, 0 }, { Version("24w03a"), Version("24w04a") } }, + { { 25, 0 }, { Version("24w05a"), Version("24w05b") } }, + { { 26, 0 }, { Version("24w06a"), Version("24w07a") } }, + { { 28, 0 }, { Version("24w09a"), Version("24w10a") } }, + { { 29, 0 }, { Version("24w11a"), Version("24w11a") } }, + { { 30, 0 }, { Version("24w12a"), Version("24w12a") } }, + { { 31, 0 }, { Version("24w13a"), Version("1.20.5-pre3") } }, + { { 32, 0 }, { Version("1.20.5"), Version("1.20.6") } }, + { { 33, 0 }, { Version("24w18a"), Version("24w20a") } }, + { { 34, 0 }, { Version("1.21"), Version("1.21.1") } }, + { { 35, 0 }, { Version("24w33a"), Version("24w33a") } }, + { { 36, 0 }, { Version("24w34a"), Version("24w35a") } }, + { { 37, 0 }, { Version("24w36a"), Version("24w36a") } }, + { { 38, 0 }, { Version("24w37a"), Version("24w37a") } }, + { { 39, 0 }, { Version("24w38a"), Version("24w39a") } }, + { { 40, 0 }, { Version("24w40a"), Version("24w40a") } }, + { { 41, 0 }, { Version("1.21.2-pre1"), Version("1.21.2-pre2") } }, + { { 42, 0 }, { Version("1.21.2"), Version("1.21.3") } }, + { { 43, 0 }, { Version("24w44a"), Version("24w44a") } }, + { { 44, 0 }, { Version("24w45a"), Version("24w45a") } }, + { { 45, 0 }, { Version("24w46a"), Version("24w46a") } }, + { { 46, 0 }, { Version("1.21.4"), Version("1.21.4") } }, + { { 47, 0 }, { Version("25w02a"), Version("25w02a") } }, + { { 48, 0 }, { Version("25w03a"), Version("25w03a") } }, + { { 49, 0 }, { Version("25w04a"), Version("25w04a") } }, + { { 50, 0 }, { Version("25w05a"), Version("25w05a") } }, + { { 51, 0 }, { Version("25w06a"), Version("25w06a") } }, + { { 52, 0 }, { Version("25w07a"), Version("25w07a") } }, + { { 53, 0 }, { Version("25w08a"), Version("25w09b") } }, + { { 54, 0 }, { Version("25w10a"), Version("25w10a") } }, + { { 55, 0 }, { Version("1.21.5"), Version("1.21.5") } }, + { { 56, 0 }, { Version("25w15a"), Version("25w15a") } }, + { { 57, 0 }, { Version("25w16a"), Version("25w16a") } }, + { { 58, 0 }, { Version("25w17a"), Version("25w17a") } }, + { { 59, 0 }, { Version("25w18a"), Version("25w18a") } }, + { { 60, 0 }, { Version("25w19a"), Version("25w19a") } }, + { { 61, 0 }, { Version("25w20a"), Version("25w20a") } }, + { { 62, 0 }, { Version("25w21a"), Version("25w21a") } }, + { { 63, 0 }, { Version("1.21.6"), Version("1.21.6") } }, + { { 64, 0 }, { Version("1.21.7"), Version("1.21.8") } }, + { { 65, 0 }, { Version("25w31a"), Version("25w31a") } }, + { { 65, 1 }, { Version("25w32a"), Version("25w32a") } }, + { { 65, 2 }, { Version("25w33a"), Version("25w33a") } }, + { { 66, 0 }, { Version("25w34a"), Version("25w34b") } }, + { { 67, 0 }, { Version("25w35a"), Version("25w35a") } }, + { { 68, 0 }, { Version("25w36a"), Version("25w36b") } }, + { { 69, 0 }, { Version("1.21.9"), Version("1.21.10") } }, + { { 70, 0 }, { Version("25w41a"), Version("25w41a") } }, + { { 70, 1 }, { Version("25w42a"), Version("25w42a") } }, + { { 71, 0 }, { Version("25w43a"), Version("25w43a") } }, + { { 72, 0 }, { Version("25w44a"), Version("25w44a") } }, + { { 73, 0 }, { Version("25w45a"), Version("25w45a") } }, + { { 74, 0 }, { Version("25w46a"), Version("25w46a") } }, + { { 75, 0 }, { Version("1.21.11"), Version("1.21.11") } }, + { { 76, 0 }, { Version("26.1-snap1"), Version("26.1-snap1") } }, + { { 77, 0 }, { Version("26.1-snap2"), Version("26.1-snap2") } }, + { { 78, 0 }, { Version("26.1-snap3"), Version("26.1-snap3") } }, + { { 78, 1 }, { Version("26.1-snap4"), Version("26.1-snap4") } }, + { { 79, 0 }, { Version("26.1-snap5"), Version("26.1-snap5") } }, + { { 80, 0 }, { Version("26.1-snap6"), Version("26.1-snap6") } }, + { { 81, 0 }, { Version("26.1-snap7"), Version("26.1-snap7") } }, + { { 81, 1 }, { Version("26.1-snap8"), Version("26.1-snap9") } }, + { { 82, 0 }, { Version("26.1-snap10"), Version("26.1-snap10") } }, + { { 83, 0 }, { Version("26.1-snap11"), Version("26.1-snap11") } }, +}; + +QMap, std::pair> ResourcePack::mappings() const +{ + return s_pack_format_versions; +} diff --git a/launcher/minecraft/mod/ResourcePack.h b/launcher/minecraft/mod/ResourcePack.h new file mode 100644 index 0000000..43aa5e1 --- /dev/null +++ b/launcher/minecraft/mod/ResourcePack.h @@ -0,0 +1,26 @@ +#pragma once + +#include "Resource.h" +#include "minecraft/mod/DataPack.h" + +#include +#include +#include +#include + +class Version; + +/* TODO: + * + * Store localized descriptions + * */ + +class ResourcePack : public DataPack { + Q_OBJECT + public: + ResourcePack(QObject* parent = nullptr) : DataPack(parent) {} + ResourcePack(QFileInfo file_info) : DataPack(file_info) {} + + /** Gets, respectively, the lower and upper versions supported by the set pack format. */ + QMap, std::pair> mappings() const override; +}; diff --git a/launcher/minecraft/mod/ResourcePackFolderModel.cpp b/launcher/minecraft/mod/ResourcePackFolderModel.cpp new file mode 100644 index 0000000..b2a2829 --- /dev/null +++ b/launcher/minecraft/mod/ResourcePackFolderModel.cpp @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ResourcePackFolderModel.h" + +#include +#include + +#include "Version.h" + +#include "minecraft/mod/tasks/LocalDataPackParseTask.h" + +ResourcePackFolderModel::ResourcePackFolderModel(const QDir& dir, BaseInstance* instance, bool is_indexed, bool create_dir, QObject* parent) + : ResourceFolderModel(dir, instance, is_indexed, create_dir, parent) +{ + m_column_names = QStringList({ "Enable", "Image", "Name", "Pack Format", "Last Modified", "Provider", "Size" }); + m_column_names_translated = + QStringList({ tr("Enable"), tr("Image"), tr("Name"), tr("Pack Format"), tr("Last Modified"), tr("Provider"), tr("Size") }); + m_column_sort_keys = { SortType::ENABLED, SortType::NAME, SortType::NAME, SortType::PACK_FORMAT, + SortType::DATE, SortType::PROVIDER, SortType::SIZE }; + m_column_resize_modes = { QHeaderView::Interactive, QHeaderView::Interactive, QHeaderView::Stretch, QHeaderView::Interactive, + QHeaderView::Interactive, QHeaderView::Interactive, QHeaderView::Interactive }; + m_columnsHideable = { false, true, false, true, true, true, true }; +} + +QVariant ResourcePackFolderModel::data(const QModelIndex& index, int role) const +{ + if (!validateIndex(index)) + return {}; + + int row = index.row(); + int column = index.column(); + + switch (role) { + case Qt::BackgroundRole: + return rowBackground(row); + case Qt::DisplayRole: { + if (column == PackFormatColumn) { + const auto& resource = at(row); + return resource.packFormatStr(); + } + break; + } + case Qt::DecorationRole: { + if (column == ImageColumn) { + return at(row).image({ 32, 32 }, Qt::AspectRatioMode::KeepAspectRatioByExpanding); + } + break; + } + case Qt::ToolTipRole: { + if (column == PackFormatColumn) { + //: The string being explained by this is in the format: ID (Lower version - Upper version) + return tr("The resource pack format ID, as well as the Minecraft versions it was designed for."); + } + break; + } + case Qt::SizeHintRole: + if (column == ImageColumn) { + return QSize(32, 32); + } + break; + } + + // map the columns to the base equivilents + QModelIndex mappedIndex; + switch (column) { + case ActiveColumn: + mappedIndex = index.siblingAtColumn(ResourceFolderModel::ActiveColumn); + break; + case NameColumn: + mappedIndex = index.siblingAtColumn(ResourceFolderModel::NameColumn); + break; + case DateColumn: + mappedIndex = index.siblingAtColumn(ResourceFolderModel::DateColumn); + break; + case ProviderColumn: + mappedIndex = index.siblingAtColumn(ResourceFolderModel::ProviderColumn); + break; + case SizeColumn: + mappedIndex = index.siblingAtColumn(ResourceFolderModel::SizeColumn); + break; + } + + if (mappedIndex.isValid()) { + return ResourceFolderModel::data(mappedIndex, role); + } + + return {}; +} + +QVariant ResourcePackFolderModel::headerData(int section, [[maybe_unused]] Qt::Orientation orientation, int role) const +{ + switch (role) { + case Qt::DisplayRole: + switch (section) { + case ActiveColumn: + case NameColumn: + case PackFormatColumn: + case DateColumn: + case ImageColumn: + case ProviderColumn: + case SizeColumn: + return columnNames().at(section); + default: + return {}; + } + + case Qt::ToolTipRole: + switch (section) { + case ActiveColumn: + return tr("Is the resource pack enabled?"); + case NameColumn: + return tr("The name of the resource pack."); + case PackFormatColumn: + //: The string being explained by this is in the format: ID (Lower version - Upper version) + return tr("The resource pack format ID, as well as the Minecraft versions it was designed for."); + case DateColumn: + return tr("The date and time this resource pack was last changed (or added)."); + case ProviderColumn: + return tr("The source provider of the resource pack."); + case SizeColumn: + return tr("The size of the resource pack."); + default: + return {}; + } + case Qt::SizeHintRole: + if (section == ImageColumn) { + return QSize(64, 0); + } + return {}; + default: + return {}; + } +} + +int ResourcePackFolderModel::columnCount(const QModelIndex& parent) const +{ + return parent.isValid() ? 0 : NUM_COLUMNS; +} + +Task* ResourcePackFolderModel::createParseTask(Resource& resource) +{ + return new LocalDataPackParseTask(m_next_resolution_ticket, dynamic_cast(&resource)); +} diff --git a/launcher/minecraft/mod/ResourcePackFolderModel.h b/launcher/minecraft/mod/ResourcePackFolderModel.h new file mode 100644 index 0000000..b552c32 --- /dev/null +++ b/launcher/minecraft/mod/ResourcePackFolderModel.h @@ -0,0 +1,25 @@ +#pragma once + +#include "ResourceFolderModel.h" + +#include "ResourcePack.h" + +class ResourcePackFolderModel : public ResourceFolderModel { + Q_OBJECT + public: + enum Columns { ActiveColumn = 0, ImageColumn, NameColumn, PackFormatColumn, DateColumn, ProviderColumn, SizeColumn, NUM_COLUMNS }; + + explicit ResourcePackFolderModel(const QDir& dir, BaseInstance* instance, bool is_indexed, bool create_dir, QObject* parent = nullptr); + + QString id() const override { return "resourcepacks"; } + + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + + QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; + int columnCount(const QModelIndex& parent) const override; + + [[nodiscard]] Resource* createResource(const QFileInfo& file) override { return new ResourcePack(file); } + [[nodiscard]] Task* createParseTask(Resource&) override; + + RESOURCE_HELPERS(ResourcePack) +}; diff --git a/launcher/minecraft/mod/ShaderPack.cpp b/launcher/minecraft/mod/ShaderPack.cpp new file mode 100644 index 0000000..99e51fc --- /dev/null +++ b/launcher/minecraft/mod/ShaderPack.cpp @@ -0,0 +1,35 @@ + +// SPDX-FileCopyrightText: 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> +// +// SPDX-License-Identifier: GPL-3.0-only + +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "ShaderPack.h" + +void ShaderPack::setPackFormat(ShaderPackFormat new_format) +{ + QMutexLocker locker(&m_data_lock); + + m_pack_format = new_format; +} + +bool ShaderPack::valid() const +{ + return m_pack_format != ShaderPackFormat::INVALID; +} diff --git a/launcher/minecraft/mod/ShaderPack.h b/launcher/minecraft/mod/ShaderPack.h new file mode 100644 index 0000000..9275ebe --- /dev/null +++ b/launcher/minecraft/mod/ShaderPack.h @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> +// +// SPDX-License-Identifier: GPL-3.0-only + +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include "Resource.h" + +/* Info: + * Currently For Optifine / Iris shader packs, + * could be expanded to support others should they exist? + * + * This class and enum are mostly here as placeholders for validating + * that a shaderpack exists and is in the right format, + * namely that they contain a folder named 'shaders'. + * + * In the technical sense it would be possible to parse files like `shaders/shaders.properties` + * to get information like the available profiles but this is not all that useful without more knowledge of the + * shader mod used to be able to change settings. + */ + +#include + +enum class ShaderPackFormat { VALID, INVALID }; + +class ShaderPack : public Resource { + Q_OBJECT + public: + using Ptr = shared_qobject_ptr; + + ShaderPackFormat packFormat() const { return m_pack_format; } + + ShaderPack(QObject* parent = nullptr) : Resource(parent) {} + ShaderPack(QFileInfo file_info) : Resource(file_info) {} + + /** Thread-safe. */ + void setPackFormat(ShaderPackFormat new_format); + + bool valid() const override; + + protected: + mutable QMutex m_data_lock; + + ShaderPackFormat m_pack_format = ShaderPackFormat::INVALID; +}; diff --git a/launcher/minecraft/mod/ShaderPackFolderModel.cpp b/launcher/minecraft/mod/ShaderPackFolderModel.cpp new file mode 100644 index 0000000..ea68e02 --- /dev/null +++ b/launcher/minecraft/mod/ShaderPackFolderModel.cpp @@ -0,0 +1,56 @@ +#include "ShaderPackFolderModel.h" +#include "FileSystem.h" + +namespace { +class ShaderPackIndexMigrateTask : public Task { + Q_OBJECT + public: + ShaderPackIndexMigrateTask(QDir resourceDir, QDir indexDir) : m_resourceDir(std::move(resourceDir)), m_indexDir(std::move(indexDir)) {} + + void executeTask() override + { + if (!m_indexDir.exists()) { + qDebug() << m_indexDir.absolutePath() << "does not exist; nothing to migrate"; + emitSucceeded(); + return; + } + + QStringList pwFiles = m_indexDir.entryList({ "*.pw.toml" }, QDir::Files); + bool movedAll = true; + + for (const auto& file : pwFiles) { + QString src = m_indexDir.filePath(file); + QString dest = m_resourceDir.filePath(file); + + if (FS::move(src, dest)) { + qDebug() << "Moved" << src << "to" << dest; + } else { + movedAll = false; + } + } + + if (!movedAll) { + // FIXME: not shown in the UI + emitFailed(tr("Failed to migrate shaderpack metadata from .index")); + return; + } + + if (!FS::deletePath(m_indexDir.absolutePath())) { + emitFailed(tr("Failed to remove old .index dir")); + return; + } + + emitSucceeded(); + } + + private: + QDir m_resourceDir, m_indexDir; +}; +} // namespace + +Task* ShaderPackFolderModel::createPreUpdateTask() +{ + return new ShaderPackIndexMigrateTask(m_dir, ResourceFolderModel::indexDir()); +} + +#include "ShaderPackFolderModel.moc" diff --git a/launcher/minecraft/mod/ShaderPackFolderModel.h b/launcher/minecraft/mod/ShaderPackFolderModel.h new file mode 100644 index 0000000..9b01801 --- /dev/null +++ b/launcher/minecraft/mod/ShaderPackFolderModel.h @@ -0,0 +1,36 @@ +#pragma once + +#include "ResourceFolderModel.h" +#include "minecraft/mod/ShaderPack.h" +#include "minecraft/mod/tasks/LocalShaderPackParseTask.h" + +class ShaderPackFolderModel : public ResourceFolderModel { + Q_OBJECT + + public: + explicit ShaderPackFolderModel(const QDir& dir, BaseInstance* instance, bool is_indexed, bool create_dir, QObject* parent = nullptr) + : ResourceFolderModel(dir, instance, is_indexed, create_dir, parent) + {} + + virtual QString id() const override { return "shaderpacks"; } + + [[nodiscard]] Resource* createResource(const QFileInfo& info) override { return new ShaderPack(info); } + + [[nodiscard]] Task* createParseTask(Resource& resource) override + { + return new LocalShaderPackParseTask(m_next_resolution_ticket, static_cast(resource)); + } + + QDir indexDir() const override { return m_dir; } + + Task* createPreUpdateTask() override; + + // avoid watching twice + virtual bool startWatching() override { return ResourceFolderModel::startWatching({ m_dir.absolutePath() }); } + virtual bool stopWatching() override { return ResourceFolderModel::stopWatching({ m_dir.absolutePath() }); } + + RESOURCE_HELPERS(ShaderPack); + + private: + QMutex m_migrateLock; +}; diff --git a/launcher/minecraft/mod/TexturePack.cpp b/launcher/minecraft/mod/TexturePack.cpp new file mode 100644 index 0000000..a1ef7f5 --- /dev/null +++ b/launcher/minecraft/mod/TexturePack.cpp @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "TexturePack.h" + +#include +#include +#include "MTPixmapCache.h" + +#include "minecraft/mod/tasks/LocalTexturePackParseTask.h" + +void TexturePack::setDescription(QString new_description) +{ + QMutexLocker locker(&m_data_lock); + + m_description = new_description; +} + +void TexturePack::setImage(QImage new_image) const +{ + QMutexLocker locker(&m_data_lock); + + Q_ASSERT(!new_image.isNull()); + + if (m_pack_image_cache_key.key.isValid()) + PixmapCache::remove(m_pack_image_cache_key.key); + + // scale the image to avoid flooding the pixmapcache + auto pixmap = + QPixmap::fromImage(new_image.scaled({ 64, 64 }, Qt::AspectRatioMode::KeepAspectRatioByExpanding, Qt::SmoothTransformation)); + + m_pack_image_cache_key.key = PixmapCache::insert(pixmap); + m_pack_image_cache_key.was_ever_used = true; +} + +QPixmap TexturePack::image(QSize size, Qt::AspectRatioMode mode) const +{ + QPixmap cached_image; + if (PixmapCache::find(m_pack_image_cache_key.key, &cached_image)) { + if (size.isNull()) + return cached_image; + return cached_image.scaled(size, mode, Qt::SmoothTransformation); + } + + // No valid image we can get + if (!m_pack_image_cache_key.was_ever_used) { + return {}; + } else { + qDebug() << "Texture Pack" << name() << "Had it's image evicted from the cache. reloading..."; + PixmapCache::markCacheMissByEviciton(); + } + + // Imaged got evicted from the cache. Re-process it and retry. + TexturePackUtils::processPackPNG(*this); + return image(size); +} + +bool TexturePack::valid() const +{ + return m_description != nullptr; +} diff --git a/launcher/minecraft/mod/TexturePack.h b/launcher/minecraft/mod/TexturePack.h new file mode 100644 index 0000000..1327e2f --- /dev/null +++ b/launcher/minecraft/mod/TexturePack.h @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include "Resource.h" + +#include +#include +#include +#include + +class Version; + +class TexturePack : public Resource { + Q_OBJECT + public: + using Ptr = shared_qobject_ptr; + + TexturePack(QObject* parent = nullptr) : Resource(parent) {} + TexturePack(QFileInfo file_info) : Resource(file_info) {} + + /** Gets the description of the texture pack. */ + QString description() const { return m_description; } + + /** Gets the image of the texture pack, converted to a QPixmap for drawing, and scaled to size. */ + QPixmap image(QSize size, Qt::AspectRatioMode mode = Qt::AspectRatioMode::IgnoreAspectRatio) const; + + /** Thread-safe. */ + void setDescription(QString new_description); + + /** Thread-safe. */ + void setImage(QImage new_image) const; + + bool valid() const override; + + protected: + mutable QMutex m_data_lock; + + /** The texture pack's description, as defined in the pack.txt file. + */ + QString m_description; + + /** The texture pack's image file cache key, for access in the QPixmapCache global instance. + * + * The 'was_ever_used' state simply identifies whether the key was never inserted on the cache (true), + * so as to tell whether a cache entry is inexistent or if it was just evicted from the cache. + */ + struct { + QPixmapCache::Key key; + bool was_ever_used = false; + } mutable m_pack_image_cache_key; +}; diff --git a/launcher/minecraft/mod/TexturePackFolderModel.cpp b/launcher/minecraft/mod/TexturePackFolderModel.cpp new file mode 100644 index 0000000..d96b768 --- /dev/null +++ b/launcher/minecraft/mod/TexturePackFolderModel.cpp @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +#include "TexturePackFolderModel.h" + +#include "minecraft/mod/tasks/LocalTexturePackParseTask.h" +#include "minecraft/mod/tasks/ResourceFolderLoadTask.h" + +TexturePackFolderModel::TexturePackFolderModel(const QDir& dir, BaseInstance* instance, bool is_indexed, bool create_dir, QObject* parent) + : ResourceFolderModel(QDir(dir), instance, is_indexed, create_dir, parent) +{ + m_column_names = QStringList({ "Enable", "Image", "Name", "Last Modified", "Provider", "Size" }); + m_column_names_translated = QStringList({ tr("Enable"), tr("Image"), tr("Name"), tr("Last Modified"), tr("Provider"), tr("Size") }); + m_column_sort_keys = { SortType::ENABLED, SortType::NAME, SortType::NAME, SortType::DATE, SortType::PROVIDER, SortType::SIZE }; + m_column_resize_modes = { QHeaderView::Interactive, QHeaderView::Interactive, QHeaderView::Stretch, + QHeaderView::Interactive, QHeaderView::Interactive, QHeaderView::Interactive }; + m_columnsHideable = { false, true, false, true, true, true }; +} + +Task* TexturePackFolderModel::createParseTask(Resource& resource) +{ + return new LocalTexturePackParseTask(m_next_resolution_ticket, static_cast(resource)); +} + +QVariant TexturePackFolderModel::data(const QModelIndex& index, int role) const +{ + if (!validateIndex(index)) + return {}; + + int row = index.row(); + int column = index.column(); + + switch (role) { + case Qt::BackgroundRole: + return rowBackground(row); + case Qt::DecorationRole: { + if (column == ImageColumn) { + return at(row).image({ 32, 32 }, Qt::AspectRatioMode::KeepAspectRatioByExpanding); + } + break; + } + case Qt::SizeHintRole: + if (column == ImageColumn) { + return QSize(32, 32); + } + break; + } + + // map the columns to the base equivilents + QModelIndex mappedIndex; + switch (column) { + case ActiveColumn: + mappedIndex = index.siblingAtColumn(ResourceFolderModel::ActiveColumn); + break; + case NameColumn: + mappedIndex = index.siblingAtColumn(ResourceFolderModel::NameColumn); + break; + case DateColumn: + mappedIndex = index.siblingAtColumn(ResourceFolderModel::DateColumn); + break; + case ProviderColumn: + mappedIndex = index.siblingAtColumn(ResourceFolderModel::ProviderColumn); + break; + case SizeColumn: + mappedIndex = index.siblingAtColumn(ResourceFolderModel::SizeColumn); + break; + } + + if (mappedIndex.isValid()) { + return ResourceFolderModel::data(mappedIndex, role); + } + + return {}; +} + +QVariant TexturePackFolderModel::headerData(int section, [[maybe_unused]] Qt::Orientation orientation, int role) const +{ + switch (role) { + case Qt::DisplayRole: + switch (section) { + case ActiveColumn: + case NameColumn: + case DateColumn: + case ImageColumn: + case ProviderColumn: + case SizeColumn: + return columnNames().at(section); + default: + return {}; + } + case Qt::ToolTipRole: { + switch (section) { + case ActiveColumn: + return tr("Is the texture pack enabled?"); + case NameColumn: + return tr("The name of the texture pack."); + case DateColumn: + return tr("The date and time this texture pack was last changed (or added)."); + case ProviderColumn: + return tr("The source provider of the texture pack."); + case SizeColumn: + return tr("The size of the texture pack."); + default: + return {}; + } + } + default: + break; + } + + return {}; +} + +int TexturePackFolderModel::columnCount(const QModelIndex& parent) const +{ + return parent.isValid() ? 0 : NUM_COLUMNS; +} diff --git a/launcher/minecraft/mod/TexturePackFolderModel.h b/launcher/minecraft/mod/TexturePackFolderModel.h new file mode 100644 index 0000000..37f78d8 --- /dev/null +++ b/launcher/minecraft/mod/TexturePackFolderModel.h @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "ResourceFolderModel.h" + +#include "TexturePack.h" + +class TexturePackFolderModel : public ResourceFolderModel { + Q_OBJECT + + public: + enum Columns { ActiveColumn = 0, ImageColumn, NameColumn, DateColumn, ProviderColumn, SizeColumn, NUM_COLUMNS }; + + explicit TexturePackFolderModel(const QDir& dir, BaseInstance* instance, bool is_indexed, bool create_dir, QObject* parent = nullptr); + + virtual QString id() const override { return "texturepacks"; } + + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + + QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; + int columnCount(const QModelIndex& parent) const override; + + [[nodiscard]] Resource* createResource(const QFileInfo& file) override { return new TexturePack(file); } + [[nodiscard]] Task* createParseTask(Resource&) override; + + RESOURCE_HELPERS(TexturePack) +}; diff --git a/launcher/minecraft/mod/WorldSave.cpp b/launcher/minecraft/mod/WorldSave.cpp new file mode 100644 index 0000000..7123f51 --- /dev/null +++ b/launcher/minecraft/mod/WorldSave.cpp @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> +// +// SPDX-License-Identifier: GPL-3.0-only + +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "WorldSave.h" + +#include "minecraft/mod/tasks/LocalWorldSaveParseTask.h" + +void WorldSave::setSaveFormat(WorldSaveFormat new_save_format) +{ + QMutexLocker locker(&m_data_lock); + + m_save_format = new_save_format; +} + +void WorldSave::setSaveDirName(QString dir_name) +{ + QMutexLocker locker(&m_data_lock); + + m_save_dir_name = dir_name; +} + +bool WorldSave::valid() const +{ + return m_save_format != WorldSaveFormat::INVALID; +} diff --git a/launcher/minecraft/mod/WorldSave.h b/launcher/minecraft/mod/WorldSave.h new file mode 100644 index 0000000..702a3ed --- /dev/null +++ b/launcher/minecraft/mod/WorldSave.h @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> +// +// SPDX-License-Identifier: GPL-3.0-only + +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include "Resource.h" + +#include + +class Version; + +enum class WorldSaveFormat { SINGLE, MULTI, INVALID }; + +class WorldSave : public Resource { + Q_OBJECT + public: + using Ptr = shared_qobject_ptr; + + WorldSave(QObject* parent = nullptr) : Resource(parent) {} + WorldSave(QFileInfo file_info) : Resource(file_info) {} + + /** Gets the format of the save. */ + WorldSaveFormat saveFormat() const { return m_save_format; } + /** Gets the name of the save dir (first found in multi mode). */ + QString saveDirName() const { return m_save_dir_name; } + + /** Thread-safe. */ + void setSaveFormat(WorldSaveFormat new_save_format); + /** Thread-safe. */ + void setSaveDirName(QString dir_name); + + bool valid() const override; + + protected: + mutable QMutex m_data_lock; + + /** The format in which the save file is in. + * Since saves can be distributed in various slightly different ways, this allows us to treat them separately. + */ + WorldSaveFormat m_save_format = WorldSaveFormat::INVALID; + + QString m_save_dir_name; +}; diff --git a/launcher/minecraft/mod/tasks/GetModDependenciesTask.cpp b/launcher/minecraft/mod/tasks/GetModDependenciesTask.cpp new file mode 100644 index 0000000..0859c98 --- /dev/null +++ b/launcher/minecraft/mod/tasks/GetModDependenciesTask.cpp @@ -0,0 +1,339 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "GetModDependenciesTask.h" + +#include +#include +#include +#include "Json.h" +#include "QObjectPtr.h" +#include "minecraft/PackProfile.h" +#include "minecraft/mod/MetadataHandler.h" +#include "modplatform/ModIndex.h" +#include "modplatform/ResourceAPI.h" +#include "tasks/SequentialTask.h" +#include "ui/pages/modplatform/ModModel.h" + +static Version mcVersion(BaseInstance* inst) +{ + return static_cast(inst)->getPackProfile()->getComponent("net.minecraft")->getVersion(); +} + +static ModPlatform::ModLoaderTypes mcLoaders(BaseInstance* inst) +{ + return static_cast(inst)->getPackProfile()->getSupportedModLoaders().value(); +} + +static bool checkDependencies(std::shared_ptr sel, + Version mcVersion, + ModPlatform::ModLoaderTypes loaders) +{ + return (sel->pack->versions.isEmpty() || sel->version.mcVersion.contains(mcVersion.toString())) && + (!loaders || !sel->version.loaders || sel->version.loaders & loaders); +} + +GetModDependenciesTask::GetModDependenciesTask(BaseInstance* instance, + ModFolderModel* folder, + QList> selected) + : SequentialTask(tr("Get dependencies")), m_selected(selected), m_version(mcVersion(instance)), m_loaderType(mcLoaders(instance)) +{ + for (auto mod : folder->allMods()) { + m_mods_file_names << mod->fileinfo().fileName(); + if (auto meta = mod->metadata(); meta) + m_mods.append(meta); + } + prepare(); +} + +void GetModDependenciesTask::prepare() +{ + for (auto sel : m_selected) { + if (checkDependencies(sel, m_version, m_loaderType)) + for (auto dep : getDependenciesForVersion(sel->version, sel->pack->provider)) { + addTask(prepareDependencyTask(dep, sel->pack->provider, 20)); + } + } +} + +ModPlatform::Dependency GetModDependenciesTask::getOverride(const ModPlatform::Dependency& dep, + const ModPlatform::ResourceProvider providerName) +{ + if (auto isQuilt = (m_loaderType & ModPlatform::Quilt) != 0U; isQuilt || (m_loaderType & ModPlatform::Fabric) != 0U) { + auto overide = ModPlatform::getOverrideDeps(); + auto over = std::find_if(overide.cbegin(), overide.cend(), [dep, providerName, isQuilt](const auto& o) { + return o.provider == providerName && dep.addonId == (isQuilt ? o.fabric : o.quilt); + }); + if (over != overide.cend()) { + return { .addonId = isQuilt ? over->quilt : over->fabric, .type = dep.type, .version = "" }; + } + } + return dep; +} + +QList GetModDependenciesTask::getDependenciesForVersion(const ModPlatform::IndexedVersion& version, + const ModPlatform::ResourceProvider providerName) +{ + QList c_dependencies; + for (auto ver_dep : version.dependencies) { + if (ver_dep.type != ModPlatform::DependencyType::REQUIRED) { + continue; + } + ver_dep = getOverride(ver_dep, providerName); + auto isOnlyVersion = providerName == ModPlatform::ResourceProvider::MODRINTH && ver_dep.addonId.toString().isEmpty(); + if (auto dep = std::find_if(c_dependencies.begin(), c_dependencies.end(), + [&ver_dep, isOnlyVersion](const ModPlatform::Dependency& i) { + return isOnlyVersion ? i.version == ver_dep.version : i.addonId == ver_dep.addonId; + }); + dep != c_dependencies.end()) { + continue; // check the current dependency list + } + + if (auto dep = std::find_if(m_selected.begin(), m_selected.end(), + [&ver_dep, providerName, isOnlyVersion](const std::shared_ptr& i) { + return i->pack->provider == providerName && (isOnlyVersion ? i->version.version == ver_dep.version + : i->pack->addonId == ver_dep.addonId); + }); + dep != m_selected.end()) { + continue; // check the selected versions + } + + if (auto dep = std::find_if(m_mods.begin(), m_mods.end(), + [&ver_dep, providerName, isOnlyVersion](const std::shared_ptr& i) { + return i->provider == providerName && + (isOnlyVersion ? i->file_id == ver_dep.version : i->project_id == ver_dep.addonId); + }); + dep != m_mods.end()) { + continue; // check the existing mods + } + + if (auto dep = std::find_if(m_pack_dependencies.begin(), m_pack_dependencies.end(), + [&ver_dep, providerName, isOnlyVersion](const std::shared_ptr& i) { + return i->pack->provider == providerName && (isOnlyVersion ? i->version.version == ver_dep.addonId + : i->pack->addonId == ver_dep.addonId); + }); + dep != m_pack_dependencies.end()) { // check loaded dependencies + continue; + } + + c_dependencies.append(ver_dep); + } + return c_dependencies; +} + +Task::Ptr GetModDependenciesTask::getProjectInfoTask(std::shared_ptr pDep) +{ + auto provider = pDep->pack->provider; + auto [info, responseInfo] = getAPI(provider)->getProject(pDep->pack->addonId.toString()); + connect(info.get(), &NetJob::succeeded, [this, responseInfo, provider, pDep] { + QJsonParseError parse_error{}; + QJsonDocument doc = QJsonDocument::fromJson(*responseInfo, &parse_error); + if (parse_error.error != QJsonParseError::NoError) { + removePack(pDep->pack->addonId); + qWarning() << "Error while parsing JSON response for mod info at" << parse_error.offset + << "reason:" << parse_error.errorString(); + qDebug() << *responseInfo; + return; + } + try { + auto obj = provider == ModPlatform::ResourceProvider::FLAME ? Json::requireObject(Json::requireObject(doc), "data") + : Json::requireObject(doc); + + getAPI(provider)->loadIndexedPack(*pDep->pack, obj); + } catch (const JSONValidationError& e) { + removePack(pDep->pack->addonId); + qDebug() << doc; + qWarning() << "Error while reading mod info:" << e.cause(); + } + }); + return info; +} + +Task::Ptr GetModDependenciesTask::prepareDependencyTask(const ModPlatform::Dependency& dep, + const ModPlatform::ResourceProvider providerName, + int level) +{ + auto pDep = std::make_shared(); + pDep->dependency = dep; + pDep->pack = std::make_shared(); + pDep->pack->addonId = dep.addonId; + pDep->pack->provider = providerName; + + m_pack_dependencies.append(pDep); + + auto provider = providerName; + + auto tasks = makeShared( + QString("DependencyInfo: %1").arg(dep.addonId.toString().isEmpty() ? dep.version : dep.addonId.toString())); + + if (!dep.addonId.toString().isEmpty()) { + tasks->addTask(getProjectInfoTask(pDep)); + } + + ResourceAPI::DependencySearchArgs args = { + .dependency = dep, .mcVersion = m_version, .loader = m_loaderType, .includeChangelog = true + }; + ResourceAPI::Callback callbacks; + callbacks.on_fail = [](const QString& reason, int) { + qCritical() << tr("A network error occurred. Could not load project dependencies:%1").arg(reason); + }; + callbacks.on_succeed = [dep, provider, pDep, level, this](auto& pack) { + pDep->version = pack; + if (!pDep->version.addonId.isValid()) { + if (m_loaderType & ModPlatform::Quilt) { // falback for quilt + auto overide = ModPlatform::getOverrideDeps(); + auto over = std::find_if(overide.cbegin(), overide.cend(), + [dep, provider](const auto& o) { return o.provider == provider && dep.addonId == o.quilt; }); + if (over != overide.cend()) { + removePack(dep.addonId); + addTask(prepareDependencyTask({ .addonId = over->fabric, .type = dep.type, .version = "" }, provider, level)); + return; + } + } + removePack(dep.addonId); + return; + } + pDep->version.is_currently_selected = true; + pDep->pack->versions = { pDep->version }; + pDep->pack->versionsLoaded = true; + + if (level == 0) { + removePack(dep.addonId); + qWarning() << "Dependency cycle exceeded"; + return; + } + if (dep.addonId.toString().isEmpty() && !pDep->version.addonId.toString().isEmpty()) { + pDep->pack->addonId = pDep->version.addonId; + auto dep_ = getOverride({ .addonId = pDep->version.addonId, .type = pDep->dependency.type, .version = "" }, provider); + if (dep_.addonId != pDep->version.addonId) { + removePack(pDep->version.addonId); + addTask(prepareDependencyTask(dep_, provider, level)); + } else { + addTask(getProjectInfoTask(pDep)); + } + } + if (isLocalyInstalled(pDep)) { + removePack(pDep->version.addonId); + return; + } + for (const auto& dep_ : getDependenciesForVersion(pDep->version, provider)) { + addTask(prepareDependencyTask(dep_, provider, level - 1)); + } + }; + + auto version = getAPI(provider)->getDependencyVersion(std::move(args), std::move(callbacks)); + tasks->addTask(version); + return tasks; +} + +void GetModDependenciesTask::removePack(const QVariant& addonId) +{ + auto pred = [addonId](const std::shared_ptr& v) { return v->pack->addonId == addonId; }; +#if QT_VERSION >= QT_VERSION_CHECK(6, 1, 0) + m_pack_dependencies.removeIf(pred); +#else + for (auto it = m_pack_dependencies.begin(); it != m_pack_dependencies.end();) + if (pred(*it)) + it = m_pack_dependencies.erase(it); + else + ++it; +#endif +} + +auto GetModDependenciesTask::getExtraInfo() -> QHash +{ + QHash rby; + auto fullList = m_selected + m_pack_dependencies; + for (auto& mod : fullList) { + auto addonId = mod->pack->addonId; + auto provider = mod->pack->provider; + auto version = mod->version.fileId; + auto req = QStringList(); + for (auto& smod : fullList) { + if (provider != smod->pack->provider) + continue; + auto deps = smod->version.dependencies; + if (auto dep = std::find_if(deps.begin(), deps.end(), + [addonId, provider, version](const ModPlatform::Dependency& d) { + return d.type == ModPlatform::DependencyType::REQUIRED && + (provider == ModPlatform::ResourceProvider::MODRINTH && d.addonId.toString().isEmpty() + ? version == d.version + : d.addonId == addonId); + }); + dep != deps.end()) { + req.append(smod->pack->name); + } + } + rby[addonId.toString()] = { maybeInstalled(mod), req }; + } + return rby; +} + +// super lax compare (but not fuzzy) +// convert to lowercase +// convert all speratores to whitespace +// simplify sequence of internal whitespace to a single space +// efectivly compare two strings ignoring all separators and case +auto laxCompare = [](QString fsfilename, QString metadataFilename, bool excludeDigits = false) { + // allowed character seperators + QList allowedSeperators = { '-', '+', '.', '_' }; + if (excludeDigits) + allowedSeperators.append({ '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' }); + + // copy in lowercase + auto fsName = fsfilename.toLower(); + auto metaName = metadataFilename.toLower(); + + // replace all potential allowed seperatores with whitespace + for (auto sep : allowedSeperators) { + fsName = fsName.replace(sep, ' '); + metaName = metaName.replace(sep, ' '); + } + + // remove extraneous whitespace + fsName = fsName.simplified(); + metaName = metaName.simplified(); + + return fsName.compare(metaName) == 0; +}; + +bool GetModDependenciesTask::isLocalyInstalled(std::shared_ptr pDep) +{ + return pDep->version.fileName.isEmpty() || + + std::find_if(m_selected.begin(), m_selected.end(), + [pDep](std::shared_ptr i) { + return !i->version.fileName.isEmpty() && laxCompare(i->version.fileName, pDep->version.fileName); + }) != m_selected.end() || // check the selected versions + + std::find_if(m_mods_file_names.begin(), m_mods_file_names.end(), + [pDep](QString i) { return !i.isEmpty() && laxCompare(i, pDep->version.fileName); }) != + m_mods_file_names.end() || // check the existing mods + + std::find_if(m_pack_dependencies.begin(), m_pack_dependencies.end(), [pDep](std::shared_ptr i) { + return pDep->pack->addonId != i->pack->addonId && !i->version.fileName.isEmpty() && + laxCompare(pDep->version.fileName, i->version.fileName); + }) != m_pack_dependencies.end(); // check loaded dependencies +} + +bool GetModDependenciesTask::maybeInstalled(std::shared_ptr pDep) +{ + return std::find_if(m_mods_file_names.begin(), m_mods_file_names.end(), [pDep](QString i) { + return !i.isEmpty() && laxCompare(i, pDep->version.fileName, true); + }) != m_mods_file_names.end(); // check the existing mods +} diff --git a/launcher/minecraft/mod/tasks/GetModDependenciesTask.h b/launcher/minecraft/mod/tasks/GetModDependenciesTask.h new file mode 100644 index 0000000..d6c2985 --- /dev/null +++ b/launcher/minecraft/mod/tasks/GetModDependenciesTask.h @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "minecraft/mod/MetadataHandler.h" +#include "minecraft/mod/ModFolderModel.h" +#include "modplatform/ModIndex.h" +#include "modplatform/ResourceAPI.h" +#include "modplatform/flame/FlameAPI.h" +#include "modplatform/modrinth/ModrinthAPI.h" +#include "tasks/SequentialTask.h" +#include "tasks/Task.h" +#include "ui/pages/modplatform/ModModel.h" + +class GetModDependenciesTask : public SequentialTask { + Q_OBJECT + public: + using Ptr = shared_qobject_ptr; + + struct PackDependency { + ModPlatform::Dependency dependency; + ModPlatform::IndexedPack::Ptr pack; + ModPlatform::IndexedVersion version; + PackDependency() = default; + PackDependency(ModPlatform::IndexedPack::Ptr p, ModPlatform::IndexedVersion v) : pack(std::move(p)), version(std::move(v)) {} + }; + + struct PackDependencyExtraInfo { + bool maybe_installed{}; + QStringList required_by; + }; + + explicit GetModDependenciesTask(BaseInstance* instance, ModFolderModel* folder, QList> selected); + + auto getDependecies() const -> QList> { return m_pack_dependencies; } + QHash getExtraInfo(); + + private: + ResourceAPI* getAPI(ModPlatform::ResourceProvider provider) + { + if (provider == ModPlatform::ResourceProvider::FLAME) { + return &m_flameAPI; + } + return &m_modrinthAPI; + } + + protected slots: + Task::Ptr prepareDependencyTask(const ModPlatform::Dependency&, ModPlatform::ResourceProvider, int); + QList getDependenciesForVersion(const ModPlatform::IndexedVersion&, + ModPlatform::ResourceProvider providerName); + void prepare(); + Task::Ptr getProjectInfoTask(std::shared_ptr pDep); + ModPlatform::Dependency getOverride(const ModPlatform::Dependency&, ModPlatform::ResourceProvider providerName); + void removePack(const QVariant& addonId); + + bool isLocalyInstalled(std::shared_ptr pDep); + bool maybeInstalled(std::shared_ptr pDep); + + private: + QList> m_pack_dependencies; + QList> m_mods; + QList> m_selected; + QStringList m_mods_file_names; + + Version m_version; + ModPlatform::ModLoaderTypes m_loaderType; + + ModrinthAPI m_modrinthAPI; + FlameAPI m_flameAPI; +}; diff --git a/launcher/minecraft/mod/tasks/LocalDataPackParseTask.cpp b/launcher/minecraft/mod/tasks/LocalDataPackParseTask.cpp new file mode 100644 index 0000000..1bf9a5c --- /dev/null +++ b/launcher/minecraft/mod/tasks/LocalDataPackParseTask.cpp @@ -0,0 +1,388 @@ +// SPDX-FileCopyrightText: 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> +// +// SPDX-License-Identifier: GPL-3.0-only + +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "LocalDataPackParseTask.h" + +#include "FileSystem.h" +#include "Json.h" +#include "archive/ArchiveReader.h" +#include "minecraft/mod/ResourcePack.h" + +#include +#include + +namespace DataPackUtils { + +bool process(DataPack* pack, ProcessingLevel level) +{ + switch (pack->type()) { + case ResourceType::FOLDER: + return DataPackUtils::processFolder(pack, level); + case ResourceType::ZIPFILE: + return DataPackUtils::processZIP(pack, level); + default: + qWarning() << "Invalid type for data pack parse task!"; + return false; + } +} + +bool processFolder(DataPack* pack, ProcessingLevel level) +{ + Q_ASSERT(pack->type() == ResourceType::FOLDER); + + auto mcmeta_invalid = [&pack]() { + qWarning() << "Data pack at" << pack->fileinfo().filePath() << "does not have a valid pack.mcmeta"; + return false; // the mcmeta is not optional + }; + + QFileInfo mcmeta_file_info(FS::PathCombine(pack->fileinfo().filePath(), "pack.mcmeta")); + if (mcmeta_file_info.exists() && mcmeta_file_info.isFile()) { + QFile mcmeta_file(mcmeta_file_info.filePath()); + if (!mcmeta_file.open(QIODevice::ReadOnly)) + return mcmeta_invalid(); // can't open mcmeta file + + auto data = mcmeta_file.readAll(); + + bool mcmeta_result = DataPackUtils::processMCMeta(pack, std::move(data)); + + mcmeta_file.close(); + if (!mcmeta_result) { + return mcmeta_invalid(); // mcmeta invalid + } + } else { + return mcmeta_invalid(); // mcmeta file isn't a valid file + } + + if (level == ProcessingLevel::BasicInfoOnly) { + return true; // only need basic info already checked + } + auto png_invalid = [&pack]() { + qWarning() << "Data pack at" << pack->fileinfo().filePath() << "does not have a valid pack.png"; + return true; // the png is optional + }; + + QFileInfo image_file_info(FS::PathCombine(pack->fileinfo().filePath(), "pack.png")); + if (image_file_info.exists() && image_file_info.isFile()) { + QFile pack_png_file(image_file_info.filePath()); + if (!pack_png_file.open(QIODevice::ReadOnly)) + return png_invalid(); // can't open pack.png file + + auto data = pack_png_file.readAll(); + + bool pack_png_result = DataPackUtils::processPackPNG(pack, std::move(data)); + + pack_png_file.close(); + if (!pack_png_result) { + return png_invalid(); // pack.png invalid + } + } else { + return png_invalid(); // pack.png does not exists or is not a valid file. + } + + return true; // all tests passed +} + +bool processZIP(DataPack* pack, ProcessingLevel level) +{ + Q_ASSERT(pack->type() == ResourceType::ZIPFILE); + + MMCZip::ArchiveReader zip(pack->fileinfo().filePath()); + + bool metaParsed = false; + bool iconParsed = false; + bool mcmeta_result = false; + bool pack_png_result = false; + if (!zip.parse( + [&metaParsed, &iconParsed, &mcmeta_result, &pack_png_result, pack, level](MMCZip::ArchiveReader::File* f, bool& breakControl) { + bool skip = true; + if (!metaParsed && f->filename() == "pack.mcmeta") { + metaParsed = true; + skip = false; + auto data = f->readAll(); + + mcmeta_result = DataPackUtils::processMCMeta(pack, std::move(data)); + + if (!mcmeta_result) { + breakControl = true; + return true; // mcmeta invalid + } + } + if (!iconParsed && level != ProcessingLevel::BasicInfoOnly && f->filename() == "pack.png") { + iconParsed = true; + skip = false; + auto data = f->readAll(); + + pack_png_result = DataPackUtils::processPackPNG(pack, std::move(data)); + if (!pack_png_result) { + breakControl = true; + return true; // pack.png invalid + } + } + if (skip) { + f->skip(); + } + if (metaParsed && (level == ProcessingLevel::BasicInfoOnly || iconParsed)) { + breakControl = true; + } + + return true; + })) { + return false; // can't open zip file + } + if (!mcmeta_result) { + qWarning() << "Data pack at" << pack->fileinfo().filePath() << "does not have a valid pack.mcmeta"; + return false; // the mcmeta is not optional + } + + if (level == ProcessingLevel::BasicInfoOnly) { + return true; // only need basic info already checked + } + + if (!pack_png_result) { + qWarning() << "Data pack at" << pack->fileinfo().filePath() << "does not have a valid pack.png"; + return true; // the png is optional + } + + return true; +} + +std::pair parseVersion(const QJsonValue& value) +{ + if (value.isDouble()) { + // Single integer -> [major, 0] + return std::make_pair(value.toInt(), 0); + } + std::pair version; + if (value.isArray()) { + QJsonArray arr = value.toArray(); + if (arr.size() >= 1) { + version.first = arr.at(0).toInt(); + } + if (arr.size() >= 2) { + version.second = arr.at(1).toInt(); + } + } + return version; +} + +// https://minecraft.wiki/w/Data_pack#pack.mcmeta +// https://minecraft.wiki/w/Raw_JSON_text_format +// https://minecraft.wiki/w/Tutorials/Creating_a_resource_pack#Formatting_pack.mcmeta +bool processMCMeta(DataPack* pack, QByteArray&& raw_data) +{ + QJsonParseError parse_error; + auto json_doc = Json::parseUntilGarbage(raw_data, &parse_error); + if (parse_error.error != QJsonParseError::NoError) { + qWarning() << "Failed to parse JSON:" << parse_error.errorString(); + return false; + } + + try { + auto pack_obj = Json::requireObject(json_doc.object(), "pack", {}); + + int pack_format = 0; + std::pair min_format; + std::pair max_format; + if (pack_obj.contains("pack_format")) { + pack_format = pack_obj.value("pack_format").toInt(); + } + if (pack_obj.contains("min_format")) { + min_format = parseVersion(pack_obj.value("min_format")); + } + if (pack_obj.contains("max_format")) { + max_format = parseVersion(pack_obj.value("max_format")); + } + pack->setPackFormat(pack_format, min_format, max_format); + pack->setDescription(DataPackUtils::processComponent(pack_obj.value("description"))); + } catch (Json::JsonException& e) { + qWarning() << "JsonException:" << e.what() << e.cause(); + return false; + } + return true; +} + +QString buildStyle(const QJsonObject& obj) +{ + QStringList styles; + if (auto color = obj["color"].toString(); !color.isEmpty()) { + styles << QString("color: %1;").arg(color); + } + if (obj.contains("bold")) { + QString weight = "normal"; + if (obj["bold"].toBool()) { + weight = "bold"; + } + styles << QString("font-weight: %1;").arg(weight); + } + if (obj.contains("italic")) { + QString style = "normal"; + if (obj["italic"].toBool()) { + style = "italic"; + } + styles << QString("font-style: %1;").arg(style); + } + + return styles.isEmpty() ? "" : QString("style=\"%1\"").arg(styles.join(" ")); +} + +QString processComponent(const QJsonArray& value, bool strikethrough, bool underline) +{ + QString result; + for (auto current : value) + result += processComponent(current, strikethrough, underline); + return result; +} + +QString processComponent(const QJsonObject& obj, bool strikethrough, bool underline) +{ + underline = obj["underlined"].toBool(underline); + strikethrough = obj["strikethrough"].toBool(strikethrough); + + QString result = obj["text"].toString(); + if (underline) { + result = QString("%1").arg(result); + } + if (strikethrough) { + result = QString("%1").arg(result); + } + // the extra needs to be a array + result += processComponent(obj["extra"].toArray(), strikethrough, underline); + if (auto style = buildStyle(obj); !style.isEmpty()) { + result = QString("%2").arg(style, result); + } + if (obj.contains("clickEvent")) { + auto click_event = obj["clickEvent"].toObject(); + auto action = click_event["action"].toString(); + auto value = click_event["value"].toString(); + if (action == "open_url" && !value.isEmpty()) { + result = QString("%2").arg(value, result); + } + } + return result; +} + +QString processComponent(const QJsonValue& value, bool strikethrough, bool underline) +{ + if (value.isString()) { + return value.toString(); + } + if (value.isBool()) { + return value.toBool() ? "true" : "false"; + } + if (value.isDouble()) { + return QString::number(value.toDouble()); + } + if (value.isArray()) { + return processComponent(value.toArray(), strikethrough, underline); + } + if (value.isObject()) { + return processComponent(value.toObject(), strikethrough, underline); + } + qWarning() << "Invalid component type!"; + return {}; +} + +bool processPackPNG(const DataPack* pack, QByteArray&& raw_data) +{ + auto img = QImage::fromData(raw_data); + if (!img.isNull()) { + pack->setImage(img); + } else { + qWarning() << "Failed to parse pack.png."; + return false; + } + return true; +} + +bool processPackPNG(const DataPack* pack) +{ + auto png_invalid = [&pack]() { + qWarning() << "Data pack at" << pack->fileinfo().filePath() << "does not have a valid pack.png"; + return false; + }; + + switch (pack->type()) { + case ResourceType::FOLDER: { + QFileInfo image_file_info(FS::PathCombine(pack->fileinfo().filePath(), "pack.png")); + if (image_file_info.exists() && image_file_info.isFile()) { + QFile pack_png_file(image_file_info.filePath()); + if (!pack_png_file.open(QIODevice::ReadOnly)) + return png_invalid(); // can't open pack.png file + + auto data = pack_png_file.readAll(); + + bool pack_png_result = DataPackUtils::processPackPNG(pack, std::move(data)); + + pack_png_file.close(); + if (!pack_png_result) { + return png_invalid(); // pack.png invalid + } + } else { + return png_invalid(); // pack.png does not exists or is not a valid file. + } + return false; // not processed correctly; https://github.com/PrismLauncher/PrismLauncher/issues/1740 + } + case ResourceType::ZIPFILE: { + MMCZip::ArchiveReader zip(pack->fileinfo().filePath()); + auto f = zip.goToFile("pack.png"); + if (!f) { + return png_invalid(); + } + auto data = f->readAll(); + + bool pack_png_result = DataPackUtils::processPackPNG(pack, std::move(data)); + + if (!pack_png_result) { + return png_invalid(); // pack.png invalid + } + return false; // not processed correctly; https://github.com/PrismLauncher/PrismLauncher/issues/1740 + } + default: + qWarning() << "Invalid type for data pack parse task!"; + return false; + } +} + +bool validate(QFileInfo file) +{ + DataPack dp{ file }; + return DataPackUtils::process(&dp, ProcessingLevel::BasicInfoOnly) && dp.valid(); +} + +bool validateResourcePack(QFileInfo file) +{ + ResourcePack rp{ file }; + return DataPackUtils::process(&rp, ProcessingLevel::BasicInfoOnly) && rp.valid(); +} + +} // namespace DataPackUtils + +LocalDataPackParseTask::LocalDataPackParseTask(int token, DataPack* dp) : Task(false), m_token(token), m_data_pack(dp) {} + +void LocalDataPackParseTask::executeTask() +{ + if (!DataPackUtils::process(m_data_pack)) { + emitFailed("process failed"); + return; + } + + emitSucceeded(); +} diff --git a/launcher/minecraft/mod/tasks/LocalDataPackParseTask.h b/launcher/minecraft/mod/tasks/LocalDataPackParseTask.h new file mode 100644 index 0000000..82bb507 --- /dev/null +++ b/launcher/minecraft/mod/tasks/LocalDataPackParseTask.h @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> +// +// SPDX-License-Identifier: GPL-3.0-only + +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include + +#include "minecraft/mod/DataPack.h" + +#include "tasks/Task.h" + +namespace DataPackUtils { + +enum class ProcessingLevel { Full, BasicInfoOnly }; + +bool process(DataPack* pack, ProcessingLevel level = ProcessingLevel::Full); + +bool processZIP(DataPack* pack, ProcessingLevel level = ProcessingLevel::Full); +bool processFolder(DataPack* pack, ProcessingLevel level = ProcessingLevel::Full); + +bool processMCMeta(DataPack* pack, QByteArray&& raw_data); + +QString processComponent(const QJsonValue& value, bool strikethrough = false, bool underline = false); + +bool processPackPNG(const DataPack* pack, QByteArray&& raw_data); + +/// processes ONLY the pack.png (rest of the pack may be invalid) +bool processPackPNG(const DataPack* pack); + +/** Checks whether a file is valid as a data pack or not. */ +bool validate(QFileInfo file); + +/** Checks whether a file is valid as a resource pack or not. */ +bool validateResourcePack(QFileInfo file); + +} // namespace DataPackUtils + +class LocalDataPackParseTask : public Task { + Q_OBJECT + public: + LocalDataPackParseTask(int token, DataPack* dp); + + void executeTask() override; + + int token() const { return m_token; } + + private: + int m_token; + + DataPack* m_data_pack; +}; diff --git a/launcher/minecraft/mod/tasks/LocalModParseTask.cpp b/launcher/minecraft/mod/tasks/LocalModParseTask.cpp new file mode 100644 index 0000000..74c3116 --- /dev/null +++ b/launcher/minecraft/mod/tasks/LocalModParseTask.cpp @@ -0,0 +1,816 @@ +#include "LocalModParseTask.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "FileSystem.h" +#include "Json.h" +#include "archive/ArchiveReader.h" +#include "minecraft/mod/ModDetails.h" +#include "settings/INIFile.h" + +static const QRegularExpression s_newlineRegex("\r\n|\n|\r"); + +namespace ModUtils { + +// NEW format +// https://github.com/MinecraftForge/FML/wiki/FML-mod-information-file/c8d8f1929aff9979e322af79a59ce81f3e02db6a + +// OLD format: +// https://github.com/MinecraftForge/FML/wiki/FML-mod-information-file/5bf6a2d05145ec79387acc0d45c958642fb049fc +ModDetails ReadMCModInfo(QByteArray contents) +{ + auto getInfoFromArray = [](QJsonArray arr) -> ModDetails { + if (!arr.at(0).isObject()) { + return {}; + } + ModDetails details; + auto firstObj = arr.at(0).toObject(); + details.mod_id = firstObj.value("modid").toString(); + auto name = firstObj.value("name").toString(); + // NOTE: ignore stupid example mods copies where the author didn't even bother to change the name + if (name != "Example Mod") { + details.name = name; + } + details.version = firstObj.value("version").toString(); + auto homeurl = firstObj.value("url").toString().trimmed(); + if (!homeurl.isEmpty()) { + // fix up url. + if (!homeurl.startsWith("http://") && !homeurl.startsWith("https://") && !homeurl.startsWith("ftp://")) { + homeurl.prepend("http://"); + } + } + details.homeurl = homeurl; + details.description = firstObj.value("description").toString(); + QJsonArray authors = firstObj.value("authorList").toArray(); + if (authors.size() == 0) { + // FIXME: what is the format of this? is there any? + authors = firstObj.value("authors").toArray(); + } + + if (firstObj.contains("logoFile")) { + details.icon_file = firstObj.value("logoFile").toString(); + } + + for (auto author : authors) { + details.authors.append(author.toString()); + } + + if (details.mod_id.startsWith("mod_")) { + details.mod_id = details.mod_id.mid(4); + } + + auto addDep = [&details](QString dep) { + if (dep == "mod_MinecraftForge" || dep == "Forge") + return; + if (dep.contains(":")) { + dep = dep.section(":", 1); + } + if (dep.contains("@")) { + dep = dep.section("@", 0, 0); + } + if (dep.startsWith("mod_")) { + dep = dep.mid(4); + } + details.dependencies.append(dep); + }; + + if (firstObj.contains("requiredMods")) { + for (auto dep : firstObj.value("requiredMods").toArray()) { + addDep(dep.toString()); + } + } else if (firstObj.contains("dependencies")) { + for (auto dep : firstObj.value("dependencies").toArray()) { + addDep(dep.toString()); + } + } + + return details; + }; + QJsonParseError jsonError; + QJsonDocument jsonDoc = QJsonDocument::fromJson(contents, &jsonError); + // this is the very old format that had just the array + if (jsonDoc.isArray()) { + return getInfoFromArray(jsonDoc.array()); + } else if (jsonDoc.isObject()) { + auto val = jsonDoc.object().value("modinfoversion"); + if (val.isUndefined()) { + val = jsonDoc.object().value("modListVersion"); + } + + int version = val.toInt(-1); + + // Some mods set the number with "", so it's a String instead + if (version < 0) + version = val.toString("").toInt(); + + if (version != 2) { + qWarning() << QString(R"(The value of 'modListVersion' is "%1" (expected "2")! The file may be corrupted.)").arg(version); + qWarning() << "The contents of 'mcmod.info' are as follows:"; + qWarning() << contents; + } + + auto arrVal = jsonDoc.object().value("modlist"); + if (arrVal.isUndefined()) { + arrVal = jsonDoc.object().value("modList"); + } + if (arrVal.isArray()) { + return getInfoFromArray(arrVal.toArray()); + } + } + return {}; +} + +// https://github.com/MinecraftForge/Documentation/blob/5ab4ba6cf9abc0ac4c0abd96ad187461aefd72af/docs/gettingstarted/structuring.md +ModDetails ReadMCModTOML(QByteArray contents) +{ + ModDetails details; + + toml::table tomlData; +#if TOML_EXCEPTIONS + try { + tomlData = toml::parse(contents.toStdString()); + } catch ([[maybe_unused]] const toml::parse_error& err) { + return {}; + } +#else + toml::parse_result result = toml::parse(contents.toStdString()); + if (!result) { + return {}; + } + tomlData = result.table(); +#endif + + // array defined by [[mods]] + auto tomlModsArr = tomlData["mods"].as_array(); + if (!tomlModsArr) { + qWarning() << "Corrupted mods.toml? Couldn't find [[mods]] array!"; + return {}; + } + + // we only really care about the first element, since multiple mods in one file is not supported by us at the moment + auto tomlModsTable0 = tomlModsArr->get(0); + if (!tomlModsTable0) { + qWarning() << "Corrupted mods.toml? [[mods]] didn't have an element at index 0!"; + return {}; + } + auto modsTable = tomlModsTable0->as_table(); + if (!modsTable) { + qWarning() << "Corrupted mods.toml? [[mods]] was not a table!"; + return {}; + } + + // mandatory properties - always in [[mods]] + if (auto modIdDatum = (*modsTable)["modId"].as_string()) { + details.mod_id = QString::fromStdString(modIdDatum->get()); + } + if (auto versionDatum = (*modsTable)["version"].as_string()) { + details.version = QString::fromStdString(versionDatum->get()); + } + if (auto displayNameDatum = (*modsTable)["displayName"].as_string()) { + details.name = QString::fromStdString(displayNameDatum->get()); + } + if (auto descriptionDatum = (*modsTable)["description"].as_string()) { + details.description = QString::fromStdString(descriptionDatum->get()); + } + + // optional properties - can be in the root table or [[mods]] + QString authors = ""; + if (auto authorsDatum = tomlData["authors"].as_string()) { + authors = QString::fromStdString(authorsDatum->get()); + } else if (auto authorsDatumMods = (*modsTable)["authors"].as_string()) { + authors = QString::fromStdString(authorsDatumMods->get()); + } + if (!authors.isEmpty()) { + details.authors.append(authors); + } + + QString homeurl = ""; + if (auto homeurlDatum = tomlData["displayURL"].as_string()) { + homeurl = QString::fromStdString(homeurlDatum->get()); + } else if (auto homeurlDatumMods = (*modsTable)["displayURL"].as_string()) { + homeurl = QString::fromStdString(homeurlDatumMods->get()); + } + // fix up url. + if (!homeurl.isEmpty() && !homeurl.startsWith("http://") && !homeurl.startsWith("https://") && !homeurl.startsWith("ftp://")) { + homeurl.prepend("http://"); + } + details.homeurl = homeurl; + + QString issueTrackerURL = ""; + if (auto issueTrackerURLDatum = tomlData["issueTrackerURL"].as_string()) { + issueTrackerURL = QString::fromStdString(issueTrackerURLDatum->get()); + } else if (auto issueTrackerURLDatumMods = (*modsTable)["issueTrackerURL"].as_string()) { + issueTrackerURL = QString::fromStdString(issueTrackerURLDatumMods->get()); + } + details.issue_tracker = issueTrackerURL; + + QString license = ""; + if (auto licenseDatum = tomlData["license"].as_string()) { + license = QString::fromStdString(licenseDatum->get()); + } else if (auto licenseDatumMods = (*modsTable)["license"].as_string()) { + license = QString::fromStdString(licenseDatumMods->get()); + } + if (!license.isEmpty()) + details.licenses.append(ModLicense(license)); + + QString logoFile = ""; + if (auto logoFileDatum = tomlData["logoFile"].as_string()) { + logoFile = QString::fromStdString(logoFileDatum->get()); + } else if (auto logoFileDatumMods = (*modsTable)["logoFile"].as_string()) { + logoFile = QString::fromStdString(logoFileDatumMods->get()); + } + details.icon_file = logoFile; + + auto parseDep = [&details](toml::array* dependencies) { + static const QStringList ignoreModIds = { "", "forge", "neoforge", "minecraft" }; + if (!dependencies) { + return; + } + auto isNeoForgeDep = [](toml::table* t) { + auto type = (*t)["type"].as_string(); + return type && type->get() == "required"; + }; + auto isForgeDep = [](toml::table* t) { + auto mandatory = (*t)["mandatory"].as_boolean(); + return mandatory && mandatory->get(); + }; + for (auto& dep : *dependencies) { + auto dep_table = dep.as_table(); + if (!dep_table) { + continue; + } + auto modId = (*dep_table)["modId"].as_string(); + if (!modId || ignoreModIds.contains(QString::fromStdString(modId->get()))) { + continue; + } + if (isNeoForgeDep(dep_table) || isForgeDep(dep_table)) { + details.dependencies.append(QString::fromStdString(modId->get())); + } + } + }; + + if (tomlData.contains("dependencies")) { + auto depValue = tomlData["dependencies"]; + if (auto array = depValue.as_array()) { + parseDep(array); + } else if (auto depTable = depValue.as_table()) { + auto expectedKey = details.mod_id.toStdString(); + if (!depTable->contains(expectedKey)) { + if (auto it = depTable->begin(); it != depTable->end()) { + expectedKey = it->first; + } + } + if ((array = (*depTable)[expectedKey].as_array())) { + parseDep(array); + } + } + } + + return details; +} + +// https://fabricmc.net/wiki/documentation:fabric_mod_json +ModDetails ReadFabricModInfo(QByteArray contents) +{ + QJsonParseError jsonError; + QJsonDocument jsonDoc = QJsonDocument::fromJson(contents, &jsonError); + auto object = jsonDoc.object(); + auto schemaVersion = object.contains("schemaVersion") ? object.value("schemaVersion").toInt(0) : 0; + + ModDetails details; + + details.mod_id = object.value("id").toString(); + details.version = object.value("version").toString(); + + details.name = object.contains("name") ? object.value("name").toString() : details.mod_id; + details.description = object.value("description").toString(); + + if (schemaVersion >= 1) { + QJsonArray authors = object.value("authors").toArray(); + for (auto author : authors) { + if (author.isObject()) { + details.authors.append(author.toObject().value("name").toString()); + } else { + details.authors.append(author.toString()); + } + } + + if (object.contains("contact")) { + QJsonObject contact = object.value("contact").toObject(); + + if (contact.contains("homepage")) { + details.homeurl = contact.value("homepage").toString(); + } + if (contact.contains("issues")) { + details.issue_tracker = contact.value("issues").toString(); + } + } + + if (object.contains("license")) { + auto license = object.value("license"); + if (license.isArray()) { + for (auto l : license.toArray()) { + if (l.isString()) { + details.licenses.append(ModLicense(l.toString())); + } else if (l.isObject()) { + auto obj = l.toObject(); + details.licenses.append(ModLicense(obj.value("name").toString(), obj.value("id").toString(), + obj.value("url").toString(), obj.value("description").toString())); + } + } + } else if (license.isString()) { + details.licenses.append(ModLicense(license.toString())); + } else if (license.isObject()) { + auto obj = license.toObject(); + details.licenses.append(ModLicense(obj.value("name").toString(), obj.value("id").toString(), obj.value("url").toString(), + obj.value("description").toString())); + } + } + + if (object.contains("icon")) { + auto icon = object.value("icon"); + if (icon.isObject()) { + auto obj = icon.toObject(); + // take the largest icon + int largest = 0; + for (auto key : obj.keys()) { + auto size = key.split('x').first().toInt(); + if (size > largest) { + largest = size; + } + } + if (largest > 0) { + auto key = QString::number(largest) + "x" + QString::number(largest); + details.icon_file = obj.value(key).toString(); + } else { // parsing the sizes failed + // take the first + if (auto it = obj.begin(); it != obj.end()) { + details.icon_file = it->toString(); + } + } + } else if (icon.isString()) { + details.icon_file = icon.toString(); + } + } + + if (object.contains("depends")) { + auto depends = object.value("depends"); + if (depends.isObject()) { + auto obj = depends.toObject(); + for (auto key : obj.keys()) { + if (key != "fabricloader" && key != "minecraft" && !key.startsWith("fabric-")) { + details.dependencies.append(key); + } + } + } + } + } + return details; +} + +// https://github.com/QuiltMC/rfcs/blob/master/specification/0002-quilt.mod.json.md +ModDetails ReadQuiltModInfo(QByteArray contents) +{ + ModDetails details; + try { + QJsonParseError jsonError; + QJsonDocument jsonDoc = QJsonDocument::fromJson(contents, &jsonError); + auto object = Json::requireObject(jsonDoc, "quilt.mod.json"); + auto schemaVersion = object.value("schema_version").toInt(); + + // https://github.com/QuiltMC/rfcs/blob/be6ba280d785395fefa90a43db48e5bfc1d15eb4/specification/0002-quilt.mod.json.md + if (schemaVersion == 1) { + auto modInfo = Json::requireObject(object.value("quilt_loader"), "Quilt mod info"); + + details.mod_id = Json::requireString(modInfo.value("id"), "Mod ID"); + details.version = Json::requireString(modInfo.value("version"), "Mod version"); + + auto modMetadata = modInfo.value("metadata").toObject(); + + details.name = modMetadata.value("name").toString(details.mod_id); + details.description = modMetadata.value("description").toString(); + + auto modContributors = modMetadata.value("contributors").toObject(); + + // We don't really care about the role of a contributor here + details.authors += modContributors.keys(); + + auto modContact = modMetadata.value("contact").toObject(); + + if (modContact.contains("homepage")) { + details.homeurl = Json::requireString(modContact.value("homepage")); + } + if (modContact.contains("issues")) { + details.issue_tracker = Json::requireString(modContact.value("issues")); + } + + if (modMetadata.contains("license")) { + auto license = modMetadata.value("license"); + if (license.isArray()) { + for (auto l : license.toArray()) { + if (l.isString()) { + details.licenses.append(ModLicense(l.toString())); + } else if (l.isObject()) { + auto obj = l.toObject(); + details.licenses.append(ModLicense(obj.value("name").toString(), obj.value("id").toString(), + obj.value("url").toString(), obj.value("description").toString())); + } + } + } else if (license.isString()) { + details.licenses.append(ModLicense(license.toString())); + } else if (license.isObject()) { + auto obj = license.toObject(); + details.licenses.append(ModLicense(obj.value("name").toString(), obj.value("id").toString(), + obj.value("url").toString(), obj.value("description").toString())); + } + } + + if (modMetadata.contains("icon")) { + auto icon = modMetadata.value("icon"); + if (icon.isObject()) { + auto obj = icon.toObject(); + // take the largest icon + int largest = 0; + for (auto key : obj.keys()) { + auto size = key.split('x').first().toInt(); + if (size > largest) { + largest = size; + } + } + if (largest > 0) { + auto key = QString::number(largest) + "x" + QString::number(largest); + details.icon_file = obj.value(key).toString(); + } else { // parsing the sizes failed + // take the first + if (auto it = obj.begin(); it != obj.end()) { + details.icon_file = it->toString(); + } + } + } else if (icon.isString()) { + details.icon_file = icon.toString(); + } + } + if (object.contains("depends")) { + auto depends = object.value("depends"); + if (depends.isArray()) { + auto array = depends.toArray(); + for (auto obj : array) { + QString modId; + if (obj.isString()) { + modId = obj.toString(); + } else if (obj.isObject()) { + auto objValue = obj.toObject(); + modId = objValue.value("id").toString(); + if (objValue.contains("optional") && objValue.value("optional").toBool()) { + continue; + } + } else { + continue; + } + if (modId != "minecraft" && !modId.startsWith("quilt_")) { + details.dependencies.append(modId); + } + } + } + } + } + + } catch (const Exception& e) { + qWarning() << "Unable to parse mod info:" << e.cause(); + } + return details; +} + +ModDetails ReadForgeInfo(QByteArray contents) +{ + ModDetails details; + // Read the data + details.name = "Minecraft Forge"; + details.mod_id = "Forge"; + details.homeurl = "http://www.minecraftforge.net/forum/"; + INIFile ini; + if (!ini.loadFile(contents)) + return details; + + QString major = ini.get("forge.major.number", "0").toString(); + QString minor = ini.get("forge.minor.number", "0").toString(); + QString revision = ini.get("forge.revision.number", "0").toString(); + QString build = ini.get("forge.build.number", "0").toString(); + + details.version = major + "." + minor + "." + revision + "." + build; + return details; +} + +ModDetails ReadLiteModInfo(QByteArray contents) +{ + ModDetails details; + QJsonParseError jsonError; + QJsonDocument jsonDoc = QJsonDocument::fromJson(contents, &jsonError); + auto object = jsonDoc.object(); + if (object.contains("name")) { + details.mod_id = details.name = object.value("name").toString(); + } + if (object.contains("version")) { + details.version = object.value("version").toString(""); + } else { + details.version = object.value("revision").toString(""); + } + details.mcversion = object.value("mcversion").toString(); + auto author = object.value("author").toString(); + if (!author.isEmpty()) { + details.authors.append(author); + } + details.description = object.value("description").toString(); + details.homeurl = object.value("url").toString(); + return details; +} + +// https://git.sleeping.town/unascribed/NilLoader/src/commit/d7fc87b255fc31019ff90f80d45894927fac6efc/src/main/java/nilloader/api/NilMetadata.java#L64 +ModDetails ReadNilModInfo(QByteArray contents, QString fname) +{ + ModDetails details; + + QDCSS cssData = QDCSS(contents); + auto name = cssData.get("@nilmod.name"); + auto desc = cssData.get("@nilmod.description"); + auto authors = cssData.get("@nilmod.authors"); + + if (name->has_value()) { + details.name = name->value(); + } + if (desc->has_value()) { + details.description = desc->value(); + } + if (authors->has_value()) { + details.authors.append(authors->value()); + } + details.version = cssData.get("@nilmod.version")->value_or("?"); + + details.mod_id = fname.remove(".nilmod.css"); + + return details; +} + +bool process(Mod& mod, ProcessingLevel level) +{ + switch (mod.type()) { + case ResourceType::FOLDER: + return processFolder(mod, level); + case ResourceType::ZIPFILE: + return processZIP(mod, level); + case ResourceType::LITEMOD: + return processLitemod(mod); + default: + qWarning() << "Invalid type" << mod.type() << "for mod parse task!"; + return false; + } +} + +bool processZIP(Mod& mod, [[maybe_unused]] ProcessingLevel level) +{ + ModDetails details; + + MMCZip::ArchiveReader zip(mod.fileinfo().filePath()); + + bool baseForgePopulated = false; + bool isNilMod = false; + bool isValid = false; + QString manifestVersion = {}; + QByteArray nilData = {}; + QString nilFilePath = {}; + + if (!zip.parse([&details, &baseForgePopulated, &manifestVersion, &isValid, &nilData, &isNilMod, &nilFilePath]( + MMCZip::ArchiveReader::File* file, bool& stop) { + auto filePath = file->filename(); + + if (filePath == "META-INF/mods.toml" || filePath == "META-INF/neoforge.mods.toml") { + details = ReadMCModTOML(file->readAll()); + isValid = true; + if (details.version == "${file.jarVersion}" && !manifestVersion.isEmpty()) { + details.version = manifestVersion; + } + stop = details.version != "${file.jarVersion}"; + baseForgePopulated = true; + return true; + } + if (filePath == "META-INF/MANIFEST.MF") { + // quick and dirty line-by-line parser + auto manifestLines = QString(file->readAll()).split(s_newlineRegex); + manifestVersion = ""; + for (auto& line : manifestLines) { + if (line.startsWith("Implementation-Version: ", Qt::CaseInsensitive)) { + manifestVersion = line.remove("Implementation-Version: ", Qt::CaseInsensitive); + break; + } + } + + // some mods use ${projectversion} in their build.gradle, causing this mess to show up in MANIFEST.MF + // also keep with forge's behavior of setting the version to "NONE" if none is found + if (manifestVersion.contains("task ':jar' property 'archiveVersion'") || manifestVersion == "") { + manifestVersion = "NONE"; + } + if (baseForgePopulated) { + details.version = manifestVersion; + stop = true; + } + return true; + } + if (filePath == "mcmod.info") { + details = ReadMCModInfo(file->readAll()); + isValid = true; + stop = true; + return true; + } + if (filePath == "quilt.mod.json") { + details = ReadQuiltModInfo(file->readAll()); + isValid = true; + stop = true; + return true; + } + if (filePath == "fabric.mod.json") { + details = ReadFabricModInfo(file->readAll()); + isValid = true; + stop = true; + return true; + } + if (filePath == "forgeversion.properties") { + details = ReadForgeInfo(file->readAll()); + isValid = true; + stop = true; + return true; + } + if (filePath == "META-INF/nil/mappings.json") { + // nilloader uses the filename of the metadata file for the modid, so we can't know the exact filename + // thankfully, there is a good file to use as a canary so we don't look for nil meta all the time + isNilMod = true; + stop = !nilFilePath.isEmpty(); + file->skip(); + return true; + } + // nilmods can shade nilloader to be able to run as a standalone agent - which includes nilloader's own meta file + if (filePath.endsWith(".nilmod.css") && filePath != "nilloader.nilmod.css") { + nilData = file->readAll(); + nilFilePath = filePath; + stop = isNilMod; + return true; + } + file->skip(); + return true; + })) { + return false; + } + if (isNilMod) { + details = ReadNilModInfo(nilData, nilFilePath); + isValid = true; + } + if (isValid) { + mod.setDetails(details); + return true; + } + return false; // no valid mod found in archive +} + +bool processFolder(Mod& mod, [[maybe_unused]] ProcessingLevel level) +{ + ModDetails details; + + QFileInfo mcmod_info(FS::PathCombine(mod.fileinfo().filePath(), "mcmod.info")); + if (mcmod_info.exists() && mcmod_info.isFile()) { + QFile mcmod(mcmod_info.filePath()); + if (!mcmod.open(QIODevice::ReadOnly)) + return false; + auto data = mcmod.readAll(); + if (data.isEmpty() || data.isNull()) + return false; + details = ReadMCModInfo(data); + + mod.setDetails(details); + return true; + } + + return false; // no valid mcmod.info file found +} + +bool processLitemod(Mod& mod, [[maybe_unused]] ProcessingLevel level) +{ + ModDetails details; + + MMCZip::ArchiveReader zip(mod.fileinfo().filePath()); + + if (auto file = zip.goToFile("litemod.json"); file) { + details = ReadLiteModInfo(file->readAll()); + + mod.setDetails(details); + return true; + } + + return false; // no valid litemod.json found in archive +} + +/** Checks whether a file is valid as a mod or not. */ +bool validate(QFileInfo file) +{ + Mod mod{ file }; + return ModUtils::process(mod, ProcessingLevel::BasicInfoOnly) && mod.valid(); +} + +bool processIconPNG(const Mod& mod, QByteArray&& raw_data, QPixmap* pixmap) +{ + auto img = QImage::fromData(raw_data); + if (!img.isNull()) { + *pixmap = mod.setIcon(img); + } else { + qWarning() << "Failed to parse mod logo:" << mod.iconPath() << "from" << mod.name(); + return false; + } + return true; +} + +bool loadIconFile(const Mod& mod, QPixmap* pixmap) +{ + if (mod.iconPath().isEmpty()) { + qWarning() << "No Iconfile set, be sure to parse the mod first"; + return false; + } + + auto png_invalid = [&mod](const QString& reason) { + qWarning() << "Mod at" << mod.fileinfo().filePath() << "does not have a valid icon:" << reason; + return false; + }; + + switch (mod.type()) { + case ResourceType::FOLDER: { + QFileInfo icon_info(FS::PathCombine(mod.fileinfo().filePath(), mod.iconPath())); + if (icon_info.exists() && icon_info.isFile()) { + QFile icon(icon_info.filePath()); + if (!icon.open(QIODevice::ReadOnly)) { + return png_invalid("failed to open file " + icon_info.filePath() + " " + icon.errorString()); + } + auto data = icon.readAll(); + + bool icon_result = ModUtils::processIconPNG(mod, std::move(data), pixmap); + + icon.close(); + + if (!icon_result) { + return png_invalid("invalid png image"); // icon invalid + } + return true; + } + return png_invalid("file '" + icon_info.filePath() + "' does not exists or is not a file"); + } + case ResourceType::ZIPFILE: { + MMCZip::ArchiveReader zip(mod.fileinfo().filePath()); + auto file = zip.goToFile(mod.iconPath()); + if (file) { + auto data = file->readAll(); + + bool icon_result = ModUtils::processIconPNG(mod, std::move(data), pixmap); + + if (!icon_result) { + return png_invalid("invalid png image"); // icon png invalid + } + return true; + } + return png_invalid("Failed to set '" + mod.iconPath() + + "' as current file in zip archive"); // could not set icon as current file. + } + case ResourceType::LITEMOD: { + return png_invalid("litemods do not have icons"); // can lightmods even have icons? + } + default: + return png_invalid("Invalid type for mod, can not load icon."); + } +} + +} // namespace ModUtils + +LocalModParseTask::LocalModParseTask(int token, ResourceType type, const QFileInfo& modFile) + : Task(false), m_token(token), m_type(type), m_modFile(modFile), m_result(new Result()) +{} + +bool LocalModParseTask::abort() +{ + m_aborted.store(true); + return true; +} + +void LocalModParseTask::executeTask() +{ + Mod mod{ m_modFile }; + ModUtils::process(mod, ModUtils::ProcessingLevel::Full); + + m_result->details = mod.details(); + + if (m_aborted) + emitAborted(); + else + emitSucceeded(); +} diff --git a/launcher/minecraft/mod/tasks/LocalModParseTask.h b/launcher/minecraft/mod/tasks/LocalModParseTask.h new file mode 100644 index 0000000..cbe0093 --- /dev/null +++ b/launcher/minecraft/mod/tasks/LocalModParseTask.h @@ -0,0 +1,57 @@ +#pragma once + +#include +#include + +#include "minecraft/mod/Mod.h" +#include "minecraft/mod/ModDetails.h" + +#include "tasks/Task.h" + +namespace ModUtils { + +ModDetails ReadFabricModInfo(QByteArray contents); +ModDetails ReadQuiltModInfo(QByteArray contents); +ModDetails ReadForgeInfo(QByteArray contents); +ModDetails ReadLiteModInfo(QByteArray contents); + +enum class ProcessingLevel { Full, BasicInfoOnly }; + +bool process(Mod& mod, ProcessingLevel level = ProcessingLevel::Full); + +bool processZIP(Mod& mod, ProcessingLevel level = ProcessingLevel::Full); +bool processFolder(Mod& mod, ProcessingLevel level = ProcessingLevel::Full); +bool processLitemod(Mod& mod, ProcessingLevel level = ProcessingLevel::Full); + +/** Checks whether a file is valid as a mod or not. */ +bool validate(QFileInfo file); + +bool processIconPNG(const Mod& mod, QByteArray&& raw_data, QPixmap* pixmap); +bool loadIconFile(const Mod& mod, QPixmap* pixmap); +} // namespace ModUtils + +class LocalModParseTask : public Task { + Q_OBJECT + public: + struct Result { + ModDetails details; + }; + using ResultPtr = std::shared_ptr; + ResultPtr result() const { return m_result; } + + bool canAbort() const override { return true; } + bool abort() override; + + LocalModParseTask(int token, ResourceType type, const QFileInfo& modFile); + void executeTask() override; + + int token() const { return m_token; } + + private: + int m_token; + ResourceType m_type; + QFileInfo m_modFile; + ResultPtr m_result; + + std::atomic m_aborted = false; +}; diff --git a/launcher/minecraft/mod/tasks/LocalResourceParse.cpp b/launcher/minecraft/mod/tasks/LocalResourceParse.cpp new file mode 100644 index 0000000..39e8a32 --- /dev/null +++ b/launcher/minecraft/mod/tasks/LocalResourceParse.cpp @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> +// +// SPDX-License-Identifier: GPL-3.0-only + +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include + +#include "LocalResourceParse.h" + +#include "LocalDataPackParseTask.h" +#include "LocalModParseTask.h" +#include "LocalShaderPackParseTask.h" +#include "LocalTexturePackParseTask.h" +#include "LocalWorldSaveParseTask.h" +#include "modplatform/ResourceType.h" + +namespace ResourceUtils { +ModPlatform::ResourceType identify(QFileInfo file) +{ + if (file.exists() && file.isFile()) { + if (ModUtils::validate(file)) { + // mods can contain resource and data packs so they must be tested first + qDebug() << file.fileName() << "is a mod"; + return ModPlatform::ResourceType::Mod; + } else if (DataPackUtils::validateResourcePack(file)) { + qDebug() << file.fileName() << "is a resource pack"; + return ModPlatform::ResourceType::ResourcePack; + } else if (TexturePackUtils::validate(file)) { + qDebug() << file.fileName() << "is a pre 1.6 texture pack"; + return ModPlatform::ResourceType::TexturePack; + } else if (DataPackUtils::validate(file)) { + qDebug() << file.fileName() << "is a data pack"; + return ModPlatform::ResourceType::DataPack; + } else if (WorldSaveUtils::validate(file)) { + qDebug() << file.fileName() << "is a world save"; + return ModPlatform::ResourceType::World; + } else if (ShaderPackUtils::validate(file)) { + qDebug() << file.fileName() << "is a shader pack"; + return ModPlatform::ResourceType::ShaderPack; + } else { + qDebug() << "Can't Identify" << file.fileName(); + } + } else { + qDebug() << "Can't find" << file.absolutePath(); + } + return ModPlatform::ResourceType::Unknown; +} + +} // namespace ResourceUtils diff --git a/launcher/minecraft/mod/tasks/LocalResourceParse.h b/launcher/minecraft/mod/tasks/LocalResourceParse.h new file mode 100644 index 0000000..dc3aeb0 --- /dev/null +++ b/launcher/minecraft/mod/tasks/LocalResourceParse.h @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> +// +// SPDX-License-Identifier: GPL-3.0-only + +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include "modplatform/ResourceType.h" + +namespace ResourceUtils { +ModPlatform::ResourceType identify(QFileInfo file); +} // namespace ResourceUtils diff --git a/launcher/minecraft/mod/tasks/LocalResourceUpdateTask.cpp b/launcher/minecraft/mod/tasks/LocalResourceUpdateTask.cpp new file mode 100644 index 0000000..8c2d6f0 --- /dev/null +++ b/launcher/minecraft/mod/tasks/LocalResourceUpdateTask.cpp @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "LocalResourceUpdateTask.h" + +#include "FileSystem.h" +#include "minecraft/mod/MetadataHandler.h" + +#ifdef Q_OS_WIN32 +#include +#endif + +LocalResourceUpdateTask::LocalResourceUpdateTask(QDir index_dir, ModPlatform::IndexedPack& project, ModPlatform::IndexedVersion& version) + : m_index_dir(index_dir), m_project(project), m_version(version) +{ + // Ensure a '.index' folder exists in the mods folder, and create it if it does not + if (!FS::ensureFolderPathExists(index_dir.path())) { + emitFailed(QString("Unable to create index directory at %1!").arg(index_dir.absolutePath())); + return; + } + +#ifdef Q_OS_WIN32 + std::wstring wpath = index_dir.path().toStdWString(); + if (index_dir.dirName().startsWith('.')) { + SetFileAttributesW(wpath.c_str(), FILE_ATTRIBUTE_HIDDEN | FILE_ATTRIBUTE_NOT_CONTENT_INDEXED); + } else { + // fix shaderpacks folder being hidden by Prism Launcher 10.0.1 + SetFileAttributesW(wpath.c_str(), FILE_ATTRIBUTE_NORMAL); + } +#endif +} + +void LocalResourceUpdateTask::executeTask() +{ + setStatus(tr("Updating index for resource:\n%1").arg(m_project.name)); + + auto old_metadata = Metadata::get(m_index_dir, m_project.addonId); + if (old_metadata.isValid()) { + emit hasOldResource(old_metadata.name, old_metadata.filename); + if (m_project.slug.isEmpty()) + m_project.slug = old_metadata.slug; + } + + auto pw_mod = Metadata::create(m_index_dir, m_project, m_version); + if (pw_mod.isValid()) { + Metadata::update(m_index_dir, pw_mod); + emitSucceeded(); + } else { + qCritical() << "Tried to update an invalid resource!"; + emitFailed(tr("Invalid metadata")); + } +} + +auto LocalResourceUpdateTask::abort() -> bool +{ + emitAborted(); + return true; +} diff --git a/launcher/minecraft/mod/tasks/LocalResourceUpdateTask.h b/launcher/minecraft/mod/tasks/LocalResourceUpdateTask.h new file mode 100644 index 0000000..f886925 --- /dev/null +++ b/launcher/minecraft/mod/tasks/LocalResourceUpdateTask.h @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include + +#include "modplatform/ModIndex.h" +#include "tasks/Task.h" + +class LocalResourceUpdateTask : public Task { + Q_OBJECT + public: + using Ptr = shared_qobject_ptr; + + explicit LocalResourceUpdateTask(QDir index_dir, ModPlatform::IndexedPack& project, ModPlatform::IndexedVersion& version); + + auto canAbort() const -> bool override { return true; } + auto abort() -> bool override; + + protected slots: + //! Entry point for tasks. + void executeTask() override; + + signals: + void hasOldResource(QString name, QString filename); + + private: + QDir m_index_dir; + ModPlatform::IndexedPack m_project; + ModPlatform::IndexedVersion m_version; +}; diff --git a/launcher/minecraft/mod/tasks/LocalShaderPackParseTask.cpp b/launcher/minecraft/mod/tasks/LocalShaderPackParseTask.cpp new file mode 100644 index 0000000..3a6b11b --- /dev/null +++ b/launcher/minecraft/mod/tasks/LocalShaderPackParseTask.cpp @@ -0,0 +1,128 @@ +// SPDX-FileCopyrightText: 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> +// +// SPDX-License-Identifier: GPL-3.0-only + +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "LocalShaderPackParseTask.h" + +#include "FileSystem.h" +#include "archive/ArchiveReader.h" + +namespace ShaderPackUtils { + +bool process(ShaderPack& pack, ProcessingLevel level) +{ + switch (pack.type()) { + case ResourceType::FOLDER: + return ShaderPackUtils::processFolder(pack, level); + case ResourceType::ZIPFILE: + return ShaderPackUtils::processZIP(pack, level); + default: + qWarning() << "Invalid type for shader pack parse task!"; + return false; + } +} + +bool processFolder(ShaderPack& pack, ProcessingLevel level) +{ + Q_ASSERT(pack.type() == ResourceType::FOLDER); + + QFileInfo shaders_dir_info(FS::PathCombine(pack.fileinfo().filePath(), "shaders")); + if (!shaders_dir_info.exists() || !shaders_dir_info.isDir()) { + return false; // assets dir does not exists or isn't valid + } + pack.setPackFormat(ShaderPackFormat::VALID); + + if (level == ProcessingLevel::BasicInfoOnly) { + return true; // only need basic info already checked + } + + return true; // all tests passed +} + +bool processZIP(ShaderPack& pack, ProcessingLevel level) +{ + Q_ASSERT(pack.type() == ResourceType::ZIPFILE); + + MMCZip::ArchiveReader zip(pack.fileinfo().filePath()); + if (!zip.collectFiles(false)) + return false; // can't open zip file + + if (!zip.exists("/shaders")) { + // assets dir does not exists at zip root, but shader packs + // will sometimes be a zip file containing a folder with the + // actual contents in it. This happens + // e.g. when the shader pack is downloaded as code + // from Github. so other than "/shaders", we + // could also check for a "shaders" folder one level deep. + + QStringList files = zip.getFiles(); + + // the assumption here is that there is just one + // folder with the "shader" subfolder. In case + // there are multiple, the first one is picked. + bool isShaderPresent = false; + for (QString f : files) { + if (f.contains("/shaders/", Qt::CaseInsensitive)) { + isShaderPresent = true; + break; + } + } + + if (!isShaderPresent) + // assets dir does not exist. + return false; + } + pack.setPackFormat(ShaderPackFormat::VALID); + + if (level == ProcessingLevel::BasicInfoOnly) { + return true; // only need basic info already checked + } + + return true; +} + +bool validate(QFileInfo file) +{ + ShaderPack sp{ file }; + return ShaderPackUtils::process(sp, ProcessingLevel::BasicInfoOnly) && sp.valid(); +} + +} // namespace ShaderPackUtils + +LocalShaderPackParseTask::LocalShaderPackParseTask(int token, ShaderPack& sp) : Task(false), m_token(token), m_shader_pack(sp) {} + +bool LocalShaderPackParseTask::abort() +{ + m_aborted = true; + return true; +} + +void LocalShaderPackParseTask::executeTask() +{ + if (!ShaderPackUtils::process(m_shader_pack)) { + emitFailed("this is not a shader pack"); + return; + } + + if (m_aborted) + emitAborted(); + else + emitSucceeded(); +} diff --git a/launcher/minecraft/mod/tasks/LocalShaderPackParseTask.h b/launcher/minecraft/mod/tasks/LocalShaderPackParseTask.h new file mode 100644 index 0000000..55d77f3 --- /dev/null +++ b/launcher/minecraft/mod/tasks/LocalShaderPackParseTask.h @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> +// +// SPDX-License-Identifier: GPL-3.0-only + +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include + +#include "minecraft/mod/ShaderPack.h" + +#include "tasks/Task.h" + +namespace ShaderPackUtils { + +enum class ProcessingLevel { Full, BasicInfoOnly }; + +bool process(ShaderPack& pack, ProcessingLevel level = ProcessingLevel::Full); + +bool processZIP(ShaderPack& pack, ProcessingLevel level = ProcessingLevel::Full); +bool processFolder(ShaderPack& pack, ProcessingLevel level = ProcessingLevel::Full); + +/** Checks whether a file is valid as a shader pack or not. */ +bool validate(QFileInfo file); +} // namespace ShaderPackUtils + +class LocalShaderPackParseTask : public Task { + Q_OBJECT + public: + LocalShaderPackParseTask(int token, ShaderPack& sp); + + bool canAbort() const override { return true; } + bool abort() override; + + void executeTask() override; + + int token() const { return m_token; } + + private: + int m_token; + + ShaderPack& m_shader_pack; + + bool m_aborted = false; +}; diff --git a/launcher/minecraft/mod/tasks/LocalTexturePackParseTask.cpp b/launcher/minecraft/mod/tasks/LocalTexturePackParseTask.cpp new file mode 100644 index 0000000..106d7c3 --- /dev/null +++ b/launcher/minecraft/mod/tasks/LocalTexturePackParseTask.cpp @@ -0,0 +1,208 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "LocalTexturePackParseTask.h" + +#include "FileSystem.h" +#include "archive/ArchiveReader.h" + +#include + +namespace TexturePackUtils { + +bool process(TexturePack& pack, ProcessingLevel level) +{ + switch (pack.type()) { + case ResourceType::FOLDER: + return TexturePackUtils::processFolder(pack, level); + case ResourceType::ZIPFILE: + return TexturePackUtils::processZIP(pack, level); + default: + qWarning() << "Invalid type for resource pack parse task!"; + return false; + } +} + +bool processFolder(TexturePack& pack, ProcessingLevel level) +{ + Q_ASSERT(pack.type() == ResourceType::FOLDER); + + QFileInfo mcmeta_file_info(FS::PathCombine(pack.fileinfo().filePath(), "pack.txt")); + if (mcmeta_file_info.isFile()) { + QFile mcmeta_file(mcmeta_file_info.filePath()); + if (!mcmeta_file.open(QIODevice::ReadOnly)) + return false; + + auto data = mcmeta_file.readAll(); + + bool packTXT_result = TexturePackUtils::processPackTXT(pack, std::move(data)); + + mcmeta_file.close(); + if (!packTXT_result) { + return false; + } + } else { + return false; + } + + if (level == ProcessingLevel::BasicInfoOnly) + return true; + + QFileInfo image_file_info(FS::PathCombine(pack.fileinfo().filePath(), "pack.png")); + if (image_file_info.isFile()) { + QFile mcmeta_file(image_file_info.filePath()); + if (!mcmeta_file.open(QIODevice::ReadOnly)) + return false; + + auto data = mcmeta_file.readAll(); + + bool packPNG_result = TexturePackUtils::processPackPNG(pack, std::move(data)); + + mcmeta_file.close(); + if (!packPNG_result) { + return false; + } + } else { + return false; + } + + return true; +} + +bool processZIP(TexturePack& pack, ProcessingLevel level) +{ + Q_ASSERT(pack.type() == ResourceType::ZIPFILE); + + MMCZip::ArchiveReader zip(pack.fileinfo().filePath()); + bool packProcessed = false; + bool iconProcessed = false; + + return zip.parse([&packProcessed, &iconProcessed, &pack, level](MMCZip::ArchiveReader::File* file, bool& stop) { + if (!packProcessed && file->filename() == "pack.txt") { + packProcessed = true; + auto data = file->readAll(); + stop = packProcessed && (iconProcessed || level == ProcessingLevel::BasicInfoOnly); + return TexturePackUtils::processPackTXT(pack, std::move(data)); + } + if (!iconProcessed && file->filename() == "pack.png") { + iconProcessed = true; + auto data = file->readAll(); + stop = packProcessed && iconProcessed; + return TexturePackUtils::processPackPNG(pack, std::move(data)); + } + file->skip(); + return true; + }); +} + +bool processPackTXT(TexturePack& pack, QByteArray&& raw_data) +{ + pack.setDescription(QString(raw_data)); + return true; +} + +bool processPackPNG(const TexturePack& pack, QByteArray&& raw_data) +{ + auto img = QImage::fromData(raw_data); + if (!img.isNull()) { + pack.setImage(img); + } else { + qWarning() << "Failed to parse pack.png."; + return false; + } + return true; +} + +bool processPackPNG(const TexturePack& pack) +{ + auto png_invalid = [&pack]() { + qWarning() << "Texture pack at" << pack.fileinfo().filePath() << "does not have a valid pack.png"; + return false; + }; + + switch (pack.type()) { + case ResourceType::FOLDER: { + QFileInfo image_file_info(FS::PathCombine(pack.fileinfo().filePath(), "pack.png")); + if (image_file_info.exists() && image_file_info.isFile()) { + QFile pack_png_file(image_file_info.filePath()); + if (!pack_png_file.open(QIODevice::ReadOnly)) + return png_invalid(); // can't open pack.png file + + auto data = pack_png_file.readAll(); + + bool pack_png_result = TexturePackUtils::processPackPNG(pack, std::move(data)); + + pack_png_file.close(); + if (!pack_png_result) { + return png_invalid(); // pack.png invalid + } + } else { + return png_invalid(); // pack.png does not exists or is not a valid file. + } + return false; + } + case ResourceType::ZIPFILE: { + MMCZip::ArchiveReader zip(pack.fileinfo().filePath()); + + auto file = zip.goToFile("pack.png"); + if (file) { + auto data = file->readAll(); + + bool pack_png_result = TexturePackUtils::processPackPNG(pack, std::move(data)); + + if (!pack_png_result) { + return png_invalid(); // pack.png invalid + } + } + return png_invalid(); // could not set pack.mcmeta as current file. + } + default: + qWarning() << "Invalid type for resource pack parse task!"; + return false; + } +} + +bool validate(QFileInfo file) +{ + TexturePack rp{ file }; + return TexturePackUtils::process(rp, ProcessingLevel::BasicInfoOnly) && rp.valid(); +} + +} // namespace TexturePackUtils + +LocalTexturePackParseTask::LocalTexturePackParseTask(int token, TexturePack& rp) : Task(false), m_token(token), m_texture_pack(rp) {} + +bool LocalTexturePackParseTask::abort() +{ + m_aborted = true; + return true; +} + +void LocalTexturePackParseTask::executeTask() +{ + if (!TexturePackUtils::process(m_texture_pack)) { + emitFailed("this is not a texture pack"); + return; + } + + if (m_aborted) + emitAborted(); + else + emitSucceeded(); +} diff --git a/launcher/minecraft/mod/tasks/LocalTexturePackParseTask.h b/launcher/minecraft/mod/tasks/LocalTexturePackParseTask.h new file mode 100644 index 0000000..b9cc1ea --- /dev/null +++ b/launcher/minecraft/mod/tasks/LocalTexturePackParseTask.h @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include + +#include "minecraft/mod/TexturePack.h" + +#include "tasks/Task.h" + +namespace TexturePackUtils { + +enum class ProcessingLevel { Full, BasicInfoOnly }; + +bool process(TexturePack& pack, ProcessingLevel level = ProcessingLevel::Full); + +bool processZIP(TexturePack& pack, ProcessingLevel level = ProcessingLevel::Full); +bool processFolder(TexturePack& pack, ProcessingLevel level = ProcessingLevel::Full); + +bool processPackTXT(TexturePack& pack, QByteArray&& raw_data); +bool processPackPNG(const TexturePack& pack, QByteArray&& raw_data); + +/// processes ONLY the pack.png (rest of the pack may be invalid) +bool processPackPNG(const TexturePack& pack); + +/** Checks whether a file is valid as a texture pack or not. */ +bool validate(QFileInfo file); +} // namespace TexturePackUtils + +class LocalTexturePackParseTask : public Task { + Q_OBJECT + public: + LocalTexturePackParseTask(int token, TexturePack& rp); + + bool canAbort() const override { return true; } + bool abort() override; + + void executeTask() override; + + int token() const { return m_token; } + + private: + int m_token; + + TexturePack& m_texture_pack; + + bool m_aborted = false; +}; diff --git a/launcher/minecraft/mod/tasks/LocalWorldSaveParseTask.cpp b/launcher/minecraft/mod/tasks/LocalWorldSaveParseTask.cpp new file mode 100644 index 0000000..50f7bbf --- /dev/null +++ b/launcher/minecraft/mod/tasks/LocalWorldSaveParseTask.cpp @@ -0,0 +1,200 @@ + +// SPDX-FileCopyrightText: 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> +// +// SPDX-License-Identifier: GPL-3.0-only + +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "LocalWorldSaveParseTask.h" + +#include "FileSystem.h" +#include "archive/ArchiveReader.h" + +#include +#include +#include + +namespace WorldSaveUtils { + +bool process(WorldSave& pack, ProcessingLevel level) +{ + switch (pack.type()) { + case ResourceType::FOLDER: + return WorldSaveUtils::processFolder(pack, level); + case ResourceType::ZIPFILE: + return WorldSaveUtils::processZIP(pack, level); + default: + qWarning() << "Invalid type for world save parse task!"; + return false; + } +} + +/// @brief checks a folder structure to see if it contains a level.dat +/// @param dir the path to check +/// @param saves used in recursive call if a "saves" dir was found +/// @return std::tuple of ( +/// bool , +/// QString , +/// bool +/// ) +static std::tuple contains_level_dat(QDir dir, bool saves = false) +{ + for (auto const& entry : dir.entryInfoList()) { + if (!entry.isDir()) { + continue; + } + if (!saves && entry.fileName() == "saves") { + return contains_level_dat(QDir(entry.filePath()), true); + } + QFileInfo level_dat(FS::PathCombine(entry.filePath(), "level.dat")); + if (level_dat.exists() && level_dat.isFile()) { + return std::make_tuple(true, entry.fileName(), saves); + } + } + return std::make_tuple(false, "", saves); +} + +bool processFolder(WorldSave& save, ProcessingLevel level) +{ + Q_ASSERT(save.type() == ResourceType::FOLDER); + + auto [found, save_dir_name, found_saves_dir] = contains_level_dat(QDir(save.fileinfo().filePath())); + + if (!found) { + return false; + } + + save.setSaveDirName(save_dir_name); + + if (found_saves_dir) { + save.setSaveFormat(WorldSaveFormat::MULTI); + } else { + save.setSaveFormat(WorldSaveFormat::SINGLE); + } + + if (level == ProcessingLevel::BasicInfoOnly) { + return true; // only need basic info already checked + } + + // reserved for more intensive processing + + return true; // all tests passed +} + +/// @brief checks a folder structure to see if it contains a level.dat +/// @param zip the zip file to check +/// @return std::tuple of ( +/// bool , +/// QString , +/// bool +/// ) +static std::tuple contains_level_dat(QString fileName) +{ + MMCZip::ArchiveReader zip(fileName); + if (!zip.collectFiles()) { + return std::make_tuple(false, "", false); + } + bool saves = false; + if (zip.exists("/saves")) { + saves = true; + } + + for (auto file : zip.getFiles()) { + QString relativePath = file; + if (saves) { + if (!relativePath.startsWith("saves/", Qt::CaseInsensitive)) + continue; + relativePath = relativePath.mid(QString("saves/").length()); + } + if (!relativePath.endsWith("/level.dat", Qt::CaseInsensitive)) + continue; + + int slashIndex = relativePath.indexOf('/'); + if (slashIndex == -1) + continue; // malformed: no slash between saves/ and level.dat + + QString worldName = relativePath.left(slashIndex); + QString remaining = relativePath.mid(slashIndex + 1); + + // Check that there's nothing between worldName/ and level.dat + if (remaining == "level.dat") { + return std::make_tuple(true, worldName, saves); + } + } + + return std::make_tuple(false, "", saves); +} + +bool processZIP(WorldSave& save, ProcessingLevel level) +{ + Q_ASSERT(save.type() == ResourceType::ZIPFILE); + + auto [found, save_dir_name, found_saves_dir] = contains_level_dat(save.fileinfo().filePath()); + + if (!found) { + return false; + } + if (save_dir_name.endsWith("/")) { + save_dir_name.chop(1); + } + + save.setSaveDirName(save_dir_name); + + if (found_saves_dir) { + save.setSaveFormat(WorldSaveFormat::MULTI); + } else { + save.setSaveFormat(WorldSaveFormat::SINGLE); + } + + if (level == ProcessingLevel::BasicInfoOnly) { + return true; // only need basic info already checked + } + + // reserved for more intensive processing + + return true; +} + +bool validate(QFileInfo file) +{ + WorldSave sp{ file }; + return WorldSaveUtils::process(sp, ProcessingLevel::BasicInfoOnly) && sp.valid(); +} + +} // namespace WorldSaveUtils + +LocalWorldSaveParseTask::LocalWorldSaveParseTask(int token, WorldSave& save) : Task(false), m_token(token), m_save(save) {} + +bool LocalWorldSaveParseTask::abort() +{ + m_aborted = true; + return true; +} + +void LocalWorldSaveParseTask::executeTask() +{ + if (!WorldSaveUtils::process(m_save)) { + emitFailed("this is not a world"); + return; + } + + if (m_aborted) + emitAborted(); + else + emitSucceeded(); +} diff --git a/launcher/minecraft/mod/tasks/LocalWorldSaveParseTask.h b/launcher/minecraft/mod/tasks/LocalWorldSaveParseTask.h new file mode 100644 index 0000000..42faf51 --- /dev/null +++ b/launcher/minecraft/mod/tasks/LocalWorldSaveParseTask.h @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> +// +// SPDX-License-Identifier: GPL-3.0-only + +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include + +#include "minecraft/mod/WorldSave.h" + +#include "tasks/Task.h" + +namespace WorldSaveUtils { + +enum class ProcessingLevel { Full, BasicInfoOnly }; + +bool process(WorldSave& save, ProcessingLevel level = ProcessingLevel::Full); + +bool processZIP(WorldSave& pack, ProcessingLevel level = ProcessingLevel::Full); +bool processFolder(WorldSave& pack, ProcessingLevel level = ProcessingLevel::Full); + +bool validate(QFileInfo file); + +} // namespace WorldSaveUtils + +class LocalWorldSaveParseTask : public Task { + Q_OBJECT + public: + LocalWorldSaveParseTask(int token, WorldSave& save); + + bool canAbort() const override { return true; } + bool abort() override; + + void executeTask() override; + + int token() const { return m_token; } + + private: + int m_token; + + WorldSave& m_save; + + bool m_aborted = false; +}; diff --git a/launcher/minecraft/mod/tasks/ResourceFolderLoadTask.cpp b/launcher/minecraft/mod/tasks/ResourceFolderLoadTask.cpp new file mode 100644 index 0000000..3b98e05 --- /dev/null +++ b/launcher/minecraft/mod/tasks/ResourceFolderLoadTask.cpp @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ResourceFolderLoadTask.h" + +#include "Application.h" +#include "FileSystem.h" +#include "minecraft/mod/MetadataHandler.h" + +#include + +ResourceFolderLoadTask::ResourceFolderLoadTask(const QDir& resource_dir, + const QDir& index_dir, + bool is_indexed, + bool clean_orphan, + std::function create_function) + : Task(false) + , m_resource_dir(resource_dir) + , m_index_dir(index_dir) + , m_is_indexed(is_indexed) + , m_clean_orphan(clean_orphan) + , m_create_func(create_function) + , m_result(new Result()) + , m_thread_to_spawn_into(thread()) +{} + +void ResourceFolderLoadTask::executeTask() +{ + if (thread() != m_thread_to_spawn_into) + connect(this, &Task::finished, this->thread(), &QThread::quit); + + if (m_is_indexed) { + // Read metadata first + getFromMetadata(); + } + + // Read JAR files that don't have metadata + m_resource_dir.refresh(); + for (auto entry : m_resource_dir.entryInfoList()) { + auto filePath = entry.absoluteFilePath(); + if (auto app = APPLICATION_DYN; app && app->checkQSavePath(filePath)) { + continue; + } + auto newFilePath = FS::getUniqueResourceName(filePath); + if (newFilePath != filePath) { + FS::move(filePath, newFilePath); + entry = QFileInfo(newFilePath); + } + + Resource* resource = m_create_func(entry); + + if (resource->enabled()) { + if (m_result->resources.contains(resource->internal_id())) { + m_result->resources[resource->internal_id()]->setStatus(ResourceStatus::INSTALLED); + // Delete the object we just created, since a valid one is already in the mods list. + delete resource; + } else { + m_result->resources[resource->internal_id()].reset(resource); + m_result->resources[resource->internal_id()]->setStatus(ResourceStatus::NO_METADATA); + } + } else { + QString chopped_id = resource->internal_id().chopped(9); + if (m_result->resources.contains(chopped_id)) { + m_result->resources[resource->internal_id()].reset(resource); + + auto metadata = m_result->resources[chopped_id]->metadata(); + if (metadata) { + resource->setMetadata(*metadata); + + m_result->resources[resource->internal_id()]->setStatus(ResourceStatus::INSTALLED); + m_result->resources.remove(chopped_id); + } + } else { + m_result->resources[resource->internal_id()].reset(resource); + m_result->resources[resource->internal_id()]->setStatus(ResourceStatus::NO_METADATA); + } + } + } + + // Remove orphan metadata to prevent issues + // See https://github.com/PolyMC/PolyMC/issues/996 + if (m_clean_orphan) { + QMutableMapIterator iter(m_result->resources); + while (iter.hasNext()) { + auto resource = iter.next().value(); + if (resource->status() == ResourceStatus::NOT_INSTALLED) { + resource->destroy(m_index_dir, false, false); + iter.remove(); + } + } + } + + for (auto mod : m_result->resources) + mod->moveToThread(m_thread_to_spawn_into); + + if (m_aborted) + emit finished(); + else + emitSucceeded(); +} + +void ResourceFolderLoadTask::getFromMetadata() +{ + m_index_dir.refresh(); + for (auto entry : m_index_dir.entryList(QDir::Files)) { + if (!entry.endsWith(".pw.toml")) { + continue; + } + + auto metadata = Metadata::get(m_index_dir, entry); + + if (!metadata.isValid()) + continue; + + auto* resource = m_create_func(QFileInfo(m_resource_dir.filePath(metadata.filename))); + resource->setMetadata(metadata); + resource->setStatus(ResourceStatus::NOT_INSTALLED); + m_result->resources[resource->internal_id()].reset(resource); + } +} diff --git a/launcher/minecraft/mod/tasks/ResourceFolderLoadTask.h b/launcher/minecraft/mod/tasks/ResourceFolderLoadTask.h new file mode 100644 index 0000000..7c872c1 --- /dev/null +++ b/launcher/minecraft/mod/tasks/ResourceFolderLoadTask.h @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include "minecraft/mod/Mod.h" +#include "tasks/Task.h" + +class ResourceFolderLoadTask : public Task { + Q_OBJECT + public: + struct Result { + QMap resources; + }; + using ResultPtr = std::shared_ptr; + ResultPtr result() const { return m_result; } + + public: + ResourceFolderLoadTask(const QDir& resource_dir, + const QDir& index_dir, + bool is_indexed, + bool clean_orphan, + std::function create_function); + + bool canAbort() const override { return true; } + bool abort() override + { + m_aborted.store(true); + return true; + } + + void executeTask() override; + + private: + void getFromMetadata(); + + private: + QDir m_resource_dir, m_index_dir; + bool m_is_indexed; + bool m_clean_orphan; + std::function m_create_func; + ResultPtr m_result; + + std::atomic m_aborted = false; + + /** This is the thread in which we should put new mod objects */ + QThread* m_thread_to_spawn_into; +}; diff --git a/launcher/minecraft/skins/CapeChange.cpp b/launcher/minecraft/skins/CapeChange.cpp new file mode 100644 index 0000000..c955a16 --- /dev/null +++ b/launcher/minecraft/skins/CapeChange.cpp @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "CapeChange.h" + +#include +#include +#include "net/RawHeaderProxy.h" + +CapeChange::CapeChange(QString cape) : NetRequest(), m_capeId(cape) +{ + logCat = taskMCSkinsLogC; +} + +QNetworkReply* CapeChange::getReply(QNetworkRequest& request) +{ + if (m_capeId.isEmpty()) { + setStatus(tr("Removing cape")); + return m_network->deleteResource(request); + } else { + setStatus(tr("Equipping cape")); + return m_network->put(request, QString("{\"capeId\":\"%1\"}").arg(m_capeId).toUtf8()); + } +} + +CapeChange::Ptr CapeChange::make(QString token, QString capeId) +{ + auto up = makeShared(capeId); + up->m_url = QUrl("https://api.minecraftservices.com/minecraft/profile/capes/active"); + up->setObjectName(QString("BYTES:") + up->m_url.toString()); + up->m_sink.reset(new Net::DummySink()); + up->addHeaderProxy(std::make_unique(QList{ + { "Authorization", QString("Bearer %1").arg(token).toLocal8Bit() }, + })); + return up; +} diff --git a/launcher/minecraft/skins/CapeChange.h b/launcher/minecraft/skins/CapeChange.h new file mode 100644 index 0000000..2be904f --- /dev/null +++ b/launcher/minecraft/skins/CapeChange.h @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include "net/NetRequest.h" + +class CapeChange : public Net::NetRequest { + Q_OBJECT + public: + using Ptr = shared_qobject_ptr; + CapeChange(QString capeId); + virtual ~CapeChange() = default; + + static CapeChange::Ptr make(QString token, QString capeId); + + protected: + virtual QNetworkReply* getReply(QNetworkRequest&) override; + + private: + QString m_capeId; +}; diff --git a/launcher/minecraft/skins/SkinDelete.cpp b/launcher/minecraft/skins/SkinDelete.cpp new file mode 100644 index 0000000..9c98e3f --- /dev/null +++ b/launcher/minecraft/skins/SkinDelete.cpp @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "SkinDelete.h" + +#include +#include "net/RawHeaderProxy.h" + +SkinDelete::SkinDelete() : NetRequest() +{ + logCat = taskMCSkinsLogC; +} + +QNetworkReply* SkinDelete::getReply(QNetworkRequest& request) +{ + setStatus(tr("Deleting skin")); + return m_network->deleteResource(request); +} + +SkinDelete::Ptr SkinDelete::make(QString token) +{ + auto up = makeShared(); + up->m_url = QUrl("https://api.minecraftservices.com/minecraft/profile/skins/active"); + up->m_sink.reset(new Net::DummySink()); + up->addHeaderProxy(std::make_unique(QList{ + { "Authorization", QString("Bearer %1").arg(token).toLocal8Bit() }, + })); + return up; +} diff --git a/launcher/minecraft/skins/SkinDelete.h b/launcher/minecraft/skins/SkinDelete.h new file mode 100644 index 0000000..d6a68d2 --- /dev/null +++ b/launcher/minecraft/skins/SkinDelete.h @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include "net/NetRequest.h" + +class SkinDelete : public Net::NetRequest { + Q_OBJECT + public: + using Ptr = shared_qobject_ptr; + SkinDelete(); + virtual ~SkinDelete() = default; + + static SkinDelete::Ptr make(QString token); + + protected: + virtual QNetworkReply* getReply(QNetworkRequest&) override; +}; diff --git a/launcher/minecraft/skins/SkinList.cpp b/launcher/minecraft/skins/SkinList.cpp new file mode 100644 index 0000000..b47f17d --- /dev/null +++ b/launcher/minecraft/skins/SkinList.cpp @@ -0,0 +1,419 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "SkinList.h" + +#include +#include + +#include "FileSystem.h" +#include "Json.h" +#include "minecraft/skins/SkinModel.h" + +SkinList::SkinList(QObject* parent, QString path, MinecraftAccountPtr acct) : QAbstractListModel(parent), m_acct(acct) +{ + FS::ensureFolderPathExists(m_dir.absolutePath()); + m_dir.setFilter(QDir::Readable | QDir::NoDotAndDotDot | QDir::Files | QDir::Dirs); + m_dir.setSorting(QDir::Name | QDir::IgnoreCase | QDir::LocaleAware); + m_watcher.reset(new QFileSystemWatcher(this)); + m_isWatching = false; + connect(m_watcher.get(), &QFileSystemWatcher::directoryChanged, this, &SkinList::directoryChanged); + connect(m_watcher.get(), &QFileSystemWatcher::fileChanged, this, &SkinList::fileChanged); + directoryChanged(path); +} + +void SkinList::startWatching() +{ + if (m_isWatching) { + return; + } + update(); + m_isWatching = m_watcher->addPath(m_dir.absolutePath()); + if (m_isWatching) { + qDebug() << "Started watching" << m_dir.absolutePath(); + } else { + qDebug() << "Failed to start watching" << m_dir.absolutePath(); + } +} + +void SkinList::stopWatching() +{ + save(); + if (!m_isWatching) { + return; + } + m_isWatching = !m_watcher->removePath(m_dir.absolutePath()); + if (!m_isWatching) { + qDebug() << "Stopped watching" << m_dir.absolutePath(); + } else { + qDebug() << "Failed to stop watching" << m_dir.absolutePath(); + } +} + +bool SkinList::update() +{ + QList newSkins; + m_dir.refresh(); + + auto manifestInfo = QFileInfo(m_dir.absoluteFilePath("index.json")); + if (manifestInfo.exists()) { + try { + auto doc = Json::requireDocument(manifestInfo.absoluteFilePath(), "SkinList JSON file"); + const auto root = doc.object(); + auto skins = root["skins"].toArray(); + for (auto jSkin : skins) { + SkinModel s(m_dir, jSkin.toObject()); + if (s.isValid()) { + newSkins << s; + } + } + } catch (const Exception& e) { + qCritical() << "Couldn't load skins json:" << e.cause(); + } + } + + bool needsSave = false; + const auto& skin = m_acct->accountData()->minecraftProfile.skin; + if (!skin.url.isEmpty() && !skin.data.isEmpty()) { + QPixmap skinTexture; + SkinModel* nskin = nullptr; + for (auto i = 0; i < newSkins.size(); i++) { + if (newSkins[i].getURL() == skin.url) { + nskin = &newSkins[i]; + break; + } + } + if (!nskin) { + auto name = m_acct->profileName() + ".png"; + if (QFileInfo(m_dir.absoluteFilePath(name)).exists()) { + name = QUrl(skin.url).fileName() + ".png"; + } + auto path = m_dir.absoluteFilePath(name); + if (skinTexture.loadFromData(skin.data, "PNG") && skinTexture.save(path)) { + SkinModel s(path); + s.setModel(skin.variant.toUpper() == "SLIM" ? SkinModel::SLIM : SkinModel::CLASSIC); + s.setCapeId(m_acct->accountData()->minecraftProfile.currentCape); + s.setURL(skin.url); + newSkins << s; + needsSave = true; + } + } else { + nskin->setCapeId(m_acct->accountData()->minecraftProfile.currentCape); + nskin->setModel(skin.variant.toUpper() == "SLIM" ? SkinModel::SLIM : SkinModel::CLASSIC); + } + } + + auto folderContents = m_dir.entryInfoList(); + // if there are any untracked files... + for (QFileInfo entry : folderContents) { + if (!entry.isFile() && entry.suffix() != "png") + continue; + + SkinModel w(entry.absoluteFilePath()); + if (w.isValid()) { + auto add = true; + for (auto s : newSkins) { + if (s.name() == w.name()) { + add = false; + break; + } + } + if (add) { + newSkins.append(w); + needsSave = true; + } + } + } + std::sort(newSkins.begin(), newSkins.end(), + [](const SkinModel& a, const SkinModel& b) { return a.getPath().localeAwareCompare(b.getPath()) < 0; }); + beginResetModel(); + m_skinList.swap(newSkins); + endResetModel(); + if (needsSave) + save(); + return true; +} + +void SkinList::directoryChanged(const QString& path) +{ + QDir new_dir(path); + if (!new_dir.exists()) + if (!FS::ensureFolderPathExists(new_dir.absolutePath())) + return; + if (m_dir.absolutePath() != new_dir.absolutePath()) { + m_dir.setPath(path); + m_dir.refresh(); + if (m_isWatching) + stopWatching(); + startWatching(); + } + update(); +} + +void SkinList::fileChanged(const QString& path) +{ + qDebug() << "Checking" << path; + QFileInfo checkfile(path); + if (!checkfile.exists()) + return; + + for (int i = 0; i < m_skinList.count(); i++) { + if (m_skinList[i].getPath() == checkfile.absoluteFilePath()) { + m_skinList[i].refresh(); + dataChanged(index(i), index(i)); + break; + } + } +} + +QStringList SkinList::mimeTypes() const +{ + return { "text/uri-list" }; +} + +Qt::DropActions SkinList::supportedDropActions() const +{ + return Qt::CopyAction; +} + +bool SkinList::dropMimeData(const QMimeData* data, + Qt::DropAction action, + [[maybe_unused]] int row, + [[maybe_unused]] int column, + [[maybe_unused]] const QModelIndex& parent) +{ + if (action == Qt::IgnoreAction) + return true; + // check if the action is supported + if (!data || !(action & supportedDropActions())) + return false; + + // files dropped from outside? + if (data->hasUrls()) { + auto urls = data->urls(); + QStringList skinFiles; + for (auto url : urls) { + // only local files may be dropped... + if (!url.isLocalFile()) + continue; + skinFiles << url.toLocalFile(); + } + installSkins(skinFiles); + return true; + } + return false; +} + +Qt::ItemFlags SkinList::flags(const QModelIndex& index) const +{ + Qt::ItemFlags f = Qt::ItemIsDropEnabled | QAbstractListModel::flags(index); + if (index.isValid()) { + f |= (Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsEditable); + } + return f; +} + +QVariant SkinList::data(const QModelIndex& index, int role) const +{ + if (!index.isValid()) + return QVariant(); + + int row = index.row(); + + if (row < 0 || row >= m_skinList.size()) + return QVariant(); + auto skin = m_skinList[row]; + switch (role) { + case Qt::DecorationRole: { + auto preview = skin.getPreview(); + if (preview.isNull()) { + preview = skin.getTexture(); + } + return preview; + } + case Qt::DisplayRole: + return skin.name(); + case Qt::UserRole: + return skin.name(); + case Qt::EditRole: + return skin.name(); + default: + return QVariant(); + } +} + +int SkinList::rowCount(const QModelIndex& parent) const +{ + return parent.isValid() ? 0 : m_skinList.size(); +} + +void SkinList::installSkins(const QStringList& iconFiles) +{ + for (QString file : iconFiles) + installSkin(file); +} + +QString getUniqueFile(const QString& root, const QString& file) +{ + auto result = FS::PathCombine(root, file); + if (!QFileInfo::exists(result)) { + return result; + } + + QString baseName = QFileInfo(file).completeBaseName(); + QString extension = QFileInfo(file).suffix(); + int tries = 0; + while (QFileInfo::exists(result)) { + if (++tries > 256) + return {}; + + QString key = QString("%1%2.%3").arg(baseName).arg(tries).arg(extension); + result = FS::PathCombine(root, key); + } + + return result; +} +QString SkinList::installSkin(const QString& file, const QString& name) +{ + if (file.isEmpty()) + return tr("Path is empty."); + QFileInfo fileinfo(file); + if (!fileinfo.exists()) + return tr("File doesn't exist."); + if (!fileinfo.isFile()) + return tr("Not a file."); + if (!fileinfo.isReadable()) + return tr("File is not readable."); + if (fileinfo.suffix() != "png" && !SkinModel(fileinfo.absoluteFilePath()).isValid()) + return tr("Skin images must be 64x64 or 64x32 pixel PNG files."); + + QString target = getUniqueFile(m_dir.absolutePath(), name.isEmpty() ? fileinfo.fileName() : name); + + return QFile::copy(file, target) ? "" : tr("Unable to copy file"); +} + +int SkinList::getSkinIndex(const QString& key) const +{ + for (int i = 0; i < m_skinList.count(); i++) { + if (m_skinList[i].name() == key) { + return i; + } + } + return -1; +} + +const SkinModel* SkinList::skin(const QString& key) const +{ + int idx = getSkinIndex(key); + if (idx == -1) + return nullptr; + return &m_skinList[idx]; +} + +SkinModel* SkinList::skin(const QString& key) +{ + int idx = getSkinIndex(key); + if (idx == -1) + return nullptr; + return &m_skinList[idx]; +} + +bool SkinList::deleteSkin(const QString& key, bool trash) +{ + int idx = getSkinIndex(key); + if (idx != -1) { + auto s = m_skinList[idx]; + if (trash) { + if (FS::trash(s.getPath(), nullptr)) { + m_skinList.remove(idx); + save(); + return true; + } + } else if (QFile::remove(s.getPath())) { + m_skinList.remove(idx); + save(); + return true; + } + } + return false; +} + +void SkinList::save() +{ + QJsonObject doc; + QJsonArray arr; + for (auto s : m_skinList) { + arr << s.toJSON(); + } + doc["skins"] = arr; + try { + Json::write(doc, m_dir.absoluteFilePath("index.json")); + } catch (const FS::FileSystemException& e) { + qCritical() << "Failed to write skin index file :" << e.cause(); + } +} + +int SkinList::getSelectedAccountSkin() +{ + const auto& skin = m_acct->accountData()->minecraftProfile.skin; + for (int i = 0; i < m_skinList.count(); i++) { + if (m_skinList[i].getURL() == skin.url) { + return i; + } + } + return -1; +} + +bool SkinList::setData(const QModelIndex& idx, const QVariant& value, int role) +{ + if (!idx.isValid() || role != Qt::EditRole) { + return false; + } + + int row = idx.row(); + if (row < 0 || row >= m_skinList.size()) + return false; + auto& skin = m_skinList[row]; + auto newName = value.toString(); + if (skin.name() != newName) { + if (!skin.rename(newName)) + return false; + save(); + } + return true; +} + +void SkinList::updateSkin(SkinModel* s) +{ + auto done = false; + for (auto i = 0; i < m_skinList.size(); i++) { + if (m_skinList[i].getPath() == s->getPath()) { + m_skinList[i].setCapeId(s->getCapeId()); + m_skinList[i].setModel(s->getModel()); + m_skinList[i].setURL(s->getURL()); + done = true; + break; + } + } + if (!done) { + beginInsertRows(QModelIndex(), m_skinList.count(), m_skinList.count() + 1); + m_skinList.append(*s); + endInsertRows(); + } + save(); +} diff --git a/launcher/minecraft/skins/SkinList.h b/launcher/minecraft/skins/SkinList.h new file mode 100644 index 0000000..9db476f --- /dev/null +++ b/launcher/minecraft/skins/SkinList.h @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include +#include + +#include "QObjectPtr.h" +#include "SkinModel.h" +#include "minecraft/auth/MinecraftAccount.h" + +class SkinList : public QAbstractListModel { + Q_OBJECT + public: + explicit SkinList(QObject* parent, QString path, MinecraftAccountPtr acct); + virtual ~SkinList() { save(); }; + + int getSkinIndex(const QString& key) const; + + virtual QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + bool setData(const QModelIndex& idx, const QVariant& value, int role) override; + virtual int rowCount(const QModelIndex& parent = QModelIndex()) const override; + + virtual QStringList mimeTypes() const override; + virtual Qt::DropActions supportedDropActions() const override; + virtual bool dropMimeData(const QMimeData* data, Qt::DropAction action, int row, int column, const QModelIndex& parent) override; + virtual Qt::ItemFlags flags(const QModelIndex& index) const override; + + bool deleteSkin(const QString& key, bool trash); + + void installSkins(const QStringList& iconFiles); + QString installSkin(const QString& file, const QString& name = {}); + + const SkinModel* skin(const QString& key) const; + SkinModel* skin(const QString& key); + + void startWatching(); + void stopWatching(); + + QString getDir() const { return m_dir.absolutePath(); } + void save(); + int getSelectedAccountSkin(); + + void updateSkin(SkinModel* s); + + private: + // hide copy constructor + SkinList(const SkinList&) = delete; + // hide assign op + SkinList& operator=(const SkinList&) = delete; + + protected slots: + void directoryChanged(const QString& path); + void fileChanged(const QString& path); + bool update(); + + private: + shared_qobject_ptr m_watcher; + bool m_isWatching; + QList m_skinList; + QDir m_dir; + MinecraftAccountPtr m_acct; +}; diff --git a/launcher/minecraft/skins/SkinModel.cpp b/launcher/minecraft/skins/SkinModel.cpp new file mode 100644 index 0000000..e2c41f1 --- /dev/null +++ b/launcher/minecraft/skins/SkinModel.cpp @@ -0,0 +1,233 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023-2025 Trial97 + * Copyright (c) 2025 Rinth, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "SkinModel.h" +#include +#include + +#include "FileSystem.h" + +static void setAlpha(QImage& image, const QRect& region, const int alpha) +{ + for (int y = region.top(); y < region.bottom(); ++y) { + QRgb* line = reinterpret_cast(image.scanLine(y)); + for (int x = region.left(); x < region.right(); ++x) { + QRgb pixel = line[x]; + line[x] = qRgba(qRed(pixel), qGreen(pixel), qBlue(pixel), alpha); + } + } +} + +static void doNotchTransparencyHack(QImage& image) +{ + for (int y = 0; y < 32; y++) { + QRgb* line = reinterpret_cast(image.scanLine(y)); + for (int x = 32; x < 64; x++) { + if (qAlpha(line[x]) < 128) { + return; + } + } + } + + setAlpha(image, { 32, 0, 32, 32 }, 0); +} + +static QImage improveSkin(QImage skin) +{ + int height = skin.height(); + int width = skin.width(); + if (width != 64 || (height != 32 && height != 64)) { // this is no minecraft skin + return skin; + } + // It seems some older skins may use this format, which can't be drawn onto + // https://github.com/PrismLauncher/PrismLauncher/issues/4032 + // https://doc.qt.io/qt-6/qpainter.html#begin + if (skin.format() <= QImage::Format_Indexed8 || !skin.hasAlphaChannel()) { + skin = skin.convertToFormat(QImage::Format_ARGB32); + } + + auto isLegacy = height == 32; // old format + if (isLegacy) { + auto newSkin = QImage(QSize(64, 64), skin.format()); + newSkin.fill(Qt::transparent); + QPainter p(&newSkin); + p.drawImage(0, 0, skin); + + auto copyRect = [&p, &newSkin](int startX, int startY, int offsetX, int offsetY, int sizeX, int sizeY) { + QImage region = newSkin.copy(startX, startY, sizeX, sizeY); + region = region.mirrored(true, false); + + p.drawImage(startX + offsetX, startY + offsetY, region); + }; + static const struct { + int x; + int y; + int offsetX; + int offsetY; + int width; + int height; + } faces[] = { + { 4, 16, 16, 32, 4, 4 }, { 8, 16, 16, 32, 4, 4 }, { 0, 20, 24, 32, 4, 12 }, { 4, 20, 16, 32, 4, 12 }, + { 8, 20, 8, 32, 4, 12 }, { 12, 20, 16, 32, 4, 12 }, { 44, 16, -8, 32, 4, 4 }, { 48, 16, -8, 32, 4, 4 }, + { 40, 20, 0, 32, 4, 12 }, { 44, 20, -8, 32, 4, 12 }, { 48, 20, -16, 32, 4, 12 }, { 52, 20, -8, 32, 4, 12 }, + }; + + for (const auto& face : faces) { + copyRect(face.x, face.y, face.offsetX, face.offsetY, face.width, face.height); + } + doNotchTransparencyHack(newSkin); + skin = newSkin; + } + static const QRect opaqueParts[] = { + { 0, 0, 32, 16 }, + { 0, 16, 64, 16 }, + { 16, 48, 32, 16 }, + }; + + for (const auto& p : opaqueParts) { + setAlpha(skin, p, 255); + } + return skin; +} + +static QImage getSkin(const QString path) +{ + return improveSkin(QImage(path)); +} + +static QImage generatePreviews(QImage texture, bool slim) +{ + QImage preview(36, 36, QImage::Format_ARGB32); + preview.fill(Qt::transparent); + QPainter paint(&preview); + + // head + paint.drawImage(4, 2, texture.copy(8, 8, 8, 8)); + paint.drawImage(4, 2, texture.copy(40, 8, 8, 8)); + // torso + paint.drawImage(4, 10, texture.copy(20, 20, 8, 12)); + paint.drawImage(4, 10, texture.copy(20, 36, 8, 12)); + // right leg + paint.drawImage(4, 22, texture.copy(4, 20, 4, 12)); + paint.drawImage(4, 22, texture.copy(4, 36, 4, 12)); + // left leg + paint.drawImage(8, 22, texture.copy(20, 52, 4, 12)); + paint.drawImage(8, 22, texture.copy(4, 52, 4, 12)); + + auto armWidth = slim ? 3 : 4; + auto armPosX = slim ? 1 : 0; + // right arm + paint.drawImage(armPosX, 10, texture.copy(44, 20, armWidth, 12)); + paint.drawImage(armPosX, 10, texture.copy(44, 36, armWidth, 12)); + // left arm + paint.drawImage(12, 10, texture.copy(36, 52, armWidth, 12)); + paint.drawImage(12, 10, texture.copy(52, 52, armWidth, 12)); + + // back + // head + paint.drawImage(24, 2, texture.copy(24, 8, 8, 8)); + paint.drawImage(24, 2, texture.copy(56, 8, 8, 8)); + // torso + paint.drawImage(24, 10, texture.copy(32, 20, 8, 12)); + paint.drawImage(24, 10, texture.copy(32, 36, 8, 12)); + // right leg + paint.drawImage(24, 22, texture.copy(12, 20, 4, 12)); + paint.drawImage(24, 22, texture.copy(12, 36, 4, 12)); + // left leg + paint.drawImage(28, 22, texture.copy(28, 52, 4, 12)); + paint.drawImage(28, 22, texture.copy(12, 52, 4, 12)); + + // right arm + paint.drawImage(armPosX + 20, 10, texture.copy(48 + armWidth, 20, armWidth, 12)); + paint.drawImage(armPosX + 20, 10, texture.copy(48 + armWidth, 36, armWidth, 12)); + // left arm + paint.drawImage(32, 10, texture.copy(40 + armWidth, 52, armWidth, 12)); + paint.drawImage(32, 10, texture.copy(56 + armWidth, 52, armWidth, 12)); + + return preview; +} +SkinModel::SkinModel(QString path) : m_path(path), m_texture(getSkin(path)), m_model(Model::CLASSIC) +{ + m_preview = generatePreviews(m_texture, false); +} + +SkinModel::SkinModel(QDir skinDir, QJsonObject obj) + : m_capeId(obj["capeId"].toString()), m_model(Model::CLASSIC), m_url(obj["url"].toString()) +{ + auto name = obj["name"].toString(); + + if (auto model = obj["model"].toString(); model == "SLIM") { + m_model = Model::SLIM; + } + m_path = skinDir.absoluteFilePath(name) + ".png"; + m_texture = getSkin(m_path); + m_preview = generatePreviews(m_texture, m_model == Model::SLIM); +} + +QString SkinModel::name() const +{ + return QFileInfo(m_path).completeBaseName(); +} + +bool SkinModel::rename(QString newName) +{ + auto info = QFileInfo(m_path); + auto new_path = FS::PathCombine(info.absolutePath(), newName + ".png"); + if (QFileInfo::exists(new_path)) { + return false; + } + m_path = new_path; + return FS::move(info.absoluteFilePath(), m_path); +} + +QJsonObject SkinModel::toJSON() const +{ + QJsonObject obj; + obj["name"] = name(); + obj["capeId"] = m_capeId; + obj["url"] = m_url; + obj["model"] = getModelString(); + return obj; +} + +QString SkinModel::getModelString() const +{ + switch (m_model) { + case CLASSIC: + return "CLASSIC"; + case SLIM: + return "SLIM"; + } + return {}; +} + +bool SkinModel::isValid() const +{ + return !m_texture.isNull() && (m_texture.size().height() == 32 || m_texture.size().height() == 64) && m_texture.size().width() == 64; +} +void SkinModel::refresh() +{ + m_texture = getSkin(m_path); + m_preview = generatePreviews(m_texture, m_model == Model::SLIM); +} +void SkinModel::setModel(Model model) +{ + m_model = model; + m_preview = generatePreviews(m_texture, m_model == Model::SLIM); +} diff --git a/launcher/minecraft/skins/SkinModel.h b/launcher/minecraft/skins/SkinModel.h new file mode 100644 index 0000000..af8ca04 --- /dev/null +++ b/launcher/minecraft/skins/SkinModel.h @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include +#include + +class SkinModel { + public: + enum Model { CLASSIC, SLIM }; + + SkinModel() = default; + SkinModel(QString path); + SkinModel(QDir skinDir, QJsonObject obj); + virtual ~SkinModel() = default; + + QString name() const; + QString getModelString() const; + bool isValid() const; + QString getPath() const { return m_path; } + QImage getTexture() const { return m_texture; } + QImage getPreview() const { return m_preview; } + QString getCapeId() const { return m_capeId; } + Model getModel() const { return m_model; } + QString getURL() const { return m_url; } + + bool rename(QString newName); + void setCapeId(QString capeID) { m_capeId = capeID; } + void setModel(Model model); + void setURL(QString url) { m_url = url; } + void refresh(); + + QJsonObject toJSON() const; + + private: + QString m_path; + QImage m_texture; + QImage m_preview; + QString m_capeId; + Model m_model; + QString m_url; +}; diff --git a/launcher/minecraft/skins/SkinUpload.cpp b/launcher/minecraft/skins/SkinUpload.cpp new file mode 100644 index 0000000..8399f1f --- /dev/null +++ b/launcher/minecraft/skins/SkinUpload.cpp @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "SkinUpload.h" + +#include + +#include "FileSystem.h" +#include "net/DummySink.h" +#include "net/RawHeaderProxy.h" + +SkinUpload::SkinUpload(QString path, QString variant) : NetRequest(), m_path(path), m_variant(variant) +{ + logCat = taskMCSkinsLogC; +} + +QNetworkReply* SkinUpload::getReply(QNetworkRequest& request) +{ + QHttpMultiPart* multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType, this); + + QHttpPart skin; + skin.setHeader(QNetworkRequest::ContentTypeHeader, QVariant("image/png")); + skin.setHeader(QNetworkRequest::ContentDispositionHeader, QVariant("form-data; name=\"file\"; filename=\"skin.png\"")); + + skin.setBody(FS::read(m_path)); + + QHttpPart model; + model.setHeader(QNetworkRequest::ContentDispositionHeader, QVariant("form-data; name=\"variant\"")); + model.setBody(m_variant.toUtf8()); + + multiPart->append(skin); + multiPart->append(model); + setStatus(tr("Uploading skin")); + return m_network->post(request, multiPart); +} + +SkinUpload::Ptr SkinUpload::make(QString token, QString path, QString variant) +{ + auto up = makeShared(path, variant); + up->m_url = QUrl("https://api.minecraftservices.com/minecraft/profile/skins"); + up->setObjectName(QString("BYTES:") + up->m_url.toString()); + up->m_sink.reset(new Net::DummySink()); + up->addHeaderProxy(std::make_unique(QList{ + { "Authorization", QString("Bearer %1").arg(token).toLocal8Bit() }, + })); + return up; +} diff --git a/launcher/minecraft/skins/SkinUpload.h b/launcher/minecraft/skins/SkinUpload.h new file mode 100644 index 0000000..c1a4930 --- /dev/null +++ b/launcher/minecraft/skins/SkinUpload.h @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include "net/NetRequest.h" + +class SkinUpload : public Net::NetRequest { + Q_OBJECT + public: + using Ptr = shared_qobject_ptr; + + // Note this class takes ownership of the file. + SkinUpload(QString path, QString variant); + virtual ~SkinUpload() = default; + + static SkinUpload::Ptr make(QString token, QString path, QString variant); + + protected: + virtual QNetworkReply* getReply(QNetworkRequest&) override; + + private: + QString m_path; + QString m_variant; +}; diff --git a/launcher/minecraft/update/AssetUpdateTask.cpp b/launcher/minecraft/update/AssetUpdateTask.cpp new file mode 100644 index 0000000..22eea47 --- /dev/null +++ b/launcher/minecraft/update/AssetUpdateTask.cpp @@ -0,0 +1,122 @@ +#include "AssetUpdateTask.h" + +#include "BuildConfig.h" +#include "launch/LaunchStep.h" +#include "minecraft/AssetsUtils.h" +#include "minecraft/MinecraftInstance.h" +#include "minecraft/PackProfile.h" +#include "net/ChecksumValidator.h" + +#include "Application.h" + +#include "net/ApiDownload.h" + +AssetUpdateTask::AssetUpdateTask(MinecraftInstance* inst) +{ + m_inst = inst; +} + +void AssetUpdateTask::executeTask() +{ + setStatus(tr("Updating assets index...")); + auto components = m_inst->getPackProfile(); + auto profile = components->getProfile(); + auto assets = profile->getMinecraftAssets(); + QUrl indexUrl = assets->url; + QString localPath = assets->id + ".json"; + auto job = makeShared(tr("Asset index for %1").arg(m_inst->name()), APPLICATION->network()); + + auto metacache = APPLICATION->metacache(); + auto entry = metacache->resolveEntry("asset_indexes", localPath); + entry->setStale(true); + auto hexSha1 = assets->sha1.toLatin1(); + qDebug() << "Asset index SHA1:" << hexSha1; + auto dl = Net::ApiDownload::makeCached(indexUrl, entry); + dl->addValidator(new Net::ChecksumValidator(QCryptographicHash::Sha1, assets->sha1)); + job->addNetAction(dl); + + downloadJob.reset(job); + + connect(downloadJob.get(), &NetJob::succeeded, this, &AssetUpdateTask::assetIndexFinished); + connect(downloadJob.get(), &NetJob::failed, this, &AssetUpdateTask::assetIndexFailed); + connect(downloadJob.get(), &NetJob::aborted, this, &AssetUpdateTask::emitAborted); + connect(downloadJob.get(), &NetJob::progress, this, &AssetUpdateTask::progress); + connect(downloadJob.get(), &NetJob::stepProgress, this, &AssetUpdateTask::propagateStepProgress); + + qDebug() << "Starting asset index download for" << m_inst->name(); + downloadJob->start(); +} + +bool AssetUpdateTask::canAbort() const +{ + return true; +} + +void AssetUpdateTask::assetIndexFinished() +{ + AssetsIndex index; + qDebug() << "Finished asset index download for" << m_inst->name(); + + auto components = m_inst->getPackProfile(); + auto profile = components->getProfile(); + auto assets = profile->getMinecraftAssets(); + + QString asset_fname = "assets/indexes/" + assets->id + ".json"; + // FIXME: this looks like a job for a generic validator based on json schema? + if (!AssetsUtils::loadAssetsIndexJson(assets->id, asset_fname, index)) { + auto metacache = APPLICATION->metacache(); + auto entry = metacache->resolveEntry("asset_indexes", assets->id + ".json"); + metacache->evictEntry(entry); + emitFailed(tr("Failed to read the assets index!")); + return; + } + + auto job = index.getDownloadJob(); + if (job) { + QString resourceURL = resourceUrl(); + QString source = tr("Mojang"); + if (resourceURL != BuildConfig.DEFAULT_RESOURCE_BASE) { + source = QUrl(resourceURL).host(); + } + setStatus(tr("Getting the asset files from %1...").arg(source)); + downloadJob = job; + connect(downloadJob.get(), &NetJob::succeeded, this, &AssetUpdateTask::emitSucceeded); + connect(downloadJob.get(), &NetJob::failed, this, &AssetUpdateTask::assetsFailed); + connect(downloadJob.get(), &NetJob::aborted, this, &AssetUpdateTask::emitAborted); + connect(downloadJob.get(), &NetJob::progress, this, &AssetUpdateTask::progress); + connect(downloadJob.get(), &NetJob::stepProgress, this, &AssetUpdateTask::propagateStepProgress); + downloadJob->start(); + return; + } + emitSucceeded(); +} + +void AssetUpdateTask::assetIndexFailed(QString reason) +{ + qDebug() << m_inst->name() << ": Failed asset index download"; + emitFailed(tr("Failed to download the assets index:\n%1").arg(reason)); +} + +void AssetUpdateTask::assetsFailed(QString reason) +{ + emitFailed(tr("Failed to download assets:\n%1").arg(reason)); +} + +bool AssetUpdateTask::abort() +{ + if (downloadJob) { + return downloadJob->abort(); + } else { + qWarning() << "Prematurely aborted AssetUpdateTask"; + } + return true; +} + +QString AssetUpdateTask::resourceUrl() +{ + if (const QString urlOverride = APPLICATION->settings()->get("ResourceURLOverride").toString(); !urlOverride.isEmpty()) { + return urlOverride; + } + + return BuildConfig.DEFAULT_RESOURCE_BASE; +} diff --git a/launcher/minecraft/update/AssetUpdateTask.h b/launcher/minecraft/update/AssetUpdateTask.h new file mode 100644 index 0000000..56aecc2 --- /dev/null +++ b/launcher/minecraft/update/AssetUpdateTask.h @@ -0,0 +1,30 @@ +#pragma once +#include "net/NetJob.h" +#include "tasks/Task.h" +class MinecraftInstance; + +class AssetUpdateTask : public Task { + Q_OBJECT + public: + AssetUpdateTask(MinecraftInstance* inst); + virtual ~AssetUpdateTask() = default; + + void executeTask() override; + + bool canAbort() const override; + + public: + static QString resourceUrl(); + + private slots: + void assetIndexFinished(); + void assetIndexFailed(QString reason); + void assetsFailed(QString reason); + + public slots: + bool abort() override; + + private: + MinecraftInstance* m_inst; + NetJob::Ptr downloadJob; +}; diff --git a/launcher/minecraft/update/FoldersTask.cpp b/launcher/minecraft/update/FoldersTask.cpp new file mode 100644 index 0000000..7d6fc43 --- /dev/null +++ b/launcher/minecraft/update/FoldersTask.cpp @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "FoldersTask.h" +#include +#include "minecraft/MinecraftInstance.h" + +FoldersTask::FoldersTask(MinecraftInstance* inst) +{ + m_inst = inst; +} + +void FoldersTask::executeTask() +{ + // Make directories + QDir mcDir(m_inst->gameRoot()); + if (!mcDir.exists() && !mcDir.mkpath(".")) { + emitFailed(tr("Failed to create folder for Minecraft binaries.")); + return; + } + emitSucceeded(); +} diff --git a/launcher/minecraft/update/FoldersTask.h b/launcher/minecraft/update/FoldersTask.h new file mode 100644 index 0000000..7df7ef8 --- /dev/null +++ b/launcher/minecraft/update/FoldersTask.h @@ -0,0 +1,16 @@ +#pragma once + +#include "tasks/Task.h" + +class MinecraftInstance; +class FoldersTask : public Task { + Q_OBJECT + public: + FoldersTask(MinecraftInstance* inst); + virtual ~FoldersTask() = default; + + void executeTask() override; + + private: + MinecraftInstance* m_inst; +}; diff --git a/launcher/minecraft/update/LegacyFMLLibrariesTask.cpp b/launcher/minecraft/update/LegacyFMLLibrariesTask.cpp new file mode 100644 index 0000000..a3bcc14 --- /dev/null +++ b/launcher/minecraft/update/LegacyFMLLibrariesTask.cpp @@ -0,0 +1,135 @@ +#include "LegacyFMLLibrariesTask.h" + +#include "FileSystem.h" +#include "minecraft/MinecraftInstance.h" +#include "minecraft/PackProfile.h" +#include "minecraft/VersionFilterData.h" + +#include "Application.h" +#include "BuildConfig.h" + +#include "net/ApiDownload.h" + +LegacyFMLLibrariesTask::LegacyFMLLibrariesTask(MinecraftInstance* inst) +{ + m_inst = inst; +} +void LegacyFMLLibrariesTask::executeTask() +{ + // Get the mod list + MinecraftInstance* inst = (MinecraftInstance*)m_inst; + auto components = inst->getPackProfile(); + auto profile = components->getProfile(); + + if (!profile->hasTrait("legacyFML")) { + emitSucceeded(); + return; + } + + QString version = components->getComponentVersion("net.minecraft"); + auto& fmlLibsMapping = g_VersionFilterData.fmlLibsMapping; + if (!fmlLibsMapping.contains(version)) { + emitSucceeded(); + return; + } + + auto& libList = fmlLibsMapping[version]; + + // determine if we need some libs for FML or forge + setStatus(tr("Checking for FML libraries...")); + if (!components->getComponent("net.minecraftforge")) { + emitSucceeded(); + return; + } + + // now check the lib folder inside the instance for files. + for (auto& lib : libList) { + QFileInfo libInfo(FS::PathCombine(inst->libDir(), lib.filename)); + if (libInfo.exists()) + continue; + fmlLibsToProcess.append(lib); + } + + // if everything is in place, there's nothing to do here... + if (fmlLibsToProcess.isEmpty()) { + emitSucceeded(); + return; + } + + // download missing libs to our place + setStatus(tr("Downloading FML libraries...")); + NetJob::Ptr dljob{ new NetJob("FML libraries", APPLICATION->network()) }; + auto metacache = APPLICATION->metacache(); + Net::Download::Options options = Net::Download::Option::MakeEternal; + const QString base = baseUrl(); + for (auto& lib : fmlLibsToProcess) { + auto entry = metacache->resolveEntry("fmllibs", lib.filename); + QString urlString = base + lib.filename; + dljob->addNetAction(Net::ApiDownload::makeCached(QUrl(urlString), entry, options)); + } + + connect(dljob.get(), &NetJob::succeeded, this, &LegacyFMLLibrariesTask::fmllibsFinished); + connect(dljob.get(), &NetJob::failed, this, &LegacyFMLLibrariesTask::fmllibsFailed); + connect(dljob.get(), &NetJob::aborted, this, &LegacyFMLLibrariesTask::emitAborted); + connect(dljob.get(), &NetJob::progress, this, &LegacyFMLLibrariesTask::progress); + connect(dljob.get(), &NetJob::stepProgress, this, &LegacyFMLLibrariesTask::propagateStepProgress); + downloadJob.reset(dljob); + downloadJob->start(); +} + +bool LegacyFMLLibrariesTask::canAbort() const +{ + return true; +} + +void LegacyFMLLibrariesTask::fmllibsFinished() +{ + downloadJob.reset(); + if (!fmlLibsToProcess.isEmpty()) { + setStatus(tr("Copying FML libraries into the instance...")); + MinecraftInstance* inst = (MinecraftInstance*)m_inst; + auto metacache = APPLICATION->metacache(); + int index = 0; + for (auto& lib : fmlLibsToProcess) { + progress(index, fmlLibsToProcess.size()); + auto entry = metacache->resolveEntry("fmllibs", lib.filename); + auto path = FS::PathCombine(inst->libDir(), lib.filename); + if (!FS::ensureFilePathExists(path)) { + emitFailed(tr("Failed creating FML library folder inside the instance.")); + return; + } + if (!QFile::copy(entry->getFullPath(), FS::PathCombine(inst->libDir(), lib.filename))) { + emitFailed(tr("Failed copying Forge/FML library: %1.").arg(lib.filename)); + return; + } + index++; + } + progress(index, fmlLibsToProcess.size()); + } + emitSucceeded(); +} +void LegacyFMLLibrariesTask::fmllibsFailed(QString reason) +{ + QStringList failed = downloadJob->getFailedFiles(); + QString failed_all = failed.join("\n"); + emitFailed(tr("Failed to download the following files:\n%1\n\nReason:%2\nPlease try again.").arg(failed_all, reason)); +} + +bool LegacyFMLLibrariesTask::abort() +{ + if (downloadJob) { + return downloadJob->abort(); + } else { + qWarning() << "Prematurely aborted LegacyFMLLibrariesTask"; + } + return true; +} + +QString LegacyFMLLibrariesTask::baseUrl() +{ + if (const QString urlOverride = APPLICATION->settings()->get("LegacyFMLLibsURLOverride").toString(); !urlOverride.isEmpty()) { + return urlOverride; + } + + return BuildConfig.LEGACY_FMLLIBS_BASE_URL; +} diff --git a/launcher/minecraft/update/LegacyFMLLibrariesTask.h b/launcher/minecraft/update/LegacyFMLLibrariesTask.h new file mode 100644 index 0000000..2591f7c --- /dev/null +++ b/launcher/minecraft/update/LegacyFMLLibrariesTask.h @@ -0,0 +1,32 @@ +#pragma once +#include "minecraft/VersionFilterData.h" +#include "net/NetJob.h" +#include "tasks/Task.h" + +class MinecraftInstance; + +class LegacyFMLLibrariesTask : public Task { + Q_OBJECT + public: + LegacyFMLLibrariesTask(MinecraftInstance* inst); + virtual ~LegacyFMLLibrariesTask() = default; + + void executeTask() override; + + bool canAbort() const override; + + private slots: + void fmllibsFinished(); + void fmllibsFailed(QString reason); + + public slots: + bool abort() override; + + private: + static QString baseUrl(); + + private: + MinecraftInstance* m_inst; + NetJob::Ptr downloadJob; + QList fmlLibsToProcess; +}; diff --git a/launcher/minecraft/update/LibrariesTask.cpp b/launcher/minecraft/update/LibrariesTask.cpp new file mode 100644 index 0000000..f725af1 --- /dev/null +++ b/launcher/minecraft/update/LibrariesTask.cpp @@ -0,0 +1,92 @@ +#include "LibrariesTask.h" + +#include "minecraft/MinecraftInstance.h" +#include "minecraft/PackProfile.h" + +#include "Application.h" + +LibrariesTask::LibrariesTask(MinecraftInstance* inst) +{ + m_inst = inst; +} + +void LibrariesTask::executeTask() +{ + setStatus(tr("Downloading required library files...")); + qDebug() << m_inst->name() << ": downloading libraries"; + MinecraftInstance* inst = (MinecraftInstance*)m_inst; + + // Build a list of URLs that will need to be downloaded. + auto components = inst->getPackProfile(); + auto profile = components->getProfile(); + + NetJob::Ptr job{ new NetJob(tr("Libraries for instance %1").arg(inst->name()), APPLICATION->network()) }; + downloadJob.reset(job); + + auto metacache = APPLICATION->metacache(); + + auto processArtifactPool = [this, inst, metacache](const QList& pool, QStringList& errors, const QString& localPath) { + for (auto lib : pool) { + if (!lib) { + emitFailed(tr("Null jar is specified in the metadata, aborting.")); + return false; + } + auto dls = lib->getDownloads(inst->runtimeContext(), metacache, errors, localPath); + for (auto dl : dls) { + downloadJob->addNetAction(dl); + } + } + return true; + }; + + QStringList failedLocalLibraries; + QList libArtifactPool; + libArtifactPool.append(profile->getLibraries()); + libArtifactPool.append(profile->getNativeLibraries()); + libArtifactPool.append(profile->getMavenFiles()); + for (const auto& agent : profile->getAgents()) { + libArtifactPool.append(agent.library); + } + libArtifactPool.append(profile->getMainJar()); + processArtifactPool(libArtifactPool, failedLocalLibraries, inst->getLocalLibraryPath()); + + QStringList failedLocalJarMods; + processArtifactPool(profile->getJarMods(), failedLocalJarMods, inst->jarModsDir()); + + if (!failedLocalJarMods.empty() || !failedLocalLibraries.empty()) { + downloadJob.reset(); + QString failed_all = (failedLocalLibraries + failedLocalJarMods).join("\n"); + emitFailed(tr("Some artifacts marked as 'local' are missing their files:\n%1\n\nYou need to either add the files, or removed the " + "packages that require them.\nYou'll have to correct this problem manually.") + .arg(failed_all)); + return; + } + + connect(downloadJob.get(), &NetJob::succeeded, this, &LibrariesTask::emitSucceeded); + connect(downloadJob.get(), &NetJob::failed, this, &LibrariesTask::jarlibFailed); + connect(downloadJob.get(), &NetJob::aborted, this, &LibrariesTask::emitAborted); + connect(downloadJob.get(), &NetJob::progress, this, &LibrariesTask::progress); + connect(downloadJob.get(), &NetJob::stepProgress, this, &LibrariesTask::propagateStepProgress); + + downloadJob->start(); +} + +bool LibrariesTask::canAbort() const +{ + return true; +} + +void LibrariesTask::jarlibFailed(QString reason) +{ + emitFailed(tr("Game update failed: it was impossible to fetch the required libraries.\nReason:\n%1").arg(reason)); +} + +bool LibrariesTask::abort() +{ + if (downloadJob) { + return downloadJob->abort(); + } else { + qWarning() << "Prematurely aborted LibrariesTask"; + } + return true; +} diff --git a/launcher/minecraft/update/LibrariesTask.h b/launcher/minecraft/update/LibrariesTask.h new file mode 100644 index 0000000..838f9d9 --- /dev/null +++ b/launcher/minecraft/update/LibrariesTask.h @@ -0,0 +1,25 @@ +#pragma once +#include "net/NetJob.h" +#include "tasks/Task.h" +class MinecraftInstance; + +class LibrariesTask : public Task { + Q_OBJECT + public: + LibrariesTask(MinecraftInstance* inst); + virtual ~LibrariesTask() = default; + + void executeTask() override; + + bool canAbort() const override; + + private slots: + void jarlibFailed(QString reason); + + public slots: + bool abort() override; + + private: + MinecraftInstance* m_inst; + NetJob::Ptr downloadJob; +}; diff --git a/launcher/modplatform/CheckUpdateTask.h b/launcher/modplatform/CheckUpdateTask.h new file mode 100644 index 0000000..d3f577f --- /dev/null +++ b/launcher/modplatform/CheckUpdateTask.h @@ -0,0 +1,74 @@ +#pragma once + +#include "minecraft/mod/tasks/GetModDependenciesTask.h" +#include "modplatform/ModIndex.h" +#include "tasks/Task.h" + +class ResourceDownloadTask; +class ModFolderModel; + +class CheckUpdateTask : public Task { + Q_OBJECT + + public: + CheckUpdateTask(QList& resources, + std::vector& mcVersions, + QList loadersList, + ResourceFolderModel* resourceModel) + : Task(), m_resources(resources), m_gameVersions(mcVersions), m_loadersList(std::move(loadersList)), m_resourceModel(resourceModel) + {} + + struct Update { + QString name; + QString old_hash; + QString old_version; + QString new_version; + std::optional new_version_type; + QString changelog; + ModPlatform::ResourceProvider provider; + shared_qobject_ptr download; + bool enabled = true; + + public: + Update(QString name, + QString old_h, + QString old_v, + QString new_v, + std::optional new_v_type, + QString changelog, + ModPlatform::ResourceProvider p, + shared_qobject_ptr t, + bool enabled = true) + : name(std::move(name)) + , old_hash(std::move(old_h)) + , old_version(std::move(old_v)) + , new_version(std::move(new_v)) + , new_version_type(std::move(new_v_type)) + , changelog(std::move(changelog)) + , provider(p) + , download(std::move(t)) + , enabled(enabled) + {} + }; + + auto getUpdates() -> std::vector&& { return std::move(m_updates); } + auto getDependencies() -> QList>&& { return std::move(m_deps); } + + public slots: + bool abort() override = 0; + + protected slots: + void executeTask() override = 0; + + signals: + void checkFailed(Resource* failed, QString reason, QUrl recover_url = {}); + + protected: + QList& m_resources; + std::vector& m_gameVersions; + QList m_loadersList; + ResourceFolderModel* m_resourceModel; + + std::vector m_updates; + QList> m_deps; +}; diff --git a/launcher/modplatform/EnsureMetadataTask.cpp b/launcher/modplatform/EnsureMetadataTask.cpp new file mode 100644 index 0000000..1ef3a56 --- /dev/null +++ b/launcher/modplatform/EnsureMetadataTask.cpp @@ -0,0 +1,513 @@ +#include "EnsureMetadataTask.h" + +#include +#include + +#include "Application.h" +#include "Json.h" + +#include "QObjectPtr.h" +#include "minecraft/mod/Mod.h" +#include "minecraft/mod/tasks/LocalResourceUpdateTask.h" + +#include "modplatform/flame/FlameAPI.h" +#include "modplatform/flame/FlameModIndex.h" +#include "modplatform/helpers/HashUtils.h" +#include "modplatform/modrinth/ModrinthAPI.h" +#include "modplatform/modrinth/ModrinthPackIndex.h" + +static ModrinthAPI modrinth_api; +static FlameAPI flame_api; + +EnsureMetadataTask::EnsureMetadataTask(Resource* resource, QDir dir, ModPlatform::ResourceProvider prov) + : Task(), m_indexDir(dir), m_provider(prov), m_hashingTask(nullptr), m_currentTask(nullptr) +{ + auto hashTask = createNewHash(resource); + if (!hashTask) + return; + connect(hashTask.get(), &Hashing::Hasher::resultsReady, [this, resource](QString hash) { m_resources.insert(hash, resource); }); + connect(hashTask.get(), &Task::failed, [this, resource] { emitFail(resource, "", RemoveFromList::No); }); + m_hashingTask = hashTask; +} + +EnsureMetadataTask::EnsureMetadataTask(QList& resources, QDir dir, ModPlatform::ResourceProvider prov) + : Task(), m_indexDir(dir), m_provider(prov), m_currentTask(nullptr) +{ + auto hashTask = makeShared("MakeHashesTask", APPLICATION->settings()->get("NumberOfConcurrentTasks").toInt()); + m_hashingTask = hashTask; + for (auto* resource : resources) { + auto hash_task = createNewHash(resource); + if (!hash_task) + continue; + connect(hash_task.get(), &Hashing::Hasher::resultsReady, [this, resource](QString hash) { m_resources.insert(hash, resource); }); + connect(hash_task.get(), &Task::failed, [this, resource] { emitFail(resource, "", RemoveFromList::No); }); + hashTask->addTask(hash_task); + } +} + +EnsureMetadataTask::EnsureMetadataTask(QHash& resources, QDir dir, ModPlatform::ResourceProvider prov) + : Task(), m_resources(resources), m_indexDir(dir), m_provider(prov), m_currentTask(nullptr) +{} + +Hashing::Hasher::Ptr EnsureMetadataTask::createNewHash(Resource* resource) +{ + if (!resource || !resource->valid() || resource->type() == ResourceType::FOLDER) + return nullptr; + + return Hashing::createHasher(resource->fileinfo().absoluteFilePath(), m_provider); +} + +QString EnsureMetadataTask::getExistingHash(Resource* resource) +{ + // Check for already computed hashes + // (linear on the number of mods vs. linear on the size of the mod's JAR) + auto it = m_resources.keyValueBegin(); + while (it != m_resources.keyValueEnd()) { + if ((*it).second == resource) + break; + it++; + } + + // We already have the hash computed + if (it != m_resources.keyValueEnd()) { + return (*it).first; + } + + // No existing hash + return {}; +} + +bool EnsureMetadataTask::abort() +{ + // Prevent sending signals to a dead object + disconnect(this, 0, 0, 0); + + if (m_currentTask) + return m_currentTask->abort(); + return true; +} + +void EnsureMetadataTask::executeTask() +{ + setStatus(tr("Checking if resources have metadata...")); + + for (auto* resource : m_resources) { + if (!resource->valid()) { + qDebug() << "Resource" << resource->name() << "is invalid!"; + emitFail(resource); + continue; + } + + // They already have the right metadata :o + if (resource->status() != ResourceStatus::NO_METADATA && resource->metadata() && resource->metadata()->provider == m_provider) { + qDebug() << "Resource" << resource->name() << "already has metadata!"; + emitReady(resource); + continue; + } + + // Folders don't have metadata + if (resource->type() == ResourceType::FOLDER) { + emitReady(resource); + } + } + + Task::Ptr version_task; + + switch (m_provider) { + case (ModPlatform::ResourceProvider::MODRINTH): + version_task = modrinthVersionsTask(); + break; + case (ModPlatform::ResourceProvider::FLAME): + version_task = flameVersionsTask(); + break; + } + + auto invalidade_leftover = [this] { + for (auto resource = m_resources.constBegin(); resource != m_resources.constEnd(); resource++) + emitFail(resource.value(), resource.key(), RemoveFromList::No); + m_resources.clear(); + + emitSucceeded(); + }; + + connect(version_task.get(), &Task::finished, this, [this, invalidade_leftover] { + Task::Ptr project_task; + + switch (m_provider) { + case (ModPlatform::ResourceProvider::MODRINTH): + project_task = modrinthProjectsTask(); + break; + case (ModPlatform::ResourceProvider::FLAME): + project_task = flameProjectsTask(); + break; + } + + if (!project_task) { + invalidade_leftover(); + return; + } + + connect(project_task.get(), &Task::finished, this, [this, invalidade_leftover, project_task] { + invalidade_leftover(); + project_task->deleteLater(); + if (m_currentTask) + m_currentTask.reset(); + }); + connect(project_task.get(), &Task::failed, this, &EnsureMetadataTask::emitFailed); + + m_currentTask = project_task; + project_task->start(); + }); + + if (m_resources.size() > 1) + setStatus(tr("Requesting metadata information from %1...").arg(ModPlatform::ProviderCapabilities::readableName(m_provider))); + else if (!m_resources.empty()) + setStatus(tr("Requesting metadata information from %1 for '%2'...") + .arg(ModPlatform::ProviderCapabilities::readableName(m_provider), m_resources.begin().value()->name())); + + m_currentTask = version_task; + version_task->start(); +} + +void EnsureMetadataTask::emitReady(Resource* resource, QString key, RemoveFromList remove) +{ + if (!resource) { + qCritical() << "Tried to mark a null resource as ready."; + if (!key.isEmpty()) + m_resources.remove(key); + + return; + } + + qDebug() << QString("Generated metadata for %1").arg(resource->name()); + emit metadataReady(resource); + + if (remove == RemoveFromList::Yes) { + if (key.isEmpty()) + key = getExistingHash(resource); + m_resources.remove(key); + } +} + +void EnsureMetadataTask::emitFail(Resource* resource, QString key, RemoveFromList remove) +{ + if (!resource) { + qCritical() << "Tried to mark a null resource as failed."; + if (!key.isEmpty()) + m_resources.remove(key); + + return; + } + + qDebug() << QString("Failed to generate metadata for %1").arg(resource->name()); + emit metadataFailed(resource); + + if (remove == RemoveFromList::Yes) { + if (key.isEmpty()) + key = getExistingHash(resource); + m_resources.remove(key); + } +} + +// Modrinth + +Task::Ptr EnsureMetadataTask::modrinthVersionsTask() +{ + auto hash_type = ModPlatform::ProviderCapabilities::hashType(ModPlatform::ResourceProvider::MODRINTH).first(); + + auto [ver_task, response] = modrinth_api.currentVersions(m_resources.keys(), hash_type); + + // Prevents unfortunate timings when aborting the task + if (!ver_task) + return Task::Ptr{ nullptr }; + + connect(ver_task.get(), &Task::succeeded, this, [this, response] { + QJsonParseError parse_error{}; + QJsonDocument doc = QJsonDocument::fromJson(*response, &parse_error); + if (parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from Modrinth::CurrentVersions at" << parse_error.offset + << "reason:" << parse_error.errorString(); + qWarning() << *response; + + failed(parse_error.errorString()); + return; + } + + try { + auto entries = Json::requireObject(doc); + for (auto& hash : m_resources.keys()) { + auto resource = m_resources.find(hash).value(); + try { + auto entry = Json::requireObject(entries, hash); + + setStatus(tr("Parsing API response from Modrinth for '%1'...").arg(resource->name())); + qDebug() << "Getting version for" << resource->name() << "from Modrinth"; + + m_tempVersions.insert(hash, Modrinth::loadIndexedPackVersion(entry)); + } catch (Json::JsonException& e) { + qDebug() << e.cause(); + qDebug() << entries; + + emitFail(resource); + } + } + } catch (Json::JsonException& e) { + qDebug() << e.cause(); + qDebug() << doc; + } + }); + + return ver_task; +} + +Task::Ptr EnsureMetadataTask::modrinthProjectsTask() +{ + QHash addonIds; + for (auto const& data : m_tempVersions) + addonIds.insert(data.addonId.toString(), data.hash); + + Task::Ptr proj_task; + QByteArray* response; + + if (addonIds.isEmpty()) { + qWarning() << "No addonId found!"; + } else if (addonIds.size() == 1) { + std::tie(proj_task, response) = modrinth_api.getProject(*addonIds.keyBegin()); + } else { + std::tie(proj_task, response) = modrinth_api.getProjects(addonIds.keys()); + } + + // Prevents unfortunate timings when aborting the task + if (!proj_task) + return Task::Ptr{ nullptr }; + + connect(proj_task.get(), &Task::succeeded, this, [this, response, addonIds] { + QJsonParseError parse_error{}; + auto doc = QJsonDocument::fromJson(*response, &parse_error); + if (parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from Modrinth projects task at" << parse_error.offset + << "reason:" << parse_error.errorString(); + qWarning() << *response; + return; + } + + QJsonArray entries; + + try { + if (addonIds.size() == 1) + entries = { doc.object() }; + else + entries = Json::requireArray(doc); + } catch (Json::JsonException& e) { + qDebug() << e.cause(); + qDebug() << doc; + } + + for (auto entry : entries) { + ModPlatform::IndexedPack pack; + + try { + auto entry_obj = Json::requireObject(entry); + + Modrinth::loadIndexedPack(pack, entry_obj); + } catch (Json::JsonException& e) { + qDebug() << e.cause(); + qDebug() << doc; + + // Skip this entry, since it has problems + continue; + } + + auto hash = addonIds.find(pack.addonId.toString()).value(); + + auto resource_iter = m_resources.find(hash); + if (resource_iter == m_resources.end()) { + qWarning() << "Invalid project id from the API response."; + continue; + } + + auto* resource = resource_iter.value(); + + setStatus(tr("Parsing API response from Modrinth for '%1'...").arg(resource->name())); + + updateMetadata(pack, m_tempVersions.find(hash).value(), resource); + } + }); + + return proj_task; +} + +// Flame +Task::Ptr EnsureMetadataTask::flameVersionsTask() +{ + QList fingerprints; + for (auto& murmur : m_resources.keys()) { + fingerprints.push_back(murmur.toUInt()); + } + + auto [ver_task, response] = flame_api.matchFingerprints(fingerprints); + + connect(ver_task.get(), &Task::succeeded, this, [this, response] { + QJsonParseError parse_error{}; + QJsonDocument doc = QJsonDocument::fromJson(*response, &parse_error); + if (parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from Flame::CurrentVersions at" << parse_error.offset + << "reason:" << parse_error.errorString(); + qWarning() << *response; + + failed(parse_error.errorString()); + return; + } + + try { + auto doc_obj = Json::requireObject(doc); + auto data_obj = Json::requireObject(doc_obj, "data"); + auto data_arr = Json::requireArray(data_obj, "exactMatches"); + + if (data_arr.isEmpty()) { + qWarning() << "No matches found for fingerprint search!"; + + return; + } + + for (auto match : data_arr) { + auto match_obj = match.toObject(); + auto file_obj = match_obj["file"].toObject(); + + if (match_obj.isEmpty() || file_obj.isEmpty()) { + qWarning() << "Fingerprint match is empty!"; + + return; + } + + auto fingerprint = QString::number(file_obj["fileFingerprint"].toInteger()); + auto resource = m_resources.find(fingerprint); + if (resource == m_resources.end()) { + qWarning() << "Invalid fingerprint from the API response."; + continue; + } + + setStatus(tr("Parsing API response from CurseForge for '%1'...").arg((*resource)->name())); + + m_tempVersions.insert(fingerprint, FlameMod::loadIndexedPackVersion(file_obj)); + } + + } catch (Json::JsonException& e) { + qDebug() << e.cause(); + qDebug() << doc; + } + }); + + return ver_task; +} + +Task::Ptr EnsureMetadataTask::flameProjectsTask() +{ + QHash addonIds; + for (auto const& hash : m_resources.keys()) { + if (m_tempVersions.contains(hash)) { + auto data = m_tempVersions.find(hash).value(); + + auto id_str = data.addonId.toString(); + if (!id_str.isEmpty()) + addonIds.insert(data.addonId.toString(), hash); + } + } + + Task::Ptr proj_task; + QByteArray* response; + + if (addonIds.isEmpty()) { + qWarning() << "No addonId found!"; + } else if (addonIds.size() == 1) { + std::tie(proj_task, response) = flame_api.getProject(*addonIds.keyBegin()); + } else { + std::tie(proj_task, response) = flame_api.getProjects(addonIds.keys()); + } + + // Prevents unfortunate timings when aborting the task + if (!proj_task) + return Task::Ptr{ nullptr }; + + connect(proj_task.get(), &Task::succeeded, this, [this, response, addonIds] { + QJsonParseError parse_error{}; + auto doc = QJsonDocument::fromJson(*response, &parse_error); + if (parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from Flame projects task at" << parse_error.offset + << "reason:" << parse_error.errorString(); + qWarning() << *response; + return; + } + + try { + QJsonArray entries; + if (addonIds.size() == 1) + entries = { Json::requireObject(Json::requireObject(doc), "data") }; + else + entries = Json::requireArray(Json::requireObject(doc), "data"); + + for (auto entry : entries) { + auto entry_obj = Json::requireObject(entry); + + auto id = QString::number(Json::requireInteger(entry_obj, "id")); + auto hash = addonIds.find(id).value(); + auto resource = m_resources.find(hash).value(); + + ModPlatform::IndexedPack pack; + try { + setStatus(tr("Parsing API response from CurseForge for '%1'...").arg(resource->name())); + + FlameMod::loadIndexedPack(pack, entry_obj); + + } catch (Json::JsonException& e) { + qDebug() << e.cause(); + qDebug() << entries; + + emitFail(resource); + } + updateMetadata(pack, m_tempVersions.find(hash).value(), resource); + } + } catch (Json::JsonException& e) { + qDebug() << e.cause(); + qDebug() << doc; + } + }); + + return proj_task; +} + +void EnsureMetadataTask::updateMetadata(ModPlatform::IndexedPack& pack, ModPlatform::IndexedVersion& ver, Resource* resource) +{ + try { + // Prevent file name mismatch + ver.fileName = resource->fileinfo().fileName(); + if (ver.fileName.endsWith(".disabled")) + ver.fileName.chop(9); + + auto task = makeShared(m_indexDir, pack, ver); + + connect(task.get(), &Task::finished, this, [this, &pack, resource] { updateMetadataCallback(pack, resource); }); + + m_updateMetadataTasks[ModPlatform::ProviderCapabilities::name(pack.provider) + pack.addonId.toString()] = task; + task->start(); + } catch (Json::JsonException& e) { + qDebug() << e.cause(); + + emitFail(resource); + } +} + +void EnsureMetadataTask::updateMetadataCallback(ModPlatform::IndexedPack& pack, Resource* resource) +{ + QDir tmpIndexDir(m_indexDir); + auto metadata = Metadata::get(tmpIndexDir, pack.slug); + if (!metadata.isValid()) { + qCritical() << "Failed to generate metadata at last step!"; + emitFail(resource); + return; + } + + resource->setMetadata(metadata); + + emitReady(resource); +} diff --git a/launcher/modplatform/EnsureMetadataTask.h b/launcher/modplatform/EnsureMetadataTask.h new file mode 100644 index 0000000..3d8a8ba --- /dev/null +++ b/launcher/modplatform/EnsureMetadataTask.h @@ -0,0 +1,65 @@ +#pragma once + +#include "ModIndex.h" +#include "net/NetJob.h" + +#include "modplatform/helpers/HashUtils.h" + +#include "minecraft/mod/Resource.h" +#include "tasks/ConcurrentTask.h" + +class Mod; +class QDir; + +class EnsureMetadataTask : public Task { + Q_OBJECT + + public: + EnsureMetadataTask(Resource*, QDir, ModPlatform::ResourceProvider = ModPlatform::ResourceProvider::MODRINTH); + EnsureMetadataTask(QList&, QDir, ModPlatform::ResourceProvider = ModPlatform::ResourceProvider::MODRINTH); + EnsureMetadataTask(QHash&, QDir, ModPlatform::ResourceProvider = ModPlatform::ResourceProvider::MODRINTH); + + ~EnsureMetadataTask() = default; + + Task::Ptr getHashingTask() { return m_hashingTask; } + + public slots: + bool abort() override; + protected slots: + void executeTask() override; + + private: + // FIXME: Move to their own namespace + Task::Ptr modrinthVersionsTask(); + Task::Ptr modrinthProjectsTask(); + + Task::Ptr flameVersionsTask(); + Task::Ptr flameProjectsTask(); + + // Helpers + enum class RemoveFromList { Yes, No }; + void emitReady(Resource*, QString key = {}, RemoveFromList = RemoveFromList::Yes); + void emitFail(Resource*, QString key = {}, RemoveFromList = RemoveFromList::Yes); + + // Hashes and stuff + Hashing::Hasher::Ptr createNewHash(Resource*); + QString getExistingHash(Resource*); + + private slots: + void updateMetadata(ModPlatform::IndexedPack& pack, ModPlatform::IndexedVersion& ver, Resource*); + void updateMetadataCallback(ModPlatform::IndexedPack& pack, Resource* resource); + + signals: + void metadataReady(Resource*); + void metadataFailed(Resource*); + + private: + QHash m_resources; + QDir m_indexDir; + ModPlatform::ResourceProvider m_provider; + + QHash m_tempVersions; + Task::Ptr m_hashingTask; + Task::Ptr m_currentTask; + QHash m_updateMetadataTasks; +}; diff --git a/launcher/modplatform/ModIndex.cpp b/launcher/modplatform/ModIndex.cpp new file mode 100644 index 0000000..635b2ae --- /dev/null +++ b/launcher/modplatform/ModIndex.cpp @@ -0,0 +1,222 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "modplatform/ModIndex.h" + +#include +#include +#include + +namespace ModPlatform { + +ModLoaderType operator|(ModLoaderType lhs, ModLoaderType rhs) +{ + return static_cast(static_cast(lhs) | static_cast(rhs)); +} + +static const QMap s_indexed_version_type_names = { { "release", IndexedVersionType::Release }, + { "beta", IndexedVersionType::Beta }, + { "alpha", IndexedVersionType::Alpha } }; + +static const QList loaderList = { NeoForge, Forge, Cauldron, LiteLoader, Quilt, Fabric, + Babric, BTA, LegacyFabric, Ornithe, Rift }; + +QList modLoaderTypesToList(ModLoaderTypes flags) +{ + QList flagList; + for (auto flag : loaderList) { + if (flags.testFlag(flag)) { + flagList.append(flag); + } + } + return flagList; +} + +QString IndexedVersionType::toString() const +{ + return s_indexed_version_type_names.key(m_type, "unknown"); +} + +IndexedVersionType IndexedVersionType::fromString(const QString& type) +{ + return s_indexed_version_type_names.value(type, IndexedVersionType::Unknown); +} + +const char* ProviderCapabilities::name(ResourceProvider p) +{ + switch (p) { + case ResourceProvider::MODRINTH: + return "modrinth"; + case ResourceProvider::FLAME: + return "curseforge"; + } + return {}; +} + +QString ProviderCapabilities::readableName(ResourceProvider p) +{ + switch (p) { + case ResourceProvider::MODRINTH: + return "Modrinth"; + case ResourceProvider::FLAME: + return "CurseForge"; + } + return {}; +} + +QStringList ProviderCapabilities::hashType(ResourceProvider p) +{ + switch (p) { + case ResourceProvider::MODRINTH: + return { "sha512", "sha1" }; + case ResourceProvider::FLAME: + // Try newer formats first, fall back to old format + return { "sha1", "md5", "murmur2" }; + } + return {}; +} + +QString getMetaURL(ResourceProvider provider, QVariant projectID) +{ + return ((provider == ModPlatform::ResourceProvider::FLAME) ? "https://www.curseforge.com/projects/" : "https://modrinth.com/mod/") + + projectID.toString(); +} + +auto getModLoaderAsString(ModLoaderType type) -> const QString +{ + switch (type) { + case NeoForge: + return "neoforge"; + case Forge: + return "forge"; + case Cauldron: + return "cauldron"; + case LiteLoader: + return "liteloader"; + case Fabric: + return "fabric"; + case Quilt: + return "quilt"; + case DataPack: + return "datapack"; + case Babric: + return "babric"; + case BTA: + return "bta-babric"; + case LegacyFabric: + return "legacy-fabric"; + case Ornithe: + return "ornithe"; + case Rift: + return "rift"; + default: + break; + } + return ""; +} + +auto getModLoaderFromString(QString type) -> ModLoaderType +{ + if (type == "neoforge") + return NeoForge; + if (type == "forge") + return Forge; + if (type == "cauldron") + return Cauldron; + if (type == "liteloader") + return LiteLoader; + if (type == "fabric") + return Fabric; + if (type == "quilt") + return Quilt; + if (type == "babric") + return Babric; + if (type == "bta-babric") + return BTA; + if (type == "legacy-fabric") + return LegacyFabric; + if (type == "ornithe") + return Ornithe; + if (type == "rift") + return Rift; + return {}; +} + +QString SideUtils::toString(Side side) +{ + switch (side) { + case Side::ClientSide: + return "client"; + case Side::ServerSide: + return "server"; + case Side::UniversalSide: + return "both"; + case Side::NoSide: + break; + } + return {}; +} + +Side SideUtils::fromString(QString side) +{ + if (side == "client") + return Side::ClientSide; + if (side == "server") + return Side::ServerSide; + if (side == "both") + return Side::UniversalSide; + return Side::UniversalSide; +} + +QString DependencyTypeUtils::toString(DependencyType type) +{ + switch (type) { + case DependencyType::REQUIRED: + return "REQUIRED"; + case DependencyType::OPTIONAL: + return "OPTIONAL"; + case DependencyType::INCOMPATIBLE: + return "INCOMPATIBLE"; + case DependencyType::EMBEDDED: + return "EMBEDDED"; + case DependencyType::TOOL: + return "TOOL"; + case DependencyType::INCLUDE: + return "INCLUDE"; + case DependencyType::UNKNOWN: + return "UNKNOWN"; + } + return "UNKNOWN"; +} + +DependencyType DependencyTypeUtils::fromString(const QString& str) +{ + static const QHash map = { + { "REQUIRED", DependencyType::REQUIRED }, + { "OPTIONAL", DependencyType::OPTIONAL }, + { "INCOMPATIBLE", DependencyType::INCOMPATIBLE }, + { "EMBEDDED", DependencyType::EMBEDDED }, + { "TOOL", DependencyType::TOOL }, + { "INCLUDE", DependencyType::INCLUDE }, + { "UNKNOWN", DependencyType::UNKNOWN }, + }; + + return map.value(str.toUpper(), DependencyType::UNKNOWN); +} +} // namespace ModPlatform diff --git a/launcher/modplatform/ModIndex.h b/launcher/modplatform/ModIndex.h new file mode 100644 index 0000000..e1278ae --- /dev/null +++ b/launcher/modplatform/ModIndex.h @@ -0,0 +1,241 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +class QIODevice; + +namespace ModPlatform { + +enum class ModLoaderType : std::uint16_t { + NeoForge = 1U << 0U, + Forge = 1U << 1U, + Cauldron = 1U << 2U, + LiteLoader = 1U << 3U, + Fabric = 1U << 4U, + Quilt = 1U << 5U, + DataPack = 1U << 6U, + Babric = 1U << 7U, + BTA = 1U << 8U, + LegacyFabric = 1U << 9U, + Ornithe = 1U << 10U, + Rift = 1U << 11U +}; + +ModLoaderType operator|(ModLoaderType lhs, ModLoaderType rhs); + +using enum ModLoaderType; + +Q_DECLARE_FLAGS(ModLoaderTypes, ModLoaderType) +QList modLoaderTypesToList(ModLoaderTypes flags); + +enum class ResourceProvider : std::uint8_t { MODRINTH, FLAME }; + +enum class DependencyType : std::uint8_t { REQUIRED, OPTIONAL, INCOMPATIBLE, EMBEDDED, TOOL, INCLUDE, UNKNOWN }; + +enum class Side : std::uint8_t { NoSide = 0, ClientSide = 1U << 0U, ServerSide = 1U << 1U, UniversalSide = ClientSide | ServerSide }; + +namespace SideUtils { +QString toString(Side side); +Side fromString(QString side); +} // namespace SideUtils + +namespace DependencyTypeUtils { +QString toString(DependencyType type); +DependencyType fromString(const QString& str); +} // namespace DependencyTypeUtils + +namespace ProviderCapabilities { +const char* name(ResourceProvider); +QString readableName(ResourceProvider); +QStringList hashType(ResourceProvider); +} // namespace ProviderCapabilities + +struct ModpackAuthor { + QString name; + QString url; +}; + +struct DonationData { + QString id; + QString platform; + QString url; +}; + +struct IndexedVersionType { + enum class Enum : std::uint8_t { Unknown = 0, Release = 1, Beta = 2, Alpha = 3 }; + using enum Enum; + constexpr IndexedVersionType(Enum e = Unknown) : m_type(e) {} // NOLINT(hicpp-explicit-conversions) + static IndexedVersionType fromString(const QString& type); + bool isValid() const { return m_type != Unknown; } + std::strong_ordering operator<=>(const IndexedVersionType& other) const = default; + std::strong_ordering operator<=>(const IndexedVersionType::Enum& other) const { return m_type <=> other; } + QString toString() const; + explicit operator int() const { return static_cast(m_type); } + explicit operator IndexedVersionType::Enum() { return m_type; } + + private: + Enum m_type; +}; + +struct Dependency { + QVariant addonId; + DependencyType type; + QString version; +}; + +struct IndexedVersion { + QVariant addonId; + QVariant fileId; + QString version; + QString version_number; + IndexedVersionType version_type; + QStringList mcVersion; + QString downloadUrl; + QString date; + QString fileName; + ModLoaderTypes loaders; + QString hash_type; + QString hash; + bool is_preferred = true; + QString changelog; + QList dependencies; + Side side = Side::NoSide; // this is for flame API + + // For internal use, not provided by APIs + bool is_currently_selected = false; + + QString getVersionDisplayString() const + { + auto release_type = version_type.isValid() ? QString(" [%1]").arg(version_type.toString()) : ""; + auto versionStr = !version.contains(version_number) ? version_number : ""; + QString gameVersion = ""; + for (const auto& v : mcVersion) { + if (version.contains(v)) { + gameVersion = ""; + break; + } + if (gameVersion.isEmpty()) { + gameVersion = QObject::tr(" for %1").arg(v); + } + } + return QString("%1%2 — %3%4").arg(version, gameVersion, versionStr, release_type); + } +}; + +struct ExtraPackData { + QList donate; + + QString issuesUrl; + QString sourceUrl; + QString wikiUrl; + QString discordUrl; + + QString status; + + QString body; +}; + +struct IndexedPack { + using Ptr = std::shared_ptr; + + QVariant addonId; + ResourceProvider provider; + QString name; + QString slug; + QString description; + QList authors; + QString logoName; + QString logoUrl; + QString websiteUrl; + Side side = Side::NoSide; + + bool versionsLoaded = false; + QList versions; + + // Don't load by default, since some modplatform don't have that info + bool extraDataLoaded = true; + ExtraPackData extraData; + + // For internal use, not provided by APIs + bool isVersionSelected(int index) const + { + if (!versionsLoaded) { + return false; + } + + return versions.at(index).is_currently_selected; + } + bool isAnyVersionSelected() const + { + if (!versionsLoaded) { + return false; + } + + return std::any_of(versions.constBegin(), versions.constEnd(), [](const auto& v) { return v.is_currently_selected; }); + } +}; + +struct OverrideDep { + QString quilt; + QString fabric; + QString slug; + ModPlatform::ResourceProvider provider; +}; + +inline auto getOverrideDeps() -> QList +{ + return { + { .quilt = "634179", .fabric = "306612", .slug = "API", .provider = ModPlatform::ResourceProvider::FLAME }, + { .quilt = "720410", .fabric = "308769", .slug = "KotlinLibraries", .provider = ModPlatform::ResourceProvider::FLAME }, + + { .quilt = "qvIfYCYJ", .fabric = "P7dR8mSH", .slug = "API", .provider = ModPlatform::ResourceProvider::MODRINTH }, + { .quilt = "lwVhp9o5", .fabric = "Ha28R6CL", .slug = "KotlinLibraries", .provider = ModPlatform::ResourceProvider::MODRINTH } + }; +} + +QString getMetaURL(ResourceProvider provider, QVariant projectID); + +auto getModLoaderAsString(ModLoaderType type) -> const QString; +auto getModLoaderFromString(QString type) -> ModLoaderType; + +constexpr bool hasSingleModLoaderSelected(ModLoaderTypes l) noexcept +{ + auto x = static_cast(l); + return (x != 0U) && ((x & (x - 1U)) == 0U); +} + +struct Category { + QString name; + QString id; +}; + +} // namespace ModPlatform + +Q_DECLARE_METATYPE(ModPlatform::IndexedPack) +Q_DECLARE_METATYPE(ModPlatform::IndexedPack::Ptr) +Q_DECLARE_METATYPE(ModPlatform::ResourceProvider) diff --git a/launcher/modplatform/ResourceAPI.cpp b/launcher/modplatform/ResourceAPI.cpp new file mode 100644 index 0000000..cda90b6 --- /dev/null +++ b/launcher/modplatform/ResourceAPI.cpp @@ -0,0 +1,301 @@ +#include "modplatform/ResourceAPI.h" + +#include "Application.h" +#include "Json.h" +#include "net/NetJob.h" + +#include "modplatform/ModIndex.h" + +#include "net/ApiDownload.h" + +Task::Ptr ResourceAPI::searchProjects(SearchArgs&& args, Callback>&& callbacks) const +{ + auto search_url_optional = getSearchURL(args); + if (!search_url_optional.has_value()) { + callbacks.on_fail("Failed to create search URL", -1); + return nullptr; + } + + auto search_url = search_url_optional.value(); + + auto netJob = makeShared(QString("%1::Search").arg(debugName()), APPLICATION->network()); + + auto [action, response] = Net::ApiDownload::makeByteArray(QUrl(search_url)); + netJob->addNetAction(action); + + QObject::connect(netJob.get(), &NetJob::succeeded, [this, response, callbacks] { + QJsonParseError parse_error{}; + QJsonDocument doc = QJsonDocument::fromJson(*response, &parse_error); + if (parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from" << debugName() << "at" << parse_error.offset + << "reason:" << parse_error.errorString(); + qWarning() << *response; + + callbacks.on_fail(parse_error.errorString(), -1); + + return; + } + + QList newList; + auto packs = documentToArray(doc); + + for (auto packRaw : packs) { + auto packObj = packRaw.toObject(); + + ModPlatform::IndexedPack::Ptr pack = std::make_shared(); + try { + loadIndexedPack(*pack, packObj); + newList << pack; + } catch (const JSONValidationError& e) { + qWarning().nospace() << "Error while loading resource from " << debugName() << ": " << e.cause(); + continue; + } + } + + callbacks.on_succeed(newList); + }); + + // Capture a weak_ptr instead of a shared_ptr to avoid circular dependency issues. + // This prevents the lambda from extending the lifetime of the shared resource, + // as it only temporarily locks the resource when needed. + auto weak = netJob.toWeakRef(); + QObject::connect(netJob.get(), &NetJob::failed, [weak, callbacks](const QString& reason) { + int network_error_code = -1; + if (auto netJob = weak.lock()) { + if (auto* failed_action = netJob->getFailedActions().at(0); failed_action) + network_error_code = failed_action->replyStatusCode(); + } + callbacks.on_fail(reason, network_error_code); + }); + QObject::connect(netJob.get(), &NetJob::aborted, [callbacks] { + if (callbacks.on_abort != nullptr) + callbacks.on_abort(); + }); + + return netJob; +} + +Task::Ptr ResourceAPI::getProjectVersions(VersionSearchArgs&& args, Callback>&& callbacks) const +{ + auto versions_url_optional = getVersionsURL(args); + if (!versions_url_optional.has_value()) + return nullptr; + + auto versions_url = versions_url_optional.value(); + + auto netJob = makeShared(QString("%1::Versions").arg(args.pack->name), APPLICATION->network()); + + auto [action, response] = Net::ApiDownload::makeByteArray(versions_url); + netJob->addNetAction(action); + + QObject::connect(netJob.get(), &NetJob::succeeded, [this, response, callbacks, args] { + QJsonParseError parse_error{}; + QJsonDocument doc = QJsonDocument::fromJson(*response, &parse_error); + if (parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response for getting versions at" << parse_error.offset + << "reason:" << parse_error.errorString(); + qWarning() << *response; + return; + } + + QVector unsortedVersions; + try { + auto arr = doc.isObject() ? doc.object()["data"].toArray() : doc.array(); + + for (auto versionIter : arr) { + auto obj = versionIter.toObject(); + + auto file = loadIndexedPackVersion(obj, args.resourceType); + if (!file.addonId.isValid()) { + file.addonId = args.pack->addonId; + } + + if (file.fileId.isValid() && !file.downloadUrl.isEmpty()) { // Heuristic to check if the returned value is valid + unsortedVersions.append(file); + } + } + + auto orderSortPredicate = [](const ModPlatform::IndexedVersion& a, const ModPlatform::IndexedVersion& b) -> bool { + // dates are in RFC 3339 format + return a.date > b.date; + }; + std::sort(unsortedVersions.begin(), unsortedVersions.end(), orderSortPredicate); + } catch (const JSONValidationError& e) { + qDebug() << doc; + qWarning() << "Error while reading" << debugName() << "resource version:" << e.cause(); + } + + callbacks.on_succeed(unsortedVersions); + }); + + // Capture a weak_ptr instead of a shared_ptr to avoid circular dependency issues. + // This prevents the lambda from extending the lifetime of the shared resource, + // as it only temporarily locks the resource when needed. + auto weak = netJob.toWeakRef(); + QObject::connect(netJob.get(), &NetJob::failed, [weak, callbacks](const QString& reason) { + int network_error_code = -1; + if (auto netJob = weak.lock()) { + if (auto* failed_action = netJob->getFailedActions().at(0); failed_action) + network_error_code = failed_action->replyStatusCode(); + } + callbacks.on_fail(reason, network_error_code); + }); + QObject::connect(netJob.get(), &NetJob::aborted, [callbacks] { + if (callbacks.on_abort != nullptr) + callbacks.on_abort(); + }); + + return netJob; +} + +Task::Ptr ResourceAPI::getProjectInfo(ProjectInfoArgs&& args, Callback&& callbacks) const +{ + auto [job, response] = getProject(args.pack->addonId.toString()); + + QObject::connect(job.get(), &NetJob::succeeded, [this, response, callbacks, args] { + auto pack = args.pack; + QJsonParseError parse_error{}; + QJsonDocument doc = QJsonDocument::fromJson(*response, &parse_error); + if (parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response for mod info at" << parse_error.offset + << "reason:" << parse_error.errorString(); + qWarning() << *response; + return; + } + try { + auto obj = Json::requireObject(doc); + if (obj.contains("data")) + obj = Json::requireObject(obj, "data"); + loadIndexedPack(*pack, obj); + loadExtraPackInfo(*pack, obj); + } catch (const JSONValidationError& e) { + qDebug() << doc; + qWarning() << "Error while reading" << debugName() << "resource info:" << e.cause(); + } + callbacks.on_succeed(pack); + }); + // Capture a weak_ptr instead of a shared_ptr to avoid circular dependency issues. + // This prevents the lambda from extending the lifetime of the shared resource, + // as it only temporarily locks the resource when needed. + auto weak = job.toWeakRef(); + QObject::connect(job.get(), &NetJob::failed, [weak, callbacks](const QString& reason) { + int network_error_code = -1; + if (auto job = weak.lock()) { + if (auto netJob = qSharedPointerDynamicCast(job)) { + if (auto* failed_action = netJob->getFailedActions().at(0); failed_action) { + network_error_code = failed_action->replyStatusCode(); + } + } + } + callbacks.on_fail(reason, network_error_code); + }); + QObject::connect(job.get(), &NetJob::aborted, [callbacks] { + if (callbacks.on_abort != nullptr) + callbacks.on_abort(); + }); + return job; +} + +Task::Ptr ResourceAPI::getDependencyVersion(DependencySearchArgs&& args, Callback&& callbacks) const +{ + auto versions_url_optional = getDependencyURL(args); + if (!versions_url_optional.has_value()) + return nullptr; + + auto versions_url = versions_url_optional.value(); + + auto netJob = makeShared(QString("%1::Dependency").arg(args.dependency.addonId.toString()), APPLICATION->network()); + auto [action, response] = Net::ApiDownload::makeByteArray(versions_url); + netJob->addNetAction(action); + + QObject::connect(netJob.get(), &NetJob::succeeded, [this, response, callbacks, args] { + QJsonParseError parse_error{}; + QJsonDocument doc = QJsonDocument::fromJson(*response, &parse_error); + if (parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response for getting dependency version at" << parse_error.offset + << "reason:" << parse_error.errorString(); + qWarning() << *response; + return; + } + + QJsonArray arr; + if (args.dependency.version.length() != 0 && doc.isObject()) { + arr.append(doc.object()); + } else { + arr = doc.isObject() ? doc.object()["data"].toArray() : doc.array(); + } + + QVector versions; + for (auto versionIter : arr) { + auto obj = versionIter.toObject(); + + auto file = loadIndexedPackVersion(obj, ModPlatform::ResourceType::Mod); + if (!file.addonId.isValid()) + file.addonId = args.dependency.addonId; + + if (file.fileId.isValid() && + (!file.loaders || args.loader & file.loaders)) // Heuristic to check if the returned value is valid + versions.append(file); + } + + auto orderSortPredicate = [](const ModPlatform::IndexedVersion& a, const ModPlatform::IndexedVersion& b) -> bool { + // dates are in RFC 3339 format + return a.date > b.date; + }; + std::sort(versions.begin(), versions.end(), orderSortPredicate); + auto bestMatch = versions.size() != 0 ? versions.front() : ModPlatform::IndexedVersion(); + callbacks.on_succeed(bestMatch); + }); + + // Capture a weak_ptr instead of a shared_ptr to avoid circular dependency issues. + // This prevents the lambda from extending the lifetime of the shared resource, + // as it only temporarily locks the resource when needed. + auto weak = netJob.toWeakRef(); + QObject::connect(netJob.get(), &NetJob::failed, [weak, callbacks](const QString& reason) { + int network_error_code = -1; + if (auto netJob = weak.lock()) { + if (auto* failed_action = netJob->getFailedActions().at(0); failed_action) + network_error_code = failed_action->replyStatusCode(); + } + callbacks.on_fail(reason, network_error_code); + }); + return netJob; +} + +QString ResourceAPI::getGameVersionsString(std::vector mcVersions) const +{ + QString s; + for (auto& ver : mcVersions) { + s += QString("\"%1\",").arg(mapMCVersionToModrinth(ver)); + } + s.remove(s.length() - 1, 1); // remove last comma + return s; +} + +QString ResourceAPI::mapMCVersionToModrinth(Version v) const +{ + static const QString preString = " Pre-Release "; + auto verStr = v.toString(); + + if (verStr.contains(preString)) { + verStr.replace(preString, "-pre"); + } + verStr.replace(" ", "-"); + return verStr; +} + +std::pair ResourceAPI::getProject(QString addonId) const +{ + auto project_url_optional = getInfoURL(addonId); + if (!project_url_optional.has_value()) + return { nullptr, nullptr }; + + auto project_url = project_url_optional.value(); + + auto netJob = makeShared(QString("%1::GetProject").arg(addonId), APPLICATION->network()); + + auto [action, response] = Net::ApiDownload::makeByteArray(QUrl(project_url)); + netJob->addNetAction(action); + + return { netJob, response }; +} diff --git a/launcher/modplatform/ResourceAPI.h b/launcher/modplatform/ResourceAPI.h new file mode 100644 index 0000000..0ad5577 --- /dev/null +++ b/launcher/modplatform/ResourceAPI.h @@ -0,0 +1,159 @@ +// SPDX-FileCopyrightText: 2023 flowln +// +// SPDX-License-Identifier: GPL-3.0-only AND Apache-2.0 +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (c) 2023-2025 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +#include +#include +#include + +#include "../Version.h" + +#include "modplatform/ModIndex.h" +#include "modplatform/ResourceType.h" +#include "tasks/Task.h" + +/* Simple class with a common interface for interacting with APIs */ +class ResourceAPI { + public: + virtual ~ResourceAPI() = default; + + struct SortingMethod { + // The index of the sorting method. Used to allow for arbitrary ordering in the list of methods. + // Used by Flame in the API request. + unsigned int index; + // The real name of the sorting, as used in the respective API specification. + // Used by Modrinth in the API request. + QString name; + // The human-readable name of the sorting, used for display in the UI. + QString readable_name; + }; + + template + struct Callback { + std::function on_succeed; + std::function on_fail; + std::function on_abort; + }; + + struct SearchArgs { + ModPlatform::ResourceType type{}; + int offset = 0; + + std::optional search; + std::optional sorting; + std::optional loaders; + std::optional> versions; + std::optional side; + std::optional categoryIds; + bool openSource{}; + }; + + struct VersionSearchArgs { + ModPlatform::IndexedPack::Ptr pack; + + std::optional> mcVersions; + std::optional loaders; + ModPlatform::ResourceType resourceType; + bool includeChangelog{}; + }; + + struct ProjectInfoArgs { + ModPlatform::IndexedPack::Ptr pack; + }; + + struct DependencySearchArgs { + ModPlatform::Dependency dependency; + Version mcVersion; + ModPlatform::ModLoaderTypes loader; + bool includeChangelog{}; + }; + + public: + /** Gets a list of available sorting methods for this API. */ + virtual auto getSortingMethods() const -> QList = 0; + + public slots: + virtual Task::Ptr searchProjects(SearchArgs&&, Callback>&&) const; + + virtual std::pair getProject(QString addonId) const; + virtual std::pair getProjects(QStringList addonIds) const = 0; + + virtual Task::Ptr getProjectInfo(ProjectInfoArgs&&, Callback&&) const; + Task::Ptr getProjectVersions(VersionSearchArgs&& args, Callback>&& callbacks) const; + virtual Task::Ptr getDependencyVersion(DependencySearchArgs&&, Callback&&) const; + + protected: + inline QString debugName() const { return "External resource API"; } + + QString mapMCVersionToModrinth(Version v) const; + + QString getGameVersionsString(std::vector mcVersions) const; + + public: + virtual auto getSearchURL(const SearchArgs& args) const -> std::optional = 0; + virtual auto getInfoURL(const QString& id) const -> std::optional = 0; + virtual auto getVersionsURL(const VersionSearchArgs& args) const -> std::optional = 0; + virtual auto getDependencyURL(const DependencySearchArgs& args) const -> std::optional = 0; + + /** Functions to load data into a pack. + * + * Those are needed for the same reason as documentToArray, and NEED to be re-implemented in the same way. + */ + + virtual void loadIndexedPack(ModPlatform::IndexedPack&, QJsonObject&) const = 0; + virtual ModPlatform::IndexedVersion loadIndexedPackVersion(QJsonObject& obj, ModPlatform::ResourceType) const = 0; + + /** Converts a JSON document to a common array format. + * + * This is needed so that different providers, with different JSON structures, can be parsed + * uniformally. You NEED to re-implement this if you intend on using the default callbacks. + */ + virtual QJsonArray documentToArray(QJsonDocument& obj) const = 0; + + /** Functions to load data into a pack. + * + * Those are needed for the same reason as documentToArray, and NEED to be re-implemented in the same way. + */ + + virtual void loadExtraPackInfo(ModPlatform::IndexedPack&, QJsonObject&) const = 0; +}; diff --git a/launcher/modplatform/ResourceType.cpp b/launcher/modplatform/ResourceType.cpp new file mode 100644 index 0000000..2758f11 --- /dev/null +++ b/launcher/modplatform/ResourceType.cpp @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> +// +// SPDX-License-Identifier: GPL-3.0-only + +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "ResourceType.h" + +namespace ModPlatform { +static const QMap s_packedTypeNames = { { ResourceType::ResourcePack, QObject::tr("resource pack") }, + { ResourceType::TexturePack, QObject::tr("texture pack") }, + { ResourceType::DataPack, QObject::tr("data pack") }, + { ResourceType::ShaderPack, QObject::tr("shader pack") }, + { ResourceType::World, QObject::tr("world save") }, + { ResourceType::Mod, QObject::tr("mod") }, + { ResourceType::Unknown, QObject::tr("unknown") } }; + +namespace ResourceTypeUtils { + +QString getName(ResourceType type) +{ + return s_packedTypeNames.constFind(type).value(); +} + +} // namespace ResourceTypeUtils +} // namespace ModPlatform diff --git a/launcher/modplatform/ResourceType.h b/launcher/modplatform/ResourceType.h new file mode 100644 index 0000000..390ade7 --- /dev/null +++ b/launcher/modplatform/ResourceType.h @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> +// +// SPDX-License-Identifier: GPL-3.0-only + +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include + +#include +#include +#include + +namespace ModPlatform { + +enum class ResourceType { Mod, ResourcePack, ShaderPack, Modpack, DataPack, World, Screenshots, TexturePack, Unknown }; + +namespace ResourceTypeUtils { +static const std::set VALID_RESOURCES = { ResourceType::DataPack, ResourceType::ResourcePack, ResourceType::TexturePack, + ResourceType::ShaderPack, ResourceType::World, ResourceType::Mod }; +QString getName(ResourceType type); +} // namespace ResourceTypeUtils +} // namespace ModPlatform diff --git a/launcher/modplatform/atlauncher/ATLPackIndex.cpp b/launcher/modplatform/atlauncher/ATLPackIndex.cpp new file mode 100644 index 0000000..d41e446 --- /dev/null +++ b/launcher/modplatform/atlauncher/ATLPackIndex.cpp @@ -0,0 +1,48 @@ +/* + * Copyright 2020-2021 Jamie Mansfield + * Copyright 2021 Petr Mrazek + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ATLPackIndex.h" + +#include + +#include "Json.h" + +static void loadIndexedVersion(ATLauncher::IndexedVersion& v, QJsonObject& obj) +{ + v.version = Json::requireString(obj, "version"); + v.minecraft = Json::requireString(obj, "minecraft"); +} + +void ATLauncher::loadIndexedPack(ATLauncher::IndexedPack& m, QJsonObject& obj) +{ + m.id = Json::requireInteger(obj, "id"); + m.position = Json::requireInteger(obj, "position"); + m.name = Json::requireString(obj, "name"); + m.type = Json::requireString(obj, "type") == "private" ? ATLauncher::PackType::Private : ATLauncher::PackType::Public; + auto versionsArr = Json::requireArray(obj, "versions"); + for (const auto versionRaw : versionsArr) { + auto versionObj = Json::requireObject(versionRaw); + ATLauncher::IndexedVersion version; + loadIndexedVersion(version, versionObj); + m.versions.append(version); + } + m.system = obj["system"].toBool(); + m.description = obj["description"].toString(""); + + static const QRegularExpression s_regex("[^A-Za-z0-9]"); + m.safeName = Json::requireString(obj, "name").replace(s_regex, "").toLower() + ".png"; +} diff --git a/launcher/modplatform/atlauncher/ATLPackIndex.h b/launcher/modplatform/atlauncher/ATLPackIndex.h new file mode 100644 index 0000000..bb04345 --- /dev/null +++ b/launcher/modplatform/atlauncher/ATLPackIndex.h @@ -0,0 +1,48 @@ +/* + * Copyright 2020-2021 Jamie Mansfield + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "ATLPackManifest.h" + +#include +#include +#include + +namespace ATLauncher { + +struct IndexedVersion { + QString version; + QString minecraft; +}; + +struct IndexedPack { + int id; + int position; + QString name; + PackType type; + QList versions; + bool system; + QString description; + + QString safeName; +}; + +void loadIndexedPack(IndexedPack& m, QJsonObject& obj); +} // namespace ATLauncher + +Q_DECLARE_METATYPE(ATLauncher::IndexedPack) +Q_DECLARE_METATYPE(QList) diff --git a/launcher/modplatform/atlauncher/ATLPackInstallTask.cpp b/launcher/modplatform/atlauncher/ATLPackInstallTask.cpp new file mode 100644 index 0000000..7a7365f --- /dev/null +++ b/launcher/modplatform/atlauncher/ATLPackInstallTask.cpp @@ -0,0 +1,1063 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2020-2021 Jamie Mansfield + * Copyright 2021 Petr Mrazek + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ATLPackInstallTask.h" + +#include +#include + +#include "FileSystem.h" +#include "Json.h" +#include "MMCZip.h" +#include "Version.h" +#include "meta/Index.h" +#include "meta/Version.h" +#include "meta/VersionList.h" +#include "minecraft/MinecraftInstance.h" +#include "minecraft/OneSixVersionFormat.h" +#include "minecraft/PackProfile.h" +#include "modplatform/atlauncher/ATLPackManifest.h" +#include "net/ChecksumValidator.h" +#include "settings/INISettingsObject.h" + +#include "net/ApiDownload.h" + +#include "Application.h" +#include "BuildConfig.h" +#include "ui/dialogs/BlockedModsDialog.h" + +namespace ATLauncher { + +static Meta::Version::Ptr getComponentVersion(const QString& uid, const QString& version); + +PackInstallTask::PackInstallTask(UserInteractionSupport* support, QString packName, QString version, InstallMode installMode) +{ + m_support = support; + m_pack_name = packName; + static const QRegularExpression s_regex("[^A-Za-z0-9]"); + m_pack_safe_name = packName.replace(s_regex, ""); + m_version_name = version; + m_install_mode = installMode; +} + +bool PackInstallTask::abort() +{ + if (abortable) { + return jobPtr->abort(); + } + return false; +} + +void PackInstallTask::executeTask() +{ + qDebug() << "PackInstallTask::executeTask:" << QThread::currentThreadId(); + NetJob::Ptr netJob{ new NetJob("ATLauncher::VersionFetch", APPLICATION->network()) }; + auto searchUrl = + QString(BuildConfig.ATL_DOWNLOAD_SERVER_URL + "packs/%1/versions/%2/Configs.json").arg(m_pack_safe_name).arg(m_version_name); + + auto [action, response] = Net::ApiDownload::makeByteArray(QUrl(searchUrl)); + netJob->addNetAction(action); + + connect(netJob.get(), &NetJob::succeeded, this, [this, response] { onDownloadSucceeded(response); }); + connect(netJob.get(), &NetJob::failed, this, &PackInstallTask::onDownloadFailed); + connect(netJob.get(), &NetJob::aborted, this, &PackInstallTask::onDownloadAborted); + + jobPtr = netJob; + jobPtr->start(); +} + +void PackInstallTask::onDownloadSucceeded(QByteArray* responsePtr) +{ + qDebug() << "PackInstallTask::onDownloadSucceeded:" << QThread::currentThreadId(); + + // NOTE(TheKodeToad): moving the response out to avoid it from being destroyed by jobPtr.reset() + QByteArray response = std::move(*responsePtr); + jobPtr.reset(); + + QJsonParseError parse_error{}; + QJsonDocument doc = QJsonDocument::fromJson(response, &parse_error); + if (parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from ATLauncher at" << parse_error.offset + << "reason:" << parse_error.errorString(); + qWarning() << response; + return; + } + auto obj = doc.object(); + + ATLauncher::PackVersion version; + try { + ATLauncher::loadVersion(version, obj); + } catch (const JSONValidationError& e) { + emitFailed(tr("Could not understand pack manifest:\n") + e.cause()); + return; + } + m_version = version; + + // Derived from the installation mode + QString message; + bool resetDirectory; + + switch (m_install_mode) { + case InstallMode::Reinstall: + case InstallMode::Update: + message = m_version.messages.update; + resetDirectory = true; + break; + + case InstallMode::Install: + message = m_version.messages.install; + resetDirectory = false; + break; + + default: + emitFailed(tr("Unsupported installation mode")); + return; + } + + // Display message if one exists + if (!message.isEmpty()) + m_support->displayMessage(message); + + auto ver = getComponentVersion("net.minecraft", m_version.minecraft); + if (!ver) { + emitFailed(tr("Failed to get local metadata index for '%1' v%2").arg("net.minecraft", m_version.minecraft)); + return; + } + minecraftVersion = ver; + + if (resetDirectory) { + deleteExistingFiles(); + } + + if (m_version.noConfigs) { + downloadMods(); + } else { + installConfigs(); + } +} + +void PackInstallTask::onDownloadFailed(QString reason) +{ + qDebug() << "PackInstallTask::onDownloadFailed:" << QThread::currentThreadId(); + jobPtr.reset(); + emitFailed(reason); +} + +void PackInstallTask::onDownloadAborted() +{ + jobPtr.reset(); + emitAborted(); +} + +void PackInstallTask::deleteExistingFiles() +{ + setStatus(tr("Deleting existing files...")); + + // Setup defaults, as per https://wiki.atlauncher.com/pack-admin/xml/delete + VersionDeletes deletes; + deletes.folders.append(VersionDelete{ "root", "mods%s%" }); + deletes.folders.append(VersionDelete{ "root", "configs%s%" }); + deletes.folders.append(VersionDelete{ "root", "bin%s%" }); + + // Setup defaults, as per https://wiki.atlauncher.com/pack-admin/xml/keep + VersionKeeps keeps; + keeps.files.append(VersionKeep{ "root", "mods%s%PortalGunSounds.pak" }); + keeps.folders.append(VersionKeep{ "root", "mods%s%rei_minimap%s%" }); + keeps.folders.append(VersionKeep{ "root", "mods%s%VoxelMods%s%" }); + keeps.files.append(VersionKeep{ "root", "config%s%NEI.cfg" }); + keeps.files.append(VersionKeep{ "root", "options.txt" }); + keeps.files.append(VersionKeep{ "root", "servers.dat" }); + + // Merge with version deletes and keeps + for (const auto& item : m_version.deletes.files) + deletes.files.append(item); + for (const auto& item : m_version.deletes.folders) + deletes.folders.append(item); + for (const auto& item : m_version.keeps.files) + keeps.files.append(item); + for (const auto& item : m_version.keeps.folders) + keeps.folders.append(item); + + auto getPathForBase = [this](const QString& base) { + auto minecraftPath = FS::PathCombine(m_stagingPath, "minecraft"); + + if (base == "root") { + return minecraftPath; + } else if (base == "config") { + return FS::PathCombine(minecraftPath, "config"); + } else { + qWarning() << "Unrecognised base path" << base; + return minecraftPath; + } + }; + + auto convertToSystemPath = [](const QString& path) { + auto t = path; + t.replace("%s%", QDir::separator()); + return t; + }; + + auto shouldKeep = [keeps, getPathForBase, convertToSystemPath](const QString& fullPath) { + for (const auto& item : keeps.files) { + auto basePath = getPathForBase(item.base); + auto targetPath = convertToSystemPath(item.target); + auto path = FS::PathCombine(basePath, targetPath); + + if (fullPath == path) { + return true; + } + } + + for (const auto& item : keeps.folders) { + auto basePath = getPathForBase(item.base); + auto targetPath = convertToSystemPath(item.target); + auto path = FS::PathCombine(basePath, targetPath); + + if (fullPath.startsWith(path)) { + return true; + } + } + + return false; + }; + + // Keep track of files to delete + QSet filesToDelete; + + for (const auto& item : deletes.files) { + auto basePath = getPathForBase(item.base); + auto targetPath = convertToSystemPath(item.target); + auto fullPath = FS::PathCombine(basePath, targetPath); + + if (shouldKeep(fullPath)) + continue; + + filesToDelete.insert(fullPath); + } + + for (const auto& item : deletes.folders) { + auto basePath = getPathForBase(item.base); + auto targetPath = convertToSystemPath(item.target); + auto fullPath = FS::PathCombine(basePath, targetPath); + + QDirIterator it(fullPath, QDirIterator::Subdirectories); + while (it.hasNext()) { + auto path = it.next(); + + if (shouldKeep(path)) + continue; + + filesToDelete.insert(path); + } + } + + // Delete the files + for (const auto& item : filesToDelete) { + FS::deletePath(item); + } +} + +QString PackInstallTask::getDirForModType(ModType type, QString raw) +{ + switch (type) { + // Mod types that can either be ignored at this stage, or ignored + // completely. + case ModType::Root: + case ModType::Extract: + case ModType::Decomp: + case ModType::TexturePackExtract: + case ModType::ResourcePackExtract: + case ModType::MCPC: + return Q_NULLPTR; + case ModType::Forge: + // Forge detection happens later on, if it cannot be detected it will + // install a jarmod component. + case ModType::Jar: + return "jarmods"; + case ModType::Mods: + return "mods"; + case ModType::Flan: + return "Flan"; + case ModType::Dependency: + return FS::PathCombine("mods", m_version.minecraft); + case ModType::Ic2Lib: + return FS::PathCombine("mods", "ic2"); + case ModType::DenLib: + return FS::PathCombine("mods", "denlib"); + case ModType::Coremods: + return "coremods"; + case ModType::Plugins: + return "plugins"; + case ModType::TexturePack: + return "texturepacks"; + case ModType::ResourcePack: + return "resourcepacks"; + case ModType::ShaderPack: + return "shaderpacks"; + case ModType::Millenaire: + qWarning() << "Unsupported mod type: " + raw; + return Q_NULLPTR; + case ModType::Unknown: + emitFailed(tr("Unknown mod type: %1").arg(raw)); + return Q_NULLPTR; + } + + return Q_NULLPTR; +} + +QString PackInstallTask::getVersionForLoader(QString uid) +{ + if (m_version.loader.recommended || m_version.loader.latest || m_version.loader.choose) { + auto vlist = APPLICATION->metadataIndex()->get(uid); + if (!vlist) { + emitFailed(tr("Failed to get local metadata index for %1").arg(uid)); + return Q_NULLPTR; + } + + vlist->waitToLoad(); + + if (m_version.loader.recommended || m_version.loader.latest) { + for (int i = 0; i < vlist->versions().size(); i++) { + auto version = vlist->versions().at(i); + auto reqs = version->requiredSet(); + + // filter by minecraft version, if the loader depends on a certain version. + // not all mod loaders depend on a given Minecraft version, so we won't do this + // filtering for those loaders. + if (m_version.loader.type != "fabric") { + auto iter = std::find_if(reqs.begin(), reqs.end(), [](const Meta::Require& req) { return req.uid == "net.minecraft"; }); + if (iter == reqs.end()) + continue; + if (iter->equalsVersion != m_version.minecraft) + continue; + } + + if (m_version.loader.recommended) { + // first recommended build we find, we use. + if (!version->isRecommended()) + continue; + } + + return version->descriptor(); + } + + emitFailed(tr("Failed to find version for %1 loader").arg(m_version.loader.type)); + return Q_NULLPTR; + } else if (m_version.loader.choose) { + // Fabric Loader doesn't depend on a given Minecraft version. + if (m_version.loader.type == "fabric") { + return m_support->chooseVersion(vlist, Q_NULLPTR); + } + + return m_support->chooseVersion(vlist, m_version.minecraft); + } + } + + if (m_version.loader.version == Q_NULLPTR || m_version.loader.version.isEmpty()) { + emitFailed(tr("No loader version set for modpack!")); + return Q_NULLPTR; + } + + return m_version.loader.version; +} + +QString PackInstallTask::detectLibrary(const VersionLibrary& library) +{ + // Try to detect what the library is + if (!library.server.isEmpty() && library.server.split("/").length() >= 3) { + auto lastSlash = library.server.lastIndexOf("/"); + auto locationAndVersion = library.server.mid(0, lastSlash); + auto fileName = library.server.mid(lastSlash + 1); + + lastSlash = locationAndVersion.lastIndexOf("/"); + auto location = locationAndVersion.mid(0, lastSlash); + auto version = locationAndVersion.mid(lastSlash + 1); + + lastSlash = location.lastIndexOf("/"); + auto group = location.mid(0, lastSlash).replace("/", "."); + auto artefact = location.mid(lastSlash + 1); + + return group + ":" + artefact + ":" + version; + } + + if (library.file.contains("-")) { + auto lastSlash = library.file.lastIndexOf("-"); + auto name = library.file.mid(0, lastSlash); + auto version = library.file.mid(lastSlash + 1).remove(".jar"); + + if (name == QString("guava")) { + return "com.google.guava:guava:" + version; + } else if (name == QString("commons-lang3")) { + return "org.apache.commons:commons-lang3:" + version; + } + } + + return "org.multimc.atlauncher:" + library.md5 + ":1"; +} + +bool PackInstallTask::createLibrariesComponent(QString instanceRoot, PackProfile* profile) +{ + if (m_version.libraries.isEmpty()) { + return true; + } + + QList exempt; + for (const auto& componentUid : componentsToInstall.keys()) { + auto componentVersion = componentsToInstall.value(componentUid); + if (componentVersion->data()) { + for (const auto& library : componentVersion->data()->libraries) { + GradleSpecifier lib(library->rawName()); + exempt.append(lib); + } + } + } + + if (minecraftVersion->data()) { + for (const auto& library : minecraftVersion->data()->libraries) { + GradleSpecifier lib(library->rawName()); + exempt.append(lib); + } + } + + auto id = QUuid::createUuid().toString(QUuid::WithoutBraces); + auto target_id = "org.multimc.atlauncher." + id; + + auto patchDir = FS::PathCombine(instanceRoot, "patches"); + if (!FS::ensureFolderPathExists(patchDir)) { + return false; + } + auto patchFileName = FS::PathCombine(patchDir, target_id + ".json"); + + auto f = std::make_shared(); + f->name = m_pack_name + " " + m_version_name + " (libraries)"; + + const static QMap liteLoaderMap = { + { "61179803bcd5fb7790789b790908663d", "1.12-SNAPSHOT" }, { "1420785ecbfed5aff4a586c5c9dd97eb", "1.12.2-SNAPSHOT" }, + { "073f68e2fcb518b91fd0d99462441714", "1.6.2_03" }, { "10a15b52fc59b1bfb9c05b56de1097d6", "1.6.2_02" }, + { "b52f90f08303edd3d4c374e268a5acf1", "1.6.2_04" }, { "ea747e24e03e24b7cad5bc8a246e0319", "1.6.2_01" }, + { "55785ccc82c07ff0ba038fe24be63ea2", "1.7.10_01" }, { "63ada46e033d0cb6782bada09ad5ca4e", "1.7.10_04" }, + { "7983e4b28217c9ae8569074388409c86", "1.7.10_03" }, { "c09882458d74fe0697c7681b8993097e", "1.7.10_02" }, + { "db7235aefd407ac1fde09a7baba50839", "1.7.10_00" }, { "6e9028816027f53957bd8fcdfabae064", "1.8" }, + { "5e732dc446f9fe2abe5f9decaec40cde", "1.10-SNAPSHOT" }, { "3a98b5ed95810bf164e71c1a53be568d", "1.11.2-SNAPSHOT" }, + { "ba8e6285966d7d988a96496f48cbddaa", "1.8.9-SNAPSHOT" }, { "8524af3ac3325a82444cc75ae6e9112f", "1.11-SNAPSHOT" }, + { "53639d52340479ccf206a04f5e16606f", "1.5.2_01" }, { "1fcdcf66ce0a0806b7ad8686afdce3f7", "1.6.4_00" }, + { "531c116f71ae2b11033f9a11a0f8e668", "1.6.4_01" }, { "4009eeb99c9068f608d3483a6439af88", "1.7.2_03" }, + { "66f343354b8417abce1a10d557d2c6e9", "1.7.2_04" }, { "ab554c21f28fbc4ae9b098bcb5f4cceb", "1.7.2_05" }, + { "e1d76a05a3723920e2f80a5e66c45f16", "1.7.2_02" }, { "00318cb0c787934d523f63cdfe8ddde4", "1.9-SNAPSHOT" }, + { "986fd1ee9525cb0dcab7609401cef754", "1.9.4-SNAPSHOT" }, { "571ad5e6edd5ff40259570c9be588bb5", "1.9.4" }, + { "1cdd72f7232e45551f16cc8ffd27ccf3", "1.10.2-SNAPSHOT" }, { "8a7c21f32d77ee08b393dd3921ced8eb", "1.10.2" }, + { "b9bef8abc8dc309069aeba6fbbe58980", "1.12.1-SNAPSHOT" } + }; + + for (const auto& lib : m_version.libraries) { + // If the library is LiteLoader, we need to ignore it and handle it separately. + if (liteLoaderMap.contains(lib.md5)) { + auto ver = getComponentVersion("com.mumfrey.liteloader", liteLoaderMap.value(lib.md5)); + if (ver) { + componentsToInstall.insert("com.mumfrey.liteloader", ver); + continue; + } + } + + auto libName = detectLibrary(lib); + GradleSpecifier libSpecifier(libName); + + bool libExempt = false; + for (const auto& existingLib : exempt) { + if (libSpecifier.matchName(existingLib)) { + // If the pack specifies a newer version of the lib, use that! + libExempt = Version(libSpecifier.version()) >= Version(existingLib.version()); + } + } + if (libExempt) + continue; + + auto library = std::make_shared(); + library->setRawName(libName); + + switch (lib.download) { + case DownloadType::Server: + library->setAbsoluteUrl(BuildConfig.ATL_DOWNLOAD_SERVER_URL + lib.url); + break; + case DownloadType::Direct: + library->setAbsoluteUrl(lib.url); + break; + case DownloadType::Browser: + case DownloadType::Unknown: + emitFailed(tr("Unknown or unsupported download type: %1").arg(lib.download_raw)); + return false; + } + + f->libraries.append(library); + } + + if (f->libraries.isEmpty()) { + return true; + } + + QFile file(patchFileName); + if (!file.open(QFile::WriteOnly)) { + qCritical() << "Error opening" << file.fileName() << "for reading:" << file.errorString(); + return false; + } + file.write(OneSixVersionFormat::versionFileToJson(f).toJson()); + file.close(); + + profile->appendComponent(ComponentPtr{ new Component(profile, target_id, f) }); + return true; +} + +bool PackInstallTask::createPackComponent(QString instanceRoot, PackProfile* profile) +{ + if (m_version.mainClass.mainClass.isEmpty() && m_version.extraArguments.arguments.isEmpty()) { + return true; + } + + auto mainClass = m_version.mainClass.mainClass; + auto extraArguments = m_version.extraArguments.arguments; + + auto hasMainClassDepends = !m_version.mainClass.depends.isEmpty(); + auto hasExtraArgumentsDepends = !m_version.extraArguments.depends.isEmpty(); + if (hasMainClassDepends || hasExtraArgumentsDepends) { + QSet mods; + for (const auto& item : m_version.mods) { + mods.insert(item.name); + } + + if (hasMainClassDepends && !mods.contains(m_version.mainClass.depends)) { + mainClass = ""; + } + + if (hasExtraArgumentsDepends && !mods.contains(m_version.extraArguments.depends)) { + extraArguments = ""; + } + } + + if (mainClass.isEmpty() && extraArguments.isEmpty()) { + return true; + } + + auto id = QUuid::createUuid().toString(QUuid::WithoutBraces); + auto target_id = "org.multimc.atlauncher." + id; + + auto patchDir = FS::PathCombine(instanceRoot, "patches"); + if (!FS::ensureFolderPathExists(patchDir)) { + return false; + } + auto patchFileName = FS::PathCombine(patchDir, target_id + ".json"); + + QStringList mainClasses; + QStringList tweakers; + for (const auto& componentUid : componentsToInstall.keys()) { + auto componentVersion = componentsToInstall.value(componentUid); + + if (componentVersion->data()) { + if (componentVersion->data()->mainClass != QString("")) { + mainClasses.append(componentVersion->data()->mainClass); + } + tweakers.append(componentVersion->data()->addTweakers); + } + } + + auto f = std::make_shared(); + f->name = m_pack_name + " " + m_version_name; + if (!mainClass.isEmpty() && !mainClasses.contains(mainClass)) { + f->mainClass = mainClass; + } + + // Parse out tweakers + auto args = extraArguments.split(" "); + QString previous; + for (auto arg : args) { + if (arg.startsWith("--tweakClass=") || previous == "--tweakClass") { + auto tweakClass = arg.remove("--tweakClass="); + if (tweakers.contains(tweakClass)) + continue; + + f->addTweakers.append(tweakClass); + } + previous = arg; + } + + if (f->mainClass == QString() && f->addTweakers.isEmpty()) { + return true; + } + + QFile file(patchFileName); + if (!file.open(QFile::WriteOnly)) { + qCritical() << "Error opening" << file.fileName() << "for writing:" << file.errorString(); + return false; + } + file.write(OneSixVersionFormat::versionFileToJson(f).toJson()); + file.close(); + + profile->appendComponent(ComponentPtr{ new Component(profile, target_id, f) }); + return true; +} + +void PackInstallTask::installConfigs() +{ + qDebug() << "PackInstallTask::installConfigs:" << QThread::currentThreadId(); + setStatus(tr("Downloading configs...")); + jobPtr.reset(new NetJob(tr("Config download"), APPLICATION->network())); + + auto path = QString("Configs/%1/%2.zip").arg(m_pack_safe_name).arg(m_version_name); + auto url = QString(BuildConfig.ATL_DOWNLOAD_SERVER_URL + "packs/%1/versions/%2/Configs.zip").arg(m_pack_safe_name).arg(m_version_name); + auto entry = APPLICATION->metacache()->resolveEntry("ATLauncherPacks", path); + entry->setStale(true); + + auto dl = Net::ApiDownload::makeCached(url, entry); + if (!m_version.configs.sha1.isEmpty()) { + dl->addValidator(new Net::ChecksumValidator(QCryptographicHash::Sha1, m_version.configs.sha1)); + } + jobPtr->addNetAction(dl); + archivePath = entry->getFullPath(); + + connect(jobPtr.get(), &NetJob::succeeded, this, [this]() { + abortable = false; + jobPtr.reset(); + extractConfigs(); + }); + connect(jobPtr.get(), &NetJob::failed, [this](QString reason) { + abortable = false; + jobPtr.reset(); + emitFailed(reason); + }); + connect(jobPtr.get(), &NetJob::progress, [this](qint64 current, qint64 total) { + abortable = true; + setProgress(current, total); + }); + connect(jobPtr.get(), &NetJob::stepProgress, this, &PackInstallTask::propagateStepProgress); + connect(jobPtr.get(), &NetJob::aborted, [this] { + abortable = false; + jobPtr.reset(); + emitAborted(); + }); + + jobPtr->start(); +} + +void PackInstallTask::extractConfigs() +{ + qDebug() << "PackInstallTask::extractConfigs:" << QThread::currentThreadId(); + setStatus(tr("Extracting configs...")); + + QDir extractDir(m_stagingPath); + m_extractFuture = QtConcurrent::run(QThreadPool::globalInstance(), QOverload::of(MMCZip::extractDir), archivePath, + extractDir.absolutePath() + "/minecraft"); + connect(&m_extractFutureWatcher, &QFutureWatcher::finished, this, [this]() { downloadMods(); }); + connect(&m_extractFutureWatcher, &QFutureWatcher::canceled, this, [this]() { emitAborted(); }); + m_extractFutureWatcher.setFuture(m_extractFuture); +} + +void PackInstallTask::downloadMods() +{ + qDebug() << "PackInstallTask::installMods:" << QThread::currentThreadId(); + + QList optionalMods; + for (const auto& mod : m_version.mods) { + if (mod.optional) { + optionalMods.push_back(mod); + } + } + + // Select optional mods, if pack contains any + QList selectedMods; + if (!optionalMods.isEmpty()) { + setStatus(tr("Selecting optional mods...")); + auto mods = m_support->chooseOptionalMods(m_version, optionalMods); + if (!mods.has_value()) { + emitAborted(); + return; + } + selectedMods = mods.value(); + } + + setStatus(tr("Downloading mods...")); + + jarmods.clear(); + jobPtr.reset(new NetJob(tr("Mod download"), APPLICATION->network())); + + QList blocked_mods; + for (const auto& mod : m_version.mods) { + // skip non-client mods + if (!mod.client) + continue; + + // skip optional mods that were not selected + if (mod.optional && !selectedMods.contains(mod.name)) + continue; + + QString url; + switch (mod.download) { + case DownloadType::Server: + url = BuildConfig.ATL_DOWNLOAD_SERVER_URL + mod.url; + break; + case DownloadType::Browser: { + blocked_mods.append(mod); + continue; + } + case DownloadType::Direct: + url = mod.url; + break; + case DownloadType::Unknown: + emitFailed(tr("Unknown download type: %1").arg(mod.download_raw)); + return; + } + + QFileInfo fileName(mod.file); + auto cacheName = fileName.completeBaseName() + "-" + mod.md5 + "." + fileName.suffix(); + + if (mod.type == ModType::Extract || mod.type == ModType::TexturePackExtract || mod.type == ModType::ResourcePackExtract) { + auto entry = APPLICATION->metacache()->resolveEntry("ATLauncherPacks", cacheName); + entry->setStale(true); + modsToExtract.insert(entry->getFullPath(), mod); + + auto dl = Net::ApiDownload::makeCached(url, entry); + if (!mod.md5.isEmpty()) { + dl->addValidator(new Net::ChecksumValidator(QCryptographicHash::Md5, mod.md5)); + } + jobPtr->addNetAction(dl); + } else if (mod.type == ModType::Decomp) { + auto entry = APPLICATION->metacache()->resolveEntry("ATLauncherPacks", cacheName); + entry->setStale(true); + modsToDecomp.insert(entry->getFullPath(), mod); + + auto dl = Net::ApiDownload::makeCached(url, entry); + if (!mod.md5.isEmpty()) { + dl->addValidator(new Net::ChecksumValidator(QCryptographicHash::Md5, mod.md5)); + } + jobPtr->addNetAction(dl); + } else { + auto relpath = getDirForModType(mod.type, mod.type_raw); + if (relpath == Q_NULLPTR) + continue; + + auto entry = APPLICATION->metacache()->resolveEntry("ATLauncherPacks", cacheName); + entry->setStale(true); + + auto dl = Net::ApiDownload::makeCached(url, entry); + if (!mod.md5.isEmpty()) { + dl->addValidator(new Net::ChecksumValidator(QCryptographicHash::Md5, mod.md5)); + } + jobPtr->addNetAction(dl); + + auto path = FS::PathCombine(m_stagingPath, "minecraft", relpath, mod.file); + + if (mod.type == ModType::Forge) { + auto ver = getComponentVersion("net.minecraftforge", mod.version); + if (ver) { + componentsToInstall.insert("net.minecraftforge", ver); + continue; + } + + qDebug() << "Jarmod: " + path; + jarmods.push_back(path); + } + + if (mod.type == ModType::Jar) { + qDebug() << "Jarmod: " + path; + jarmods.push_back(path); + } + + // Download after Forge handling, to avoid downloading Forge twice. + qDebug() << "Will download" << url << "to" << path; + modsToCopy[entry->getFullPath()] = path; + } + } + if (!blocked_mods.isEmpty()) { + QList mods; + + for (auto mod : blocked_mods) { + BlockedMod blocked_mod; + blocked_mod.name = mod.file; + blocked_mod.websiteUrl = mod.url; + blocked_mod.hash = mod.md5; + blocked_mod.matched = false; + blocked_mod.localPath = ""; + + mods.append(blocked_mod); + } + + qWarning() << "Blocked mods found, displaying mod list"; + + BlockedModsDialog message_dialog(nullptr, tr("Blocked mods found"), + tr("The following files are not available for download in third party launchers.
" + "You will need to manually download them and add them to the instance."), + mods, "md5"); + + message_dialog.setModal(true); + + if (message_dialog.exec()) { + qDebug() << "Post dialog blocked mods list:" << mods; + for (auto blocked : mods) { + if (!blocked.matched) { + qDebug() << blocked.name << "was not matched to a local file, skipping copy"; + continue; + } + auto modIter = std::find_if(blocked_mods.begin(), blocked_mods.end(), + [blocked](const VersionMod& mod) { return mod.url == blocked.websiteUrl; }); + if (modIter == blocked_mods.end()) + continue; + auto mod = *modIter; + if (mod.type == ModType::Extract || mod.type == ModType::TexturePackExtract || mod.type == ModType::ResourcePackExtract) { + modsToExtract.insert(blocked.localPath, mod); + } else if (mod.type == ModType::Decomp) { + modsToDecomp.insert(blocked.localPath, mod); + } else { + auto relpath = getDirForModType(mod.type, mod.type_raw); + if (relpath == Q_NULLPTR) + continue; + + auto path = FS::PathCombine(m_stagingPath, "minecraft", relpath, mod.file); + + if (mod.type == ModType::Forge) { + auto ver = getComponentVersion("net.minecraftforge", mod.version); + if (ver) { + componentsToInstall.insert("net.minecraftforge", ver); + continue; + } + + qDebug() << "Jarmod: " + path; + jarmods.push_back(path); + } + + if (mod.type == ModType::Jar) { + qDebug() << "Jarmod: " + path; + jarmods.push_back(path); + } + + modsToCopy[blocked.localPath] = path; + } + } + } else { + emitFailed(tr("Unknown download type: %1").arg("browser")); + return; + } + } + + connect(jobPtr.get(), &NetJob::succeeded, this, &PackInstallTask::onModsDownloaded); + connect(jobPtr.get(), &NetJob::progress, [this](qint64 current, qint64 total) { + setDetails(tr("%1 out of %2 complete").arg(current).arg(total)); + abortable = true; + setProgress(current, total); + }); + connect(jobPtr.get(), &NetJob::stepProgress, this, &PackInstallTask::propagateStepProgress); + connect(jobPtr.get(), &NetJob::aborted, &PackInstallTask::emitAborted); + connect(jobPtr.get(), &NetJob::failed, &PackInstallTask::emitFailed); + + jobPtr->start(); +} + +void PackInstallTask::onModsDownloaded() +{ + abortable = false; + + qDebug() << "PackInstallTask::onModsDownloaded:" << QThread::currentThreadId(); + jobPtr.reset(); + + if (!modsToExtract.empty() || !modsToDecomp.empty() || !modsToCopy.empty()) { + m_modExtractFuture = + QtConcurrent::run(QThreadPool::globalInstance(), &PackInstallTask::extractMods, this, modsToExtract, modsToDecomp, modsToCopy); + connect(&m_modExtractFutureWatcher, &QFutureWatcher::finished, this, &PackInstallTask::onModsExtracted); + connect(&m_modExtractFutureWatcher, &QFutureWatcher::canceled, this, &PackInstallTask::emitAborted); + m_modExtractFutureWatcher.setFuture(m_modExtractFuture); + } else { + install(); + } +} + +void PackInstallTask::onModsExtracted() +{ + qDebug() << "PackInstallTask::onModsExtracted:" << QThread::currentThreadId(); + if (m_modExtractFuture.result()) { + install(); + } else { + emitFailed(tr("Failed to extract mods...")); + } +} + +bool PackInstallTask::extractMods(const QMap& toExtract, + const QMap& toDecomp, + const QMap& toCopy) +{ + qDebug() << "PackInstallTask::extractMods:" << QThread::currentThreadId(); + + setStatus(tr("Extracting mods...")); + for (auto iter = toExtract.begin(); iter != toExtract.end(); iter++) { + auto& modPath = iter.key(); + auto& mod = iter.value(); + + QString extractToDir; + if (mod.type == ModType::Extract) { + extractToDir = getDirForModType(mod.extractTo, mod.extractTo_raw); + } else if (mod.type == ModType::TexturePackExtract) { + extractToDir = FS::PathCombine("texturepacks", "extracted"); + } else if (mod.type == ModType::ResourcePackExtract) { + extractToDir = FS::PathCombine("resourcepacks", "extracted"); + } + + QDir extractDir(m_stagingPath); + auto extractToPath = FS::PathCombine(extractDir.absolutePath(), "minecraft", extractToDir); + + QString folderToExtract = ""; + if (mod.type == ModType::Extract) { + folderToExtract = mod.extractFolder; + static const QRegularExpression s_regex("^/"); + folderToExtract.remove(s_regex); + } + + qDebug() << "Extracting " + mod.file + " to " + extractToDir; + if (!MMCZip::extractDir(modPath, folderToExtract, extractToPath)) { + // assume error + return false; + } + } + + for (auto iter = toDecomp.begin(); iter != toDecomp.end(); iter++) { + auto& modPath = iter.key(); + auto& mod = iter.value(); + auto extractToDir = getDirForModType(mod.decompType, mod.decompType_raw); + + QDir extractDir(m_stagingPath); + auto extractToPath = FS::PathCombine(extractDir.absolutePath(), "minecraft", extractToDir, mod.decompFile); + + qDebug() << "Extracting " + mod.decompFile + " to " + extractToDir; + if (!MMCZip::extractFile(modPath, mod.decompFile, extractToPath)) { + qWarning() << "Failed to extract" << mod.decompFile; + return false; + } + } + + for (auto iter = toCopy.begin(); iter != toCopy.end(); iter++) { + auto& from = iter.key(); + auto& to = iter.value(); + + // If the file already exists, assume the mod is the correct copy - and remove + // the copy from the Configs.zip + QFileInfo fileInfo(to); + if (fileInfo.exists()) { + if (!FS::deletePath(to)) { + qWarning() << "Failed to delete" << to; + return false; + } + } + + FS::copy fileCopyOperation(from, to); + if (!fileCopyOperation()) { + qWarning() << "Failed to copy" << from << "to" << to; + return false; + } + } + return true; +} + +void PackInstallTask::install() +{ + qDebug() << "PackInstallTask::install:" << QThread::currentThreadId(); + setStatus(tr("Installing modpack")); + + auto instanceConfigPath = FS::PathCombine(m_stagingPath, "instance.cfg"); + MinecraftInstance instance(m_globalSettings, std::make_unique(instanceConfigPath), m_stagingPath); + { + SettingsObject::Lock lock(instance.settings()); + auto components = instance.getPackProfile(); + components->buildingFromScratch(); + + // Use a component to add libraries BEFORE Minecraft + if (!createLibrariesComponent(instance.instanceRoot(), components)) { + emitFailed(tr("Failed to create libraries component")); + return; + } + + // Minecraft + components->setComponentVersion("net.minecraft", m_version.minecraft, true); + + // Loader + if (m_version.loader.type == QString("forge")) { + auto version = getVersionForLoader("net.minecraftforge"); + if (version == Q_NULLPTR) + return; + + components->setComponentVersion("net.minecraftforge", version); + } else if (m_version.loader.type == QString("neoforge")) { + auto version = getVersionForLoader("net.neoforged"); + if (version == Q_NULLPTR) + return; + + components->setComponentVersion("net.neoforged", version); + } else if (m_version.loader.type == QString("fabric")) { + auto version = getVersionForLoader("net.fabricmc.fabric-loader"); + if (version == Q_NULLPTR) + return; + + components->setComponentVersion("net.fabricmc.fabric-loader", version); + } else if (m_version.loader.type != QString()) { + emitFailed(tr("Unknown loader type: ") + m_version.loader.type); + return; + } + + for (const auto& componentUid : componentsToInstall.keys()) { + auto version = componentsToInstall.value(componentUid); + components->setComponentVersion(componentUid, version->version()); + } + + components->installJarMods(jarmods); + + // Use a component to fill in the rest of the data + // todo: use more detection + if (!createPackComponent(instance.instanceRoot(), components)) { + emitFailed(tr("Failed to create pack component")); + return; + } + + components->saveNow(); + + instance.setName(name()); + instance.setIconKey(m_instIcon); + instance.setManagedPack("atlauncher", m_pack_safe_name, m_pack_name, m_version_name, m_version_name); + + jarmods.clear(); + } + emitSucceeded(); +} + +static Meta::Version::Ptr getComponentVersion(const QString& uid, const QString& version) +{ + return APPLICATION->metadataIndex()->getLoadedVersion(uid, version); +} + +} // namespace ATLauncher diff --git a/launcher/modplatform/atlauncher/ATLPackInstallTask.h b/launcher/modplatform/atlauncher/ATLPackInstallTask.h new file mode 100644 index 0000000..d1ffdfe --- /dev/null +++ b/launcher/modplatform/atlauncher/ATLPackInstallTask.h @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2020-2021 Jamie Mansfield + * Copyright 2021 Petr Mrazek + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include "ATLPackManifest.h" + +#include "InstanceTask.h" +#include "meta/Version.h" +#include "minecraft/MinecraftInstance.h" +#include "minecraft/PackProfile.h" +#include "net/NetJob.h" +#include "settings/INISettingsObject.h" + +#include +#include + +namespace ATLauncher { + +enum class InstallMode { + Install, + Reinstall, + Update, +}; + +class UserInteractionSupport { + public: + /** + * Requests a user interaction to select which optional mods should be installed. + */ + virtual std::optional> chooseOptionalMods(const PackVersion& version, QList mods) = 0; + + /** + * Requests a user interaction to select a component version from a given version list + * and constrained to a given Minecraft version. + */ + virtual QString chooseVersion(Meta::VersionList::Ptr vlist, QString minecraftVersion) = 0; + + /** + * Requests a user interaction to display a message. + */ + virtual void displayMessage(QString message) = 0; + + virtual ~UserInteractionSupport() = default; +}; + +class PackInstallTask : public InstanceTask { + Q_OBJECT + + public: + explicit PackInstallTask(UserInteractionSupport* support, + QString packName, + QString version, + InstallMode installMode = InstallMode::Install); + virtual ~PackInstallTask() { delete m_support; } + + bool canAbort() const override { return true; } + bool abort() override; + + protected: + virtual void executeTask() override; + + private slots: + void onDownloadSucceeded(QByteArray* responsePtr); + void onDownloadFailed(QString reason); + void onDownloadAborted(); + + void onModsDownloaded(); + void onModsExtracted(); + + private: + QString getDirForModType(ModType type, QString raw); + QString getVersionForLoader(QString uid); + QString detectLibrary(const VersionLibrary& library); + + bool createLibrariesComponent(QString instanceRoot, PackProfile* profile); + bool createPackComponent(QString instanceRoot, PackProfile* profile); + + void deleteExistingFiles(); + void installConfigs(); + void extractConfigs(); + void downloadMods(); + bool extractMods(const QMap& toExtract, + const QMap& toDecomp, + const QMap& toCopy); + void install(); + + private: + UserInteractionSupport* m_support; + + bool abortable = false; + + NetJob::Ptr jobPtr; + + InstallMode m_install_mode; + QString m_pack_name; + QString m_pack_safe_name; + QString m_version_name; + PackVersion m_version; + + QMap modsToExtract; + QMap modsToDecomp; + QMap modsToCopy; + + QString archivePath; + QStringList jarmods; + Meta::Version::Ptr minecraftVersion; + QMap componentsToInstall; + + QFuture> m_extractFuture; + QFutureWatcher> m_extractFutureWatcher; + + QFuture m_modExtractFuture; + QFutureWatcher m_modExtractFutureWatcher; +}; + +} // namespace ATLauncher diff --git a/launcher/modplatform/atlauncher/ATLPackManifest.cpp b/launcher/modplatform/atlauncher/ATLPackManifest.cpp new file mode 100644 index 0000000..22f63ad --- /dev/null +++ b/launcher/modplatform/atlauncher/ATLPackManifest.cpp @@ -0,0 +1,335 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2020-2021 Jamie Mansfield + * Copyright 2021 Petr Mrazek + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ATLPackManifest.h" + +#include "Json.h" + +static ATLauncher::DownloadType parseDownloadType(QString rawType) +{ + if (rawType == QString("server")) { + return ATLauncher::DownloadType::Server; + } else if (rawType == QString("browser")) { + return ATLauncher::DownloadType::Browser; + } else if (rawType == QString("direct")) { + return ATLauncher::DownloadType::Direct; + } + + return ATLauncher::DownloadType::Unknown; +} + +static ATLauncher::ModType parseModType(QString rawType) +{ + // See https://wiki.atlauncher.com/mod_types + if (rawType == QString("root")) { + return ATLauncher::ModType::Root; + } else if (rawType == QString("forge")) { + return ATLauncher::ModType::Forge; + } else if (rawType == QString("jar")) { + return ATLauncher::ModType::Jar; + } else if (rawType == QString("mods")) { + return ATLauncher::ModType::Mods; + } else if (rawType == QString("flan")) { + return ATLauncher::ModType::Flan; + } else if (rawType == QString("dependency") || rawType == QString("depandency")) { + return ATLauncher::ModType::Dependency; + } else if (rawType == QString("ic2lib")) { + return ATLauncher::ModType::Ic2Lib; + } else if (rawType == QString("denlib")) { + return ATLauncher::ModType::DenLib; + } else if (rawType == QString("coremods")) { + return ATLauncher::ModType::Coremods; + } else if (rawType == QString("mcpc")) { + return ATLauncher::ModType::MCPC; + } else if (rawType == QString("plugins")) { + return ATLauncher::ModType::Plugins; + } else if (rawType == QString("extract")) { + return ATLauncher::ModType::Extract; + } else if (rawType == QString("decomp")) { + return ATLauncher::ModType::Decomp; + } else if (rawType == QString("texturepack")) { + return ATLauncher::ModType::TexturePack; + } else if (rawType == QString("resourcepack")) { + return ATLauncher::ModType::ResourcePack; + } else if (rawType == QString("shaderpack")) { + return ATLauncher::ModType::ShaderPack; + } else if (rawType == QString("texturepackextract")) { + return ATLauncher::ModType::TexturePackExtract; + } else if (rawType == QString("resourcepackextract")) { + return ATLauncher::ModType::ResourcePackExtract; + } else if (rawType == QString("millenaire")) { + return ATLauncher::ModType::Millenaire; + } + + return ATLauncher::ModType::Unknown; +} + +static void loadVersionLoader(ATLauncher::VersionLoader& p, QJsonObject& obj) +{ + p.type = Json::requireString(obj, "type"); + p.choose = obj["choose"].toBool(); + + auto metadata = Json::requireObject(obj, "metadata"); + p.latest = metadata["latest"].toBool(); + p.recommended = metadata["recommended"].toBool(); + + // Minecraft Forge + if (p.type == "forge" || p.type == "neoforge") { + p.version = metadata["version"].toString(""); + } + + // Fabric Loader + if (p.type == "fabric") { + p.version = metadata["loader"].toString(""); + } +} + +static void loadVersionLibrary(ATLauncher::VersionLibrary& p, QJsonObject& obj) +{ + p.url = Json::requireString(obj, "url"); + p.file = Json::requireString(obj, "file"); + p.md5 = Json::requireString(obj, "md5"); + + p.download_raw = Json::requireString(obj, "download"); + p.download = parseDownloadType(p.download_raw); + + p.server = obj["server"].toString(""); +} + +static void loadVersionConfigs(ATLauncher::VersionConfigs& p, QJsonObject& obj) +{ + p.filesize = Json::requireInteger(obj, "filesize"); + p.sha1 = Json::requireString(obj, "sha1"); +} + +static void loadVersionMod(ATLauncher::VersionMod& p, QJsonObject& obj) +{ + p.name = Json::requireString(obj, "name"); + p.version = Json::requireString(obj, "version"); + p.url = Json::requireString(obj, "url"); + p.file = Json::requireString(obj, "file"); + p.md5 = obj["md5"].toString(""); + + p.download_raw = Json::requireString(obj, "download"); + p.download = parseDownloadType(p.download_raw); + + p.type_raw = Json::requireString(obj, "type"); + p.type = parseModType(p.type_raw); + + // This contributes to the Minecraft Forge detection, where we rely on mod.type being "Forge" + // when the mod represents Forge. As there is little difference between "Jar" and "Forge, some + // packs regretfully use "Jar". This will correct the type to "Forge" in these cases (as best + // it can). + if (p.name == QString("Minecraft Forge") && p.type == ATLauncher::ModType::Jar) { + p.type_raw = "forge"; + p.type = ATLauncher::ModType::Forge; + } + + if (obj.contains("extractTo")) { + p.extractTo_raw = Json::requireString(obj, "extractTo"); + p.extractTo = parseModType(p.extractTo_raw); + p.extractFolder = obj["extractFolder"].toString("").replace("%s%", "/"); + } + + if (obj.contains("decompType")) { + p.decompType_raw = Json::requireString(obj, "decompType"); + p.decompType = parseModType(p.decompType_raw); + p.decompFile = Json::requireString(obj, "decompFile"); + } + + p.description = obj["description"].toString(""); + p.optional = obj["optional"].toBool(); + p.recommended = obj["recommended"].toBool(); + p.selected = obj["selected"].toBool(); + p.hidden = obj["hidden"].toBool(); + p.library = obj["library"].toBool(); + p.group = obj["group"].toString(""); + if (obj.contains("depends")) { + auto dependsArr = Json::requireArray(obj, "depends"); + for (const auto depends : dependsArr) { + p.depends.append(Json::requireString(depends)); + } + } + p.colour = obj["colour"].toString(""); + p.warning = obj["warning"].toString(""); + + p.client = obj["client"].toBool(); + + // computed + p.effectively_hidden = p.hidden || p.library; +} + +static void loadVersionMessages(ATLauncher::VersionMessages& m, QJsonObject& obj) +{ + m.install = obj["install"].toString(""); + m.update = obj["update"].toString(""); +} + +static void loadVersionMainClass(ATLauncher::PackVersionMainClass& m, QJsonObject& obj) +{ + m.mainClass = obj["mainClass"].toString(""); + m.depends = obj["depends"].toString(""); +} + +static void loadVersionExtraArguments(ATLauncher::PackVersionExtraArguments& a, QJsonObject& obj) +{ + a.arguments = obj["arguments"].toString(""); + a.depends = obj["depends"].toString(""); +} + +static void loadVersionKeep(ATLauncher::VersionKeep& k, QJsonObject& obj) +{ + k.base = Json::requireString(obj, "base"); + k.target = Json::requireString(obj, "target"); +} + +static void loadVersionKeeps(ATLauncher::VersionKeeps& k, QJsonObject& obj) +{ + if (obj.contains("files")) { + auto files = Json::requireArray(obj, "files"); + for (const auto keepRaw : files) { + auto keepObj = Json::requireObject(keepRaw); + ATLauncher::VersionKeep keep; + loadVersionKeep(keep, keepObj); + k.files.append(keep); + } + } + + if (obj.contains("folders")) { + auto folders = Json::requireArray(obj, "folders"); + for (const auto keepRaw : folders) { + auto keepObj = Json::requireObject(keepRaw); + ATLauncher::VersionKeep keep; + loadVersionKeep(keep, keepObj); + k.folders.append(keep); + } + } +} + +static void loadVersionDelete(ATLauncher::VersionDelete& d, QJsonObject& obj) +{ + d.base = Json::requireString(obj, "base"); + d.target = Json::requireString(obj, "target"); +} + +static void loadVersionDeletes(ATLauncher::VersionDeletes& d, QJsonObject& obj) +{ + if (obj.contains("files")) { + auto files = Json::requireArray(obj, "files"); + for (const auto deleteRaw : files) { + auto deleteObj = Json::requireObject(deleteRaw); + ATLauncher::VersionDelete versionDelete; + loadVersionDelete(versionDelete, deleteObj); + d.files.append(versionDelete); + } + } + + if (obj.contains("folders")) { + auto folders = Json::requireArray(obj, "folders"); + for (const auto deleteRaw : folders) { + auto deleteObj = Json::requireObject(deleteRaw); + ATLauncher::VersionDelete versionDelete; + loadVersionDelete(versionDelete, deleteObj); + d.folders.append(versionDelete); + } + } +} + +void ATLauncher::loadVersion(PackVersion& v, QJsonObject& obj) +{ + v.version = Json::requireString(obj, "version"); + v.minecraft = Json::requireString(obj, "minecraft"); + v.noConfigs = obj["noConfigs"].toBool(); + + if (obj.contains("mainClass")) { + auto main = Json::requireObject(obj, "mainClass"); + loadVersionMainClass(v.mainClass, main); + } + + if (obj.contains("extraArguments")) { + auto arguments = Json::requireObject(obj, "extraArguments"); + loadVersionExtraArguments(v.extraArguments, arguments); + } + + if (obj.contains("loader")) { + auto loader = Json::requireObject(obj, "loader"); + loadVersionLoader(v.loader, loader); + } + + if (obj.contains("libraries")) { + auto libraries = Json::requireArray(obj, "libraries"); + for (const auto libraryRaw : libraries) { + auto libraryObj = Json::requireObject(libraryRaw); + ATLauncher::VersionLibrary target; + loadVersionLibrary(target, libraryObj); + v.libraries.append(target); + } + } + + if (obj.contains("mods")) { + auto mods = Json::requireArray(obj, "mods"); + for (const auto modRaw : mods) { + auto modObj = Json::requireObject(modRaw); + ATLauncher::VersionMod mod; + loadVersionMod(mod, modObj); + v.mods.append(mod); + } + } + + if (obj.contains("configs")) { + auto configsObj = Json::requireObject(obj, "configs"); + loadVersionConfigs(v.configs, configsObj); + } + + auto colourObj = obj["colours"].toObject(); + for (const auto& key : colourObj.keys()) { + v.colours[key] = Json::requireString(colourObj.value(key), "colour"); + } + + auto warningsObj = obj["warnings"].toObject(); + for (const auto& key : warningsObj.keys()) { + v.warnings[key] = Json::requireString(warningsObj.value(key), "warning"); + } + + auto messages = obj["messages"].toObject(); + loadVersionMessages(v.messages, messages); + + auto keeps = obj["keeps"].toObject(); + loadVersionKeeps(v.keeps, keeps); + + auto deletes = obj["deletes"].toObject(); + loadVersionDeletes(v.deletes, deletes); +} diff --git a/launcher/modplatform/atlauncher/ATLPackManifest.h b/launcher/modplatform/atlauncher/ATLPackManifest.h new file mode 100644 index 0000000..b6c3b7a --- /dev/null +++ b/launcher/modplatform/atlauncher/ATLPackManifest.h @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2020 Jamie Mansfield + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include + +namespace ATLauncher { + +enum class PackType { Public, Private }; + +enum class ModType { + Root, + Forge, + Jar, + Mods, + Flan, + Dependency, + Ic2Lib, + DenLib, + Coremods, + MCPC, + Plugins, + Extract, + Decomp, + TexturePack, + ResourcePack, + ShaderPack, + TexturePackExtract, + ResourcePackExtract, + Millenaire, + Unknown +}; + +enum class DownloadType { Server, Browser, Direct, Unknown }; + +struct VersionLoader { + QString type; + bool latest; + bool recommended; + bool choose; + + QString version; +}; + +struct VersionLibrary { + QString url; + QString file; + QString server; + QString md5; + DownloadType download; + QString download_raw; +}; + +struct VersionMod { + QString name; + QString version; + QString url; + QString file; + QString md5; + DownloadType download; + QString download_raw; + ModType type; + QString type_raw; + + ModType extractTo; + QString extractTo_raw; + QString extractFolder; + + ModType decompType; + QString decompType_raw; + QString decompFile; + + QString description; + bool optional; + bool recommended; + bool selected; + bool hidden; + bool library; + QString group; + QStringList depends; + QString colour; + QString warning; + + bool client; + + // computed + bool effectively_hidden; +}; + +struct VersionConfigs { + int filesize; + QString sha1; +}; + +struct VersionMessages { + QString install; + QString update; +}; + +struct VersionKeep { + QString base; + QString target; +}; + +struct VersionKeeps { + QList files; + QList folders; +}; + +struct VersionDelete { + QString base; + QString target; +}; + +struct VersionDeletes { + QList files; + QList folders; +}; + +struct PackVersionMainClass { + QString mainClass; + QString depends; +}; + +struct PackVersionExtraArguments { + QString arguments; + QString depends; +}; + +struct PackVersion { + QString version; + QString minecraft; + bool noConfigs; + PackVersionMainClass mainClass; + PackVersionExtraArguments extraArguments; + + VersionLoader loader; + QList libraries; + QList mods; + VersionConfigs configs; + + QMap colours; + QMap warnings; + VersionMessages messages; + + VersionKeeps keeps; + VersionDeletes deletes; +}; + +void loadVersion(PackVersion& v, QJsonObject& obj); + +} // namespace ATLauncher diff --git a/launcher/modplatform/atlauncher/ATLShareCode.cpp b/launcher/modplatform/atlauncher/ATLShareCode.cpp new file mode 100644 index 0000000..800eac5 --- /dev/null +++ b/launcher/modplatform/atlauncher/ATLShareCode.cpp @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "ATLShareCode.h" + +#include "Json.h" + +namespace ATLauncher { + +static void loadShareCodeMod(ShareCodeMod& m, QJsonObject& obj) +{ + m.selected = Json::requireBoolean(obj, "selected"); + m.name = Json::requireString(obj, "name"); +} + +static void loadShareCode(ShareCode& c, QJsonObject& obj) +{ + c.pack = Json::requireString(obj, "pack"); + c.version = Json::requireString(obj, "version"); + + auto mods = Json::requireObject(obj, "mods"); + auto optional = Json::requireArray(mods, "optional"); + for (const auto modRaw : optional) { + auto modObj = Json::requireObject(modRaw); + ShareCodeMod mod; + loadShareCodeMod(mod, modObj); + c.mods.append(mod); + } +} + +void loadShareCodeResponse(ShareCodeResponse& r, QJsonObject& obj) +{ + r.error = Json::requireBoolean(obj, "error"); + r.code = Json::requireInteger(obj, "code"); + + if (obj.contains("message") && !obj.value("message").isNull()) + r.message = Json::requireString(obj, "message"); + + if (!r.error) { + auto dataRaw = Json::requireObject(obj, "data"); + loadShareCode(r.data, dataRaw); + } +} + +} // namespace ATLauncher diff --git a/launcher/modplatform/atlauncher/ATLShareCode.h b/launcher/modplatform/atlauncher/ATLShareCode.h new file mode 100644 index 0000000..9b56c6d --- /dev/null +++ b/launcher/modplatform/atlauncher/ATLShareCode.h @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include +#include + +namespace ATLauncher { + +struct ShareCodeMod { + bool selected; + QString name; +}; + +struct ShareCode { + QString pack; + QString version; + QList mods; +}; + +struct ShareCodeResponse { + bool error; + int code; + QString message; + ShareCode data; +}; + +void loadShareCodeResponse(ShareCodeResponse& r, QJsonObject& obj); + +} // namespace ATLauncher diff --git a/launcher/modplatform/flame/FileResolvingTask.cpp b/launcher/modplatform/flame/FileResolvingTask.cpp new file mode 100644 index 0000000..9cd85ae --- /dev/null +++ b/launcher/modplatform/flame/FileResolvingTask.cpp @@ -0,0 +1,287 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2024 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "FileResolvingTask.h" +#include + +#include "Json.h" +#include "modplatform/ModIndex.h" +#include "modplatform/flame/FlameAPI.h" +#include "modplatform/flame/FlameModIndex.h" +#include "modplatform/modrinth/ModrinthAPI.h" + +#include "modplatform/modrinth/ModrinthPackIndex.h" +#include "net/NetJob.h" +#include "tasks/Task.h" + +#include "Application.h" + +static const FlameAPI flameAPI; +static ModrinthAPI modrinthAPI; + +Flame::FileResolvingTask::FileResolvingTask(Flame::Manifest& toProcess) : m_manifest(toProcess) {} + +bool Flame::FileResolvingTask::abort() +{ + bool aborted = true; + if (m_task) { + aborted = m_task->abort(); + } + return aborted ? Task::abort() : false; +} + +void Flame::FileResolvingTask::executeTask() +{ + if (m_manifest.files.isEmpty()) { // no file to resolve so leave it empty and emit success immediately + emitSucceeded(); + return; + } + setStatus(tr("Resolving mod IDs...")); + setProgress(0, 3); + + QStringList fileIds; + for (auto file : m_manifest.files) { + fileIds.push_back(QString::number(file.fileId)); + } + auto [task, response] = flameAPI.getFiles(fileIds); + m_task = task; + + auto step_progress = std::make_shared(); + connect(m_task.get(), &Task::succeeded, this, [this, response, step_progress]() { + step_progress->state = TaskStepState::Succeeded; + stepProgress(*step_progress); + netJobFinished(response); + }); + connect(m_task.get(), &Task::failed, this, [this, step_progress](QString reason) { + step_progress->state = TaskStepState::Failed; + stepProgress(*step_progress); + emitFailed(reason); + }); + connect(m_task.get(), &Task::stepProgress, this, &FileResolvingTask::propagateStepProgress); + connect(m_task.get(), &Task::progress, this, [this, step_progress](qint64 current, qint64 total) { + qDebug() << "Resolve slug progress" << current << total; + step_progress->update(current, total); + stepProgress(*step_progress); + }); + connect(m_task.get(), &Task::status, this, [this, step_progress](QString status) { + step_progress->status = status; + stepProgress(*step_progress); + }); + + m_task->start(); +} + +ModPlatform::ResourceType getResourceType(int classId) +{ + switch (classId) { + case 17: // Worlds + return ModPlatform::ResourceType::World; + case 6: // Mods + return ModPlatform::ResourceType::Mod; + case 12: // Resource Packs + // return ModPlatform::ResourceType::ResourcePack; // not really a resourcepack + /* fallthrough */ + case 4546: // Customization + // return ModPlatform::ResourceType::ShaderPack; // not really a shaderPack + /* fallthrough */ + case 4471: // Modpacks + /* fallthrough */ + case 5: // Bukkit Plugins + /* fallthrough */ + case 4559: // Addons + /* fallthrough */ + default: + return ModPlatform::ResourceType::Unknown; + } +} + +void Flame::FileResolvingTask::netJobFinished(QByteArray* response) +{ + setProgress(1, 3); + // job to check modrinth for blocked projects + QJsonDocument doc; + QJsonArray array; + + try { + doc = Json::requireDocument(*response); + array = Json::requireArray(doc.object()["data"]); + } catch (Json::JsonException& e) { + qCritical() << "Non-JSON data returned from the CF API"; + qCritical() << e.cause(); + + emitFailed(tr("Invalid data returned from the API.")); + + return; + } + + QStringList hashes; + for (QJsonValueRef file : array) { + try { + auto obj = Json::requireObject(file); + auto version = FlameMod::loadIndexedPackVersion(obj); + auto fileid = version.fileId.toInt(); + Q_ASSERT(fileid != 0); + Q_ASSERT(m_manifest.files.contains(fileid)); + m_manifest.files[fileid].version = version; + auto url = QUrl(version.downloadUrl, QUrl::TolerantMode); + if (!url.isValid() && "sha1" == version.hash_type && !version.hash.isEmpty()) { + hashes.push_back(version.hash); + } + } catch (Json::JsonException& e) { + qCritical() << "Non-JSON data returned from the CF API"; + qCritical() << e.cause(); + + emitFailed(tr("Invalid data returned from the API.")); + + return; + } + } + if (hashes.isEmpty()) { + getFlameProjects(); + return; + } + auto [modrinthTask, modrinthResponse] = modrinthAPI.currentVersions(hashes, "sha1"); + m_task = modrinthTask; + (dynamic_cast(m_task.get()))->setAskRetry(false); + auto step_progress = std::make_shared(); + connect(m_task.get(), &Task::succeeded, this, [this, modrinthResponse, step_progress]() { + step_progress->state = TaskStepState::Succeeded; + stepProgress(*step_progress); + QJsonParseError parse_error{}; + QJsonDocument doc = QJsonDocument::fromJson(*modrinthResponse, &parse_error); + if (parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from Modrinth::CurrentVersions at" << parse_error.offset + << "reason:" << parse_error.errorString(); + qWarning() << *modrinthResponse; + + getFlameProjects(); + return; + } + if (APPLICATION->settings()->get("FallbackMRBlockedMods").toBool()){ + try { + auto entries = Json::requireObject(doc); + for (auto& out : m_manifest.files) { + auto url = QUrl(out.version.downloadUrl, QUrl::TolerantMode); + if (!url.isValid() && "sha1" == out.version.hash_type && !out.version.hash.isEmpty()) { + try { + auto entry = Json::requireObject(entries, out.version.hash); + + auto file = Modrinth::loadIndexedPackVersion(entry); + + out.version.downloadUrl = file.downloadUrl; + qDebug() << "Found alternative on modrinth" << out.version.fileName; + } catch (Json::JsonException& e) { + qDebug() << e.cause(); + qDebug() << entries; + } + } + } + } catch (Json::JsonException& e) { + qDebug() << e.cause(); + qDebug() << doc; + } + } + getFlameProjects(); + }); + connect(m_task.get(), &Task::failed, this, [this, step_progress](QString reason) { + step_progress->state = TaskStepState::Failed; + stepProgress(*step_progress); + getFlameProjects(); + }); + connect(m_task.get(), &Task::stepProgress, this, &FileResolvingTask::propagateStepProgress); + connect(m_task.get(), &Task::progress, this, [this, step_progress](qint64 current, qint64 total) { + qDebug() << "Resolve slug progress" << current << total; + step_progress->update(current, total); + stepProgress(*step_progress); + }); + connect(m_task.get(), &Task::status, this, [this, step_progress](QString status) { + step_progress->status = status; + stepProgress(*step_progress); + }); + m_task->start(); +} + +void Flame::FileResolvingTask::getFlameProjects() +{ + setProgress(2, 3); + QStringList addonIds; + for (auto file : m_manifest.files) { + addonIds.push_back(QString::number(file.projectId)); + } + + auto [task, response] = flameAPI.getProjects(addonIds); + m_task = task; + + auto step_progress = std::make_shared(); + connect(m_task.get(), &Task::succeeded, this, [this, response, step_progress] { + QJsonParseError parse_error{}; + auto doc = QJsonDocument::fromJson(*response, &parse_error); + if (parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from Modrinth projects task at" << parse_error.offset + << "reason:" << parse_error.errorString(); + qWarning() << *response; + return; + } + + try { + QJsonArray entries; + entries = Json::requireArray(Json::requireObject(doc), "data"); + + for (auto entry : entries) { + auto entry_obj = Json::requireObject(entry); + auto id = Json::requireInteger(entry_obj, "id"); + auto file = std::find_if(m_manifest.files.begin(), m_manifest.files.end(), + [id](const Flame::File& file) { return file.projectId == id; }); + if (file == m_manifest.files.end()) { + continue; + } + + setStatus(tr("Parsing API response from CurseForge for '%1'...").arg(file->version.fileName)); + FlameMod::loadIndexedPack(file->pack, entry_obj); + file->resourceType = getResourceType(Json::requireInteger(entry_obj, "classId", "modClassId")); + if (file->resourceType == ModPlatform::ResourceType::World) { + file->targetFolder = "saves"; + } + } + } catch (Json::JsonException& e) { + qDebug() << e.cause(); + qDebug() << doc; + } + step_progress->state = TaskStepState::Succeeded; + stepProgress(*step_progress); + emitSucceeded(); + }); + + connect(m_task.get(), &Task::failed, this, [this, step_progress](QString reason) { + step_progress->state = TaskStepState::Failed; + stepProgress(*step_progress); + emitFailed(reason); + }); + connect(m_task.get(), &Task::stepProgress, this, &FileResolvingTask::propagateStepProgress); + connect(m_task.get(), &Task::progress, this, [this, step_progress](qint64 current, qint64 total) { + qDebug() << "Resolve slug progress" << current << total; + step_progress->update(current, total); + stepProgress(*step_progress); + }); + connect(m_task.get(), &Task::status, this, [this, step_progress](QString status) { + step_progress->status = status; + stepProgress(*step_progress); + }); + + m_task->start(); +} diff --git a/launcher/modplatform/flame/FileResolvingTask.h b/launcher/modplatform/flame/FileResolvingTask.h new file mode 100644 index 0000000..21fa53d --- /dev/null +++ b/launcher/modplatform/flame/FileResolvingTask.h @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2024 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +#pragma once + +#include "PackManifest.h" +#include "tasks/Task.h" + +namespace Flame { +class FileResolvingTask : public Task { + Q_OBJECT + public: + explicit FileResolvingTask(Flame::Manifest& toProcess); + virtual ~FileResolvingTask() = default; + + bool canAbort() const override { return true; } + bool abort() override; + + const Flame::Manifest& getResults() const { return m_manifest; } + + protected: + virtual void executeTask() override; + + protected slots: + void netJobFinished(QByteArray* response); + + private: + void getFlameProjects(); + + private: /* data */ + Flame::Manifest m_manifest; + Task::Ptr m_task; +}; +} // namespace Flame diff --git a/launcher/modplatform/flame/FlameAPI.cpp b/launcher/modplatform/flame/FlameAPI.cpp new file mode 100644 index 0000000..b9b5c22 --- /dev/null +++ b/launcher/modplatform/flame/FlameAPI.cpp @@ -0,0 +1,275 @@ +// SPDX-FileCopyrightText: 2023 flowln +// +// SPDX-License-Identifier: GPL-3.0-only + +#include "FlameAPI.h" +#include +#include +#include "BuildConfig.h" +#include "FlameModIndex.h" + +#include "Application.h" +#include "Json.h" +#include "modplatform/ModIndex.h" +#include "net/ApiDownload.h" +#include "net/ApiUpload.h" +#include "net/NetJob.h" + +std::pair FlameAPI::matchFingerprints(const QList& fingerprints) +{ + auto netJob = makeShared(QString("Flame::MatchFingerprints"), APPLICATION->network()); + + QJsonObject body_obj; + QJsonArray fingerprints_arr; + for (auto& fp : fingerprints) { + fingerprints_arr.append(QString("%1").arg(fp)); + } + + body_obj["fingerprints"] = fingerprints_arr; + + QJsonDocument body(body_obj); + auto body_raw = body.toJson(); + auto [action, response] = Net::ApiUpload::makeByteArray(QString(BuildConfig.FLAME_BASE_URL + "/fingerprints"), body_raw); + netJob->addNetAction(action); + + return { netJob, response }; +} + +QString FlameAPI::getModFileChangelog(int modId, int fileId) +{ + QEventLoop lock; + QString changelog; + + auto netJob = makeShared(QString("Flame::FileChangelog"), APPLICATION->network()); + auto [action, response] = Net::ApiDownload::makeByteArray( + QString(BuildConfig.FLAME_BASE_URL + "/mods/%1/files/%2/changelog") + .arg(QString::fromStdString(std::to_string(modId)), QString::fromStdString(std::to_string(fileId)))); + netJob->addNetAction(action); + + QObject::connect(netJob.get(), &NetJob::succeeded, [&netJob, response, &changelog] { + QJsonParseError parse_error{}; + QJsonDocument doc = QJsonDocument::fromJson(*response, &parse_error); + if (parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from Flame::FileChangelog at" << parse_error.offset + << "reason:" << parse_error.errorString(); + qWarning() << *response; + + netJob->failed(parse_error.errorString()); + return; + } + + changelog = doc.object()["data"].toString(); + }); + + QObject::connect(netJob.get(), &NetJob::finished, [&lock] { lock.quit(); }); + + netJob->start(); + lock.exec(); + + return changelog; +} + +QString FlameAPI::getModDescription(int modId) +{ + QEventLoop lock; + QString description; + + auto netJob = makeShared(QString("Flame::ModDescription"), APPLICATION->network()); + auto [action, response] = + Net::ApiDownload::makeByteArray(QString(BuildConfig.FLAME_BASE_URL + "/mods/%1/description").arg(QString::number(modId))); + netJob->addNetAction(action); + + QObject::connect(netJob.get(), &NetJob::succeeded, [&netJob, response, &description] { + QJsonParseError parse_error{}; + QJsonDocument doc = QJsonDocument::fromJson(*response, &parse_error); + if (parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from Flame::ModDescription at" << parse_error.offset + << "reason:" << parse_error.errorString(); + qWarning() << *response; + + netJob->failed(parse_error.errorString()); + return; + } + + description = doc.object()["data"].toString(); + }); + + QObject::connect(netJob.get(), &NetJob::finished, [&lock] { lock.quit(); }); + + netJob->start(); + lock.exec(); + + return description; +} + +std::pair FlameAPI::getProjects(QStringList addonIds) const +{ + auto netJob = makeShared(QString("Flame::GetProjects"), APPLICATION->network()); + + QJsonObject body_obj; + QJsonArray addons_arr; + for (auto& addonId : addonIds) { + addons_arr.append(addonId); + } + + body_obj["modIds"] = addons_arr; + + QJsonDocument body(body_obj); + auto body_raw = body.toJson(); + auto [action, response] = Net::ApiUpload::makeByteArray(QString(BuildConfig.FLAME_BASE_URL + "/mods"), body_raw); + netJob->addNetAction(action); + + QObject::connect(netJob.get(), &NetJob::failed, [body_raw] { qDebug() << body_raw; }); + + return { netJob, response }; +} + +std::pair FlameAPI::getFiles(const QStringList& fileIds) const +{ + auto netJob = makeShared(QString("Flame::GetFiles"), APPLICATION->network()); + + QJsonObject body_obj; + QJsonArray files_arr; + for (auto& fileId : fileIds) { + files_arr.append(fileId); + } + + body_obj["fileIds"] = files_arr; + + QJsonDocument body(body_obj); + auto body_raw = body.toJson(); + + auto [action, response] = Net::ApiUpload::makeByteArray(QString(BuildConfig.FLAME_BASE_URL + "/mods/files"), body_raw); + netJob->addNetAction(action); + + QObject::connect(netJob.get(), &NetJob::failed, [body_raw] { qDebug() << body_raw; }); + + return { netJob, response }; +} + +std::pair FlameAPI::getFile(const QString& addonId, const QString& fileId) const +{ + auto netJob = makeShared(QString("Flame::GetFile"), APPLICATION->network()); + auto [action, response] = + Net::ApiDownload::makeByteArray(QUrl(QString(BuildConfig.FLAME_BASE_URL + "/mods/%1/files/%2").arg(addonId, fileId))); + netJob->addNetAction(action); + + QObject::connect(netJob.get(), &NetJob::failed, [addonId, fileId] { qDebug() << "Flame API file failure" << addonId << fileId; }); + + return { netJob, response }; +} + +QList FlameAPI::getSortingMethods() const +{ + // https://docs.curseforge.com/?python#tocS_ModsSearchSortField + return { { 1, "Featured", QObject::tr("Sort by Featured") }, + { 2, "Popularity", QObject::tr("Sort by Popularity") }, + { 3, "LastUpdated", QObject::tr("Sort by Last Updated") }, + { 4, "Name", QObject::tr("Sort by Name") }, + { 5, "Author", QObject::tr("Sort by Author") }, + { 6, "TotalDownloads", QObject::tr("Sort by Downloads") }, + { 7, "Category", QObject::tr("Sort by Category") }, + { 8, "GameVersion", QObject::tr("Sort by Game Version") } }; +} + +std::pair FlameAPI::getCategories(ModPlatform::ResourceType type) +{ + auto netJob = makeShared(QString("Flame::GetCategories"), APPLICATION->network()); + auto [action, response] = Net::ApiDownload::makeByteArray( + QUrl(QString(BuildConfig.FLAME_BASE_URL + "/categories?gameId=432&classId=%1").arg(getClassId(type)))); + netJob->addNetAction(action); + QObject::connect(netJob.get(), &Task::failed, [](QString msg) { qDebug() << "Flame failed to get categories:" << msg; }); + return { netJob, response }; +} + +std::pair FlameAPI::getModCategories() +{ + return getCategories(ModPlatform::ResourceType::Mod); +} + +QList FlameAPI::loadModCategories(const QByteArray& response) +{ + QList categories; + QJsonParseError parse_error{}; + QJsonDocument doc = QJsonDocument::fromJson(response, &parse_error); + if (parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from categories at" << parse_error.offset + << "reason:" << parse_error.errorString(); + qWarning() << *response; + return categories; + } + + try { + auto obj = Json::requireObject(doc); + auto arr = Json::requireArray(obj, "data"); + + for (auto val : arr) { + auto cat = Json::requireObject(val); + auto id = Json::requireInteger(cat, "id"); + auto name = Json::requireString(cat, "name"); + categories.push_back({ name, QString::number(id) }); + } + + } catch (Json::JsonException& e) { + qCritical() << "Failed to parse response from a version request."; + qCritical() << e.what(); + qDebug() << doc; + } + return categories; +}; + +std::optional FlameAPI::getLatestVersion(QList versions, + QList instanceLoaders, + ModPlatform::ModLoaderTypes modLoaders, + bool checkLoaders) +{ + static const auto noLoader = ModPlatform::ModLoaderType(0); + if (!checkLoaders) { + std::optional ver; + for (auto file_tmp : versions) { + if (!ver.has_value() || file_tmp.date > ver->date) { + ver = file_tmp; + } + } + return ver; + } + QHash bestMatch; + auto checkVersion = [&bestMatch](const ModPlatform::IndexedVersion& version, const ModPlatform::ModLoaderType& loader) { + if (bestMatch.contains(loader)) { + auto best = bestMatch.value(loader); + if (version.date > best.date) { + bestMatch[loader] = version; + } + } else { + bestMatch[loader] = version; + } + }; + for (auto file_tmp : versions) { + auto loaders = ModPlatform::modLoaderTypesToList(file_tmp.loaders); + if (loaders.isEmpty()) { + checkVersion(file_tmp, noLoader); + } else { + for (auto loader : loaders) { + checkVersion(file_tmp, loader); + } + } + } + // edge case: mod has installed for forge but the instance is fabric => fabric version will be prioritizated on update + auto currentLoaders = instanceLoaders + ModPlatform::modLoaderTypesToList(modLoaders); + currentLoaders.append(noLoader); // add a fallback in case the versions do not define a loader + + for (auto loader : currentLoaders) { + if (bestMatch.contains(loader)) { + auto bestForLoader = bestMatch.value(loader); + // awkward case where the mod has only two loaders and one of them is not specified + if (loader != noLoader && bestMatch.contains(noLoader) && bestMatch.size() == 2) { + auto bestForNoLoader = bestMatch.value(noLoader); + if (bestForNoLoader.date > bestForLoader.date) { + return bestForNoLoader; + } + } + return bestForLoader; + } + } + return {}; +} diff --git a/launcher/modplatform/flame/FlameAPI.h b/launcher/modplatform/flame/FlameAPI.h new file mode 100644 index 0000000..607b32c --- /dev/null +++ b/launcher/modplatform/flame/FlameAPI.h @@ -0,0 +1,175 @@ +// SPDX-FileCopyrightText: 2023 flowln +// +// SPDX-License-Identifier: GPL-3.0-only + +#pragma once + +#include +#include +#include "BuildConfig.h" +#include "Json.h" +#include "Version.h" +#include "modplatform/ModIndex.h" +#include "modplatform/ResourceAPI.h" +#include "modplatform/flame/FlameModIndex.h" + +class FlameAPI : public ResourceAPI { + public: + QString getModFileChangelog(int modId, int fileId); + QString getModDescription(int modId); + + std::optional getLatestVersion(QList versions, + QList instanceLoaders, + ModPlatform::ModLoaderTypes fallback, + bool checkLoaders); + + std::pair getProjects(QStringList addonIds) const override; + std::pair matchFingerprints(const QList& fingerprints); + std::pair getFiles(const QStringList& fileIds) const; + std::pair getFile(const QString& addonId, const QString& fileId) const; + + static std::pair getCategories(ModPlatform::ResourceType type); + static std::pair getModCategories(); + static QList loadModCategories(const QByteArray& response); + + QList getSortingMethods() const override; + + static inline bool validateModLoaders(ModPlatform::ModLoaderTypes loaders) + { + return loaders & (ModPlatform::NeoForge | ModPlatform::Forge | ModPlatform::Fabric | ModPlatform::Quilt); + } + + private: + static int getClassId(ModPlatform::ResourceType type) + { + switch (type) { + default: + case ModPlatform::ResourceType::Mod: + return 6; + case ModPlatform::ResourceType::ResourcePack: + return 12; + case ModPlatform::ResourceType::ShaderPack: + return 6552; + case ModPlatform::ResourceType::Modpack: + return 4471; + case ModPlatform::ResourceType::DataPack: + return 6945; + } + } + + static int getMappedModLoader(ModPlatform::ModLoaderType loaders) + { + // https://docs.curseforge.com/?http#tocS_ModLoaderType + switch (loaders) { + case ModPlatform::Forge: + return 1; + case ModPlatform::Cauldron: + return 2; + case ModPlatform::LiteLoader: + return 3; + case ModPlatform::Fabric: + return 4; + case ModPlatform::Quilt: + return 5; + case ModPlatform::NeoForge: + return 6; + case ModPlatform::DataPack: + case ModPlatform::Babric: + case ModPlatform::BTA: + case ModPlatform::LegacyFabric: + case ModPlatform::Ornithe: + case ModPlatform::Rift: + break; // not supported + } + return 0; + } + + static const QStringList getModLoaderStrings(const ModPlatform::ModLoaderTypes types) + { + QStringList l; + for (auto loader : { ModPlatform::NeoForge, ModPlatform::Forge, ModPlatform::Fabric, ModPlatform::Quilt }) { + if (types & loader) { + l << QString::number(getMappedModLoader(loader)); + } + } + return l; + } + + static const QString getModLoaderFilters(ModPlatform::ModLoaderTypes types) { return "[" + getModLoaderStrings(types).join(',') + "]"; } + + public: + std::optional getSearchURL(const SearchArgs& args) const override + { + QStringList get_arguments; + get_arguments.append(QString("classId=%1").arg(getClassId(args.type))); + get_arguments.append(QString("index=%1").arg(args.offset)); + get_arguments.append("pageSize=25"); + if (args.search.has_value()) + get_arguments.append(QString("searchFilter=%1").arg(args.search.value())); + if (args.sorting.has_value()) + get_arguments.append(QString("sortField=%1").arg(args.sorting.value().index)); + get_arguments.append("sortOrder=desc"); + if (args.loaders.has_value()) { + ModPlatform::ModLoaderTypes loaders = args.loaders.value(); + loaders &= ~static_cast(ModPlatform::ModLoaderType::DataPack); + if (loaders != 0) + get_arguments.append(QString("modLoaderTypes=%1").arg(getModLoaderFilters(loaders))); + } + if (args.categoryIds.has_value() && !args.categoryIds->empty()) + get_arguments.append(QString("categoryIds=[%1]").arg(args.categoryIds->join(","))); + + if (args.versions.has_value() && !args.versions.value().empty()) + get_arguments.append(QString("gameVersion=%1").arg(args.versions.value().front().toString())); + + return BuildConfig.FLAME_BASE_URL + "/mods/search?gameId=432&" + get_arguments.join('&'); + } + + std::optional getVersionsURL(const VersionSearchArgs& args) const override + { + auto addonId = args.pack->addonId.toString(); + QString url = QString(BuildConfig.FLAME_BASE_URL + "/mods/%1/files?pageSize=10000").arg(addonId); + + if (args.mcVersions.has_value()) + url += QString("&gameVersion=%1").arg(args.mcVersions.value().front().toString()); + + if (args.loaders.has_value() && args.loaders.value() != ModPlatform::ModLoaderType::DataPack && + ModPlatform::hasSingleModLoaderSelected(args.loaders.value())) { + int mappedModLoader = getMappedModLoader(static_cast(static_cast(args.loaders.value()))); + url += QString("&modLoaderType=%1").arg(mappedModLoader); + } + return url; + } + + QJsonArray documentToArray(QJsonDocument& obj) const override { return obj.object()["data"].toArray(); } + void loadIndexedPack(ModPlatform::IndexedPack& m, QJsonObject& obj) const override { FlameMod::loadIndexedPack(m, obj); } + ModPlatform::IndexedVersion loadIndexedPackVersion(QJsonObject& obj, ModPlatform::ResourceType resourceType) const override + { + auto arr = FlameMod::loadIndexedPackVersion(obj); + if (resourceType != ModPlatform::ResourceType::TexturePack) { + return arr; + } + // FIXME: Client-side version filtering. This won't take into account any user-selected filtering. + const auto& mc_versions = arr.mcVersion; + + if (std::any_of(mc_versions.constBegin(), mc_versions.constEnd(), + [](const auto& mc_version) { return Version(mc_version) <= Version("1.6"); })) { + return arr; + } + return {}; + }; + void loadExtraPackInfo(ModPlatform::IndexedPack& m, [[maybe_unused]] QJsonObject&) const override { FlameMod::loadBody(m); } + + private: + std::optional getInfoURL(const QString& id) const override { return QString(BuildConfig.FLAME_BASE_URL + "/mods/%1").arg(id); } + std::optional getDependencyURL(const DependencySearchArgs& args) const override + { + auto addonId = args.dependency.addonId.toString(); + auto url = + QString(BuildConfig.FLAME_BASE_URL + "/mods/%1/files?pageSize=10000&gameVersion=%2").arg(addonId, args.mcVersion.toString()); + if (args.loader && ModPlatform::hasSingleModLoaderSelected(args.loader)) { + int mappedModLoader = getMappedModLoader(static_cast(static_cast(args.loader))); + url += QString("&modLoaderType=%1").arg(mappedModLoader); + } + return url; + } +}; diff --git a/launcher/modplatform/flame/FlameCheckUpdate.cpp b/launcher/modplatform/flame/FlameCheckUpdate.cpp new file mode 100644 index 0000000..37f0bcb --- /dev/null +++ b/launcher/modplatform/flame/FlameCheckUpdate.cpp @@ -0,0 +1,202 @@ +#include "FlameCheckUpdate.h" +#include "Application.h" +#include "FlameAPI.h" +#include "FlameModIndex.h" + +#include +#include + +#include "Json.h" + +#include "QObjectPtr.h" +#include "ResourceDownloadTask.h" + +#include "minecraft/mod/tasks/GetModDependenciesTask.h" + +#include "modplatform/ModIndex.h" +#include "net/ApiDownload.h" +#include "net/NetJob.h" +#include "tasks/Task.h" + +static FlameAPI api; + +bool FlameCheckUpdate::abort() +{ + bool result = false; + if (m_task && m_task->canAbort()) { + result = m_task->abort(); + } + Task::abort(); + return result; +} + +/* Check for update: + * - Get latest version available + * - Compare hash of the latest version with the current hash + * - If equal, no updates, else, there's updates, so add to the list + * */ +void FlameCheckUpdate::executeTask() +{ + setStatus(tr("Preparing resources for CurseForge...")); + + auto netJob = new NetJob("Get latest versions", APPLICATION->network()); + connect(netJob, &Task::finished, this, &FlameCheckUpdate::collectBlockedMods); + + connect(netJob, &Task::progress, this, &FlameCheckUpdate::setProgress); + connect(netJob, &Task::stepProgress, this, &FlameCheckUpdate::propagateStepProgress); + connect(netJob, &Task::details, this, &FlameCheckUpdate::setDetails); + for (auto* resource : m_resources) { + auto project = std::make_shared(); + project->addonId = resource->metadata()->project_id.toString(); + auto versionsUrlOptional = api.getVersionsURL({ project, m_gameVersions }); + if (!versionsUrlOptional.has_value()) + continue; + + auto [task, response] = Net::ApiDownload::makeByteArray(versionsUrlOptional.value()); + + connect(task.get(), &Task::succeeded, this, [this, resource, response] { getLatestVersionCallback(resource, response); }); + netJob->addNetAction(task); + } + m_task.reset(netJob); + m_task->start(); +} + +void FlameCheckUpdate::getLatestVersionCallback(Resource* resource, QByteArray* response) +{ + QJsonParseError parse_error{}; + QJsonDocument doc = QJsonDocument::fromJson(*response, &parse_error); + if (parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from latest mod version at" << parse_error.offset + << "reason:" << parse_error.errorString(); + qWarning() << *response; + return; + } + + // Fake pack with the necessary info to pass to the download task :) + auto pack = std::make_shared(); + pack->name = resource->name(); + pack->slug = resource->metadata()->slug; + pack->addonId = resource->metadata()->project_id; + pack->provider = ModPlatform::ResourceProvider::FLAME; + try { + auto obj = Json::requireObject(doc); + auto arr = Json::requireArray(obj, "data"); + + FlameMod::loadIndexedPackVersions(*pack.get(), arr); + } catch (Json::JsonException& e) { + qCritical() << "Failed to parse response from a version request."; + qCritical() << e.what(); + qDebug() << doc; + } + auto latest_ver = api.getLatestVersion(pack->versions, m_loadersList, resource->metadata()->loaders, !m_loadersList.isEmpty()); + + setStatus(tr("Parsing the API response from CurseForge for '%1'...").arg(resource->name())); + + if (!latest_ver.has_value() || !latest_ver->addonId.isValid()) { + QString reason; + if (dynamic_cast(resource) != nullptr) + reason = + tr("No valid version found for this resource. It's probably unavailable for the current game " + "version / mod loader."); + else + reason = tr("No valid version found for this resource. It's probably unavailable for the current game version."); + + emit checkFailed(resource, reason); + return; + } + + if (latest_ver->downloadUrl.isEmpty() && latest_ver->fileId != resource->metadata()->file_id) { + m_blocked[resource] = latest_ver->fileId.toString(); + return; + } + + if (!latest_ver->hash.isEmpty() && + (resource->metadata()->hash != latest_ver->hash || resource->status() == ResourceStatus::NOT_INSTALLED)) { + auto old_version = resource->metadata()->version_number; + if (old_version.isEmpty()) { + if (resource->status() == ResourceStatus::NOT_INSTALLED) + old_version = tr("Not installed"); + else + old_version = tr("Unknown"); + } + + auto download_task = makeShared(pack, latest_ver.value(), m_resourceModel); + m_updates.emplace_back(pack->name, resource->metadata()->hash, old_version, latest_ver->version, latest_ver->version_type, + api.getModFileChangelog(latest_ver->addonId.toInt(), latest_ver->fileId.toInt()), + ModPlatform::ResourceProvider::FLAME, download_task, resource->enabled()); + } + m_deps.append(std::make_shared(pack, latest_ver.value())); +} + +void FlameCheckUpdate::collectBlockedMods() +{ + QStringList addonIds; + QHash quickSearch; + for (auto const& resource : m_blocked.keys()) { + auto addonId = resource->metadata()->project_id.toString(); + addonIds.append(addonId); + quickSearch[addonId] = resource; + } + + Task::Ptr projTask; + QByteArray* response; + + if (addonIds.isEmpty()) { + emitSucceeded(); + return; + } else if (addonIds.size() == 1) { + std::tie(projTask, response) = api.getProject(*addonIds.begin()); + } else { + std::tie(projTask, response) = api.getProjects(addonIds); + } + + connect(projTask.get(), &Task::succeeded, this, [this, response, addonIds, quickSearch] { + QJsonParseError parse_error{}; + auto doc = QJsonDocument::fromJson(*response, &parse_error); + if (parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from Flame projects task at" << parse_error.offset + << "reason:" << parse_error.errorString(); + qWarning() << *response; + return; + } + + try { + QJsonArray entries; + if (addonIds.size() == 1) + entries = { Json::requireObject(Json::requireObject(doc), "data") }; + else + entries = Json::requireArray(Json::requireObject(doc), "data"); + + for (auto entry : entries) { + auto entry_obj = Json::requireObject(entry); + + auto id = QString::number(Json::requireInteger(entry_obj, "id")); + + auto resource = quickSearch.find(id).value(); + + ModPlatform::IndexedPack pack; + try { + setStatus(tr("Parsing API response from CurseForge for '%1'...").arg(resource->name())); + + FlameMod::loadIndexedPack(pack, entry_obj); + auto recover_url = QString("%1/download/%2").arg(pack.websiteUrl, m_blocked[resource]); + emit checkFailed(resource, tr("Resource has a new update available, but is not downloadable using CurseForge."), + recover_url); + } catch (Json::JsonException& e) { + qDebug() << e.cause(); + qDebug() << entries; + } + } + } catch (Json::JsonException& e) { + qDebug() << e.cause(); + qDebug() << doc; + } + }); + + connect(projTask.get(), &Task::finished, this, &FlameCheckUpdate::emitSucceeded); // do not care much about error + connect(projTask.get(), &Task::progress, this, &FlameCheckUpdate::setProgress); + connect(projTask.get(), &Task::stepProgress, this, &FlameCheckUpdate::propagateStepProgress); + connect(projTask.get(), &Task::details, this, &FlameCheckUpdate::setDetails); + m_task.reset(projTask); + m_task->start(); +} diff --git a/launcher/modplatform/flame/FlameCheckUpdate.h b/launcher/modplatform/flame/FlameCheckUpdate.h new file mode 100644 index 0000000..c2b3c9c --- /dev/null +++ b/launcher/modplatform/flame/FlameCheckUpdate.h @@ -0,0 +1,29 @@ +#pragma once + +#include "modplatform/CheckUpdateTask.h" + +class FlameCheckUpdate : public CheckUpdateTask { + Q_OBJECT + + public: + FlameCheckUpdate(QList& resources, + std::vector& mcVersions, + QList loadersList, + ResourceFolderModel* resourceModel) + : CheckUpdateTask(resources, mcVersions, std::move(loadersList), resourceModel) + {} + + public slots: + bool abort() override; + + protected slots: + void executeTask() override; + private slots: + void getLatestVersionCallback(Resource* resource, QByteArray* response); + void collectBlockedMods(); + + private: + Task::Ptr m_task = nullptr; + + QHash m_blocked; +}; diff --git a/launcher/modplatform/flame/FlameInstanceCreationTask.cpp b/launcher/modplatform/flame/FlameInstanceCreationTask.cpp new file mode 100644 index 0000000..534132a --- /dev/null +++ b/launcher/modplatform/flame/FlameInstanceCreationTask.cpp @@ -0,0 +1,726 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "FlameInstanceCreationTask.h" + +#include "InstanceTask.h" +#include "QObjectPtr.h" +#include "minecraft/mod/tasks/LocalResourceUpdateTask.h" +#include "modplatform/flame/FileResolvingTask.h" +#include "modplatform/flame/FlameAPI.h" +#include "modplatform/flame/FlameModIndex.h" +#include "modplatform/flame/PackManifest.h" + +#include "Application.h" +#include "FileSystem.h" +#include "InstanceList.h" +#include "Json.h" + +#include "minecraft/MinecraftInstance.h" +#include "minecraft/PackProfile.h" + +#include "modplatform/helpers/OverrideUtils.h" + +#include "settings/INISettingsObject.h" + +#include "SysInfo.h" +#include "tasks/ConcurrentTask.h" +#include "ui/dialogs/BlockedModsDialog.h" +#include "ui/dialogs/CustomMessageBox.h" + +#include +#include + +#include "HardwareInfo.h" +#include "meta/Index.h" +#include "minecraft/World.h" +#include "minecraft/mod/tasks/LocalResourceParse.h" +#include "net/ApiDownload.h" +#include "ui/pages/modplatform/OptionalModDialog.h" + +static const FlameAPI api; + +bool FlameCreationTask::abort() +{ + if (!canAbort()) + return false; + + if (m_processUpdateFileInfoJob) + m_processUpdateFileInfoJob->abort(); + if (m_filesJob) + m_filesJob->abort(); + if (m_modIdResolver) + m_modIdResolver->abort(); + + return InstanceCreationTask::abort(); +} + +bool FlameCreationTask::updateInstance() +{ + auto instance_list = APPLICATION->instances(); + + // FIXME: How to handle situations when there's more than one install already for a given modpack? + BaseInstance* inst; + if (auto original_id = originalInstanceID(); !original_id.isEmpty()) { + inst = instance_list->getInstanceById(original_id); + Q_ASSERT(inst); + } else { + inst = instance_list->getInstanceByManagedName(originalName()); + + if (!inst) { + inst = instance_list->getInstanceById(originalName()); + + if (!inst) + return false; + } + } + + QString index_path(FS::PathCombine(m_stagingPath, "manifest.json")); + + try { + Flame::loadManifest(m_pack, index_path); + } catch (const JSONValidationError& e) { + setError(tr("Could not understand pack manifest:\n") + e.cause()); + return false; + } + + auto version_id = inst->getManagedPackVersionName(); + auto version_str = !version_id.isEmpty() ? tr(" (version %1)").arg(version_id) : ""; + + if (shouldConfirmUpdate()) { + auto should_update = askIfShouldUpdate(m_parent, version_str); + if (should_update == ShouldUpdate::SkipUpdating) + return false; + if (should_update == ShouldUpdate::Cancel) { + m_abort = true; + return false; + } + } + + QDir old_inst_dir(inst->instanceRoot()); + + QString old_index_folder(FS::PathCombine(old_inst_dir.absolutePath(), "flame")); + QString old_index_path(FS::PathCombine(old_index_folder, "manifest.json")); + + QFileInfo old_index_file(old_index_path); + if (old_index_file.exists()) { + Flame::Manifest old_pack; + Flame::loadManifest(old_pack, old_index_path); + + auto& old_files = old_pack.files; + + auto& files = m_pack.files; + + // Remove repeated files, we don't need to download them! + auto files_iterator = files.begin(); + while (files_iterator != files.end()) { + auto const& file = files_iterator; + + auto old_file = old_files.find(file.key()); + if (old_file != old_files.end()) { + // We found a match, but is it a different version? + if (old_file->fileId == file->fileId) { + qDebug() << "Removed file at" << file->targetFolder << "with id" << file->fileId << "from list of downloads"; + + old_files.remove(file.key()); + files_iterator = files.erase(files_iterator); + + if (files_iterator != files.begin()) + files_iterator--; + } + } + + files_iterator++; + } + + QDir old_minecraft_dir(inst->gameRoot()); + + // We will remove all the previous overrides, to prevent duplicate files! + // TODO: Currently 'overrides' will always override the stuff on update. How do we preserve unchanged overrides? + // FIXME: We may want to do something about disabled mods. + auto old_overrides = Override::readOverrides("overrides", old_index_folder); + for (const auto& entry : old_overrides) { + scheduleToDelete(m_parent, old_minecraft_dir, entry); + } + + // Remove remaining old files (we need to do an API request to know which ids are which files...) + QStringList fileIds; + + for (auto& file : old_files) { + fileIds.append(QString::number(file.fileId)); + } + + auto [job, raw_response] = api.getFiles(fileIds); + + QEventLoop loop; + + connect(job.get(), &Task::succeeded, this, [this, raw_response, fileIds, old_inst_dir, &old_files, old_minecraft_dir] { + // Parse the API response + QJsonParseError parse_error{}; + auto doc = QJsonDocument::fromJson(*raw_response, &parse_error); + if (parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from Flame files task at" << parse_error.offset + << "reason:" << parse_error.errorString(); + qWarning() << *raw_response; + return; + } + + try { + QJsonArray entries; + if (fileIds.size() == 1) + entries = { Json::requireObject(Json::requireObject(doc), "data") }; + else + entries = Json::requireArray(Json::requireObject(doc), "data"); + + for (auto entry : entries) { + auto entry_obj = Json::requireObject(entry); + + Flame::File file; + // We don't care about blocked mods, we just need local data to delete the file + file.version = FlameMod::loadIndexedPackVersion(entry_obj); + auto id = Json::requireInteger(entry_obj, "id"); + old_files.insert(id, file); + } + } catch (Json::JsonException& e) { + qCritical() << e.cause() << e.what(); + } + + // Delete the files + for (auto& file : old_files) { + if (file.version.fileName.isEmpty() || file.targetFolder.isEmpty()) + continue; + + QString relative_path(FS::PathCombine(file.targetFolder, file.version.fileName)); + scheduleToDelete(m_parent, old_minecraft_dir, relative_path, true); + } + }); + connect(job.get(), &Task::failed, this, [](QString reason) { qCritical() << "Failed to get files:" << reason; }); + connect(job.get(), &Task::finished, &loop, &QEventLoop::quit); + + m_processUpdateFileInfoJob = job; + job->start(); + + loop.exec(); + + m_processUpdateFileInfoJob = nullptr; + } else { + // We don't have an old index file, so we may duplicate stuff! + auto dialog = CustomMessageBox::selectable(m_parent, tr("No index file."), + tr("We couldn't find a suitable index file for the older version. This may cause some " + "of the files to be duplicated. Do you want to continue?"), + QMessageBox::Warning, QMessageBox::Ok | QMessageBox::Cancel); + + if (dialog->exec() == QDialog::DialogCode::Rejected) { + m_abort = true; + return false; + } + } + + setOverride(true, inst->id()); + qDebug() << "Will override instance!"; + + m_instance = inst; + + // We let it go through the createInstance() stage, just with a couple modifications for updating + return false; +} + +QString FlameCreationTask::getVersionForLoader(QString uid, QString loaderType, QString loaderVersion, QString mcVersion) +{ + if (loaderVersion == "recommended") { + auto vlist = APPLICATION->metadataIndex()->get(uid); + if (!vlist) { + setError(tr("Failed to get local metadata index for %1").arg(uid)); + return {}; + } + + if (!vlist->isLoaded()) { + QEventLoop loadVersionLoop; + auto task = vlist->getLoadTask(); + connect(task.get(), &Task::finished, &loadVersionLoop, &QEventLoop::quit); + if (!task->isRunning()) + task->start(); + + loadVersionLoop.exec(); + } + + for (auto version : vlist->versions()) { + // first recommended build we find, we use. + if (!version->isRecommended()) + continue; + auto reqs = version->requiredSet(); + + // filter by minecraft version, if the loader depends on a certain version. + // not all mod loaders depend on a given Minecraft version, so we won't do this + // filtering for those loaders. + if (loaderType == "forge" || loaderType == "neoforge") { + auto iter = std::find_if(reqs.begin(), reqs.end(), [mcVersion](const Meta::Require& req) { + return req.uid == "net.minecraft" && req.equalsVersion == mcVersion; + }); + if (iter == reqs.end()) + continue; + } + return version->descriptor(); + } + + setError(tr("Failed to find version for %1 loader").arg(loaderType)); + return {}; + } + + if (loaderVersion.isEmpty()) { + emitFailed(tr("No loader version set for modpack!")); + return {}; + } + + return loaderVersion; +} + +std::unique_ptr FlameCreationTask::createInstance() +{ + QEventLoop loop; + + QString parent_folder(FS::PathCombine(m_stagingPath, "flame")); + + try { + QString index_path(FS::PathCombine(m_stagingPath, "manifest.json")); + if (!m_pack.is_loaded) + Flame::loadManifest(m_pack, index_path); + + // Keep index file in case we need it some other time (like when changing versions) + QString new_index_place(FS::PathCombine(parent_folder, "manifest.json")); + FS::ensureFilePathExists(new_index_place); + FS::move(index_path, new_index_place); + + } catch (const JSONValidationError& e) { + setError(tr("Could not understand pack manifest:\n") + e.cause()); + return nullptr; + } + + if (!m_pack.overrides.isEmpty()) { + QString overridePath = FS::PathCombine(m_stagingPath, m_pack.overrides); + if (QFile::exists(overridePath)) { + // Create a list of overrides in "overrides.txt" inside flame/ + Override::createOverrides("overrides", parent_folder, overridePath); + + QString mcPath = FS::PathCombine(m_stagingPath, "minecraft"); + if (!FS::move(overridePath, mcPath)) { + setError(tr("Could not rename the overrides folder:\n") + m_pack.overrides); + return nullptr; + } + } else { + logWarning( + tr("The specified overrides folder (%1) is missing. Maybe the modpack was already used before?").arg(m_pack.overrides)); + } + } + + QString loaderType; + QString loaderUid; + QString loaderVersion; + + for (auto& loader : m_pack.minecraft.modLoaders) { + auto id = loader.id; + if (id.startsWith("neoforge-")) { + id.remove("neoforge-"); + if (id.startsWith("1.20.1-")) + id.remove("1.20.1-"); // this is a mess for curseforge + loaderType = "neoforge"; + loaderUid = "net.neoforged"; + } else if (id.startsWith("forge-")) { + id.remove("forge-"); + loaderType = "forge"; + loaderUid = "net.minecraftforge"; + } else if (id.startsWith("fabric-")) { + id.remove("fabric-"); + loaderType = "fabric"; + loaderUid = "net.fabricmc.fabric-loader"; + } else if (id.startsWith("quilt-")) { + id.remove("quilt-"); + loaderType = "quilt"; + loaderUid = "org.quiltmc.quilt-loader"; + } else { + logWarning(tr("Unknown mod loader in manifest: %1").arg(id)); + continue; + } + loaderVersion = id; + } + + QString configPath = FS::PathCombine(m_stagingPath, "instance.cfg"); + auto instanceSettings = std::make_unique(configPath); + auto instance = std::make_unique(m_globalSettings, std::move(instanceSettings), m_stagingPath); + auto mcVersion = m_pack.minecraft.version; + + // Hack to correct some 'special sauce'... + if (mcVersion.endsWith('.')) { + static const QRegularExpression s_regex("[.]+$"); + mcVersion.remove(s_regex); + logWarning(tr("Mysterious trailing dots removed from Minecraft version while importing pack.")); + } + + auto components = instance->getPackProfile(); + components->buildingFromScratch(); + components->setComponentVersion("net.minecraft", mcVersion, true); + if (!loaderType.isEmpty()) { + auto version = getVersionForLoader(loaderUid, loaderType, loaderVersion, mcVersion); + if (version.isEmpty()) + return nullptr; + components->setComponentVersion(loaderUid, version); + } + + if (m_instIcon != "default") { + instance->setIconKey(m_instIcon); + } else { + if (m_pack.name.contains("Direwolf20")) { + instance->setIconKey("steve"); + } else if (m_pack.name.contains("FTB") || m_pack.name.contains("Feed The Beast")) { + instance->setIconKey("ftb_logo"); + } else { + instance->setIconKey("flame"); + } + } + + int recommendedRAM = m_pack.minecraft.recommendedRAM; + + // only set memory if this is a fresh instance + if (!m_instance && recommendedRAM > 0) { + const uint64_t sysMiB = HardwareInfo::totalRamMiB(); + const uint64_t max = sysMiB * 0.9; + + if (static_cast(recommendedRAM) > max) { + logWarning(tr("The recommended memory of the modpack exceeds 90% of your system RAM—reducing it from %1 MiB to %2 MiB!") + .arg(recommendedRAM) + .arg(max)); + recommendedRAM = max; + } + + instance->settings()->set("OverrideMemory", true); + instance->settings()->set("MaxMemAlloc", recommendedRAM); + } + + QString jarmodsPath = FS::PathCombine(m_stagingPath, "minecraft", "jarmods"); + QFileInfo jarmodsInfo(jarmodsPath); + if (jarmodsInfo.isDir()) { + // install all the jar mods + qDebug() << "Found jarmods:"; + QDir jarmodsDir(jarmodsPath); + QStringList jarMods; + for (const auto& info : jarmodsDir.entryInfoList(QDir::NoDotAndDotDot | QDir::Files)) { + qDebug() << info.fileName(); + jarMods.push_back(info.absoluteFilePath()); + } + auto profile = instance->getPackProfile(); + profile->installJarMods(jarMods); + // nuke the original files + FS::deletePath(jarmodsPath); + } + + // Don't add managed info to packs without an ID (most likely imported from ZIP) + if (!m_managedId.isEmpty()) + instance->setManagedPack("flame", m_managedId, m_pack.name, m_managedVersionId, m_pack.version); + else + instance->setManagedPack("flame", "", name(), "", ""); + + instance->setName(name()); + + m_modIdResolver.reset(new Flame::FileResolvingTask(m_pack)); + connect(m_modIdResolver.get(), &Flame::FileResolvingTask::succeeded, this, [this, &loop] { idResolverSucceeded(loop); }); + connect(m_modIdResolver.get(), &Flame::FileResolvingTask::failed, [this, &loop](QString reason) { + m_modIdResolver.reset(); + setError(tr("Unable to resolve mod IDs:\n") + reason); + loop.quit(); + }); + connect(m_modIdResolver.get(), &Flame::FileResolvingTask::aborted, &loop, &QEventLoop::quit); + connect(m_modIdResolver.get(), &Flame::FileResolvingTask::progress, this, &FlameCreationTask::setProgress); + connect(m_modIdResolver.get(), &Flame::FileResolvingTask::status, this, &FlameCreationTask::setStatus); + connect(m_modIdResolver.get(), &Flame::FileResolvingTask::stepProgress, this, &FlameCreationTask::propagateStepProgress); + connect(m_modIdResolver.get(), &Flame::FileResolvingTask::details, this, &FlameCreationTask::setDetails); + m_modIdResolver->start(); + + loop.exec(); + + bool did_succeed = getError().isEmpty(); + + // Update information of the already installed instance, if any. + if (m_instance && did_succeed) { + setAbortable(false); + auto inst = m_instance.value(); + + inst->copyManagedPack(*instance); + } + + if (did_succeed) { + return instance; + } + return nullptr; +} + +void FlameCreationTask::idResolverSucceeded(QEventLoop& loop) +{ + auto results = m_modIdResolver->getResults().files; + + QStringList optionalFiles; + for (auto& result : results) { + if (!result.required) { + optionalFiles << FS::PathCombine(result.targetFolder, result.version.fileName); + } + } + + if (!optionalFiles.empty()) { + OptionalModDialog optionalModDialog(m_parent, optionalFiles); + if (optionalModDialog.exec() == QDialog::Rejected) { + emitAborted(); + loop.quit(); + return; + } + + m_selectedOptionalMods = optionalModDialog.getResult(); + } + + // first check for blocked mods + QList blocked_mods; + auto anyBlocked = false; + for (const auto& result : results.values()) { + if (result.resourceType != ModPlatform::ResourceType::Mod) { + m_otherResources.append(std::make_pair(result.version.fileName, result.targetFolder)); + } + + // skip optional mods that were not selected + if (result.version.downloadUrl.isEmpty()) { + BlockedMod blocked_mod; + blocked_mod.name = result.version.fileName; + blocked_mod.websiteUrl = QString("%1/download/%2").arg(result.pack.websiteUrl, QString::number(result.fileId)); + blocked_mod.hash = result.version.hash; + blocked_mod.matched = false; + blocked_mod.localPath = ""; + blocked_mod.targetFolder = result.targetFolder; + auto fileName = result.version.fileName; + fileName = FS::RemoveInvalidPathChars(fileName); + auto relpath = FS::PathCombine(result.targetFolder, fileName); + blocked_mod.disabled = !result.required && !m_selectedOptionalMods.contains(relpath); + + blocked_mods.append(blocked_mod); + + anyBlocked = true; + } + } + if (anyBlocked) { + qWarning() << "Blocked mods found, displaying mod list"; + + BlockedModsDialog message_dialog(m_parent, tr("Blocked mods found"), + tr("The following files are not available for download in third party launchers.
" + "You will need to manually download them and add them to the instance."), + blocked_mods); + + message_dialog.setModal(true); + + if (message_dialog.exec()) { + qDebug() << "Post dialog blocked mods list:" << blocked_mods; + copyBlockedMods(blocked_mods); + setupDownloadJob(loop); + } else { + m_modIdResolver.reset(); + setError("Canceled"); + loop.quit(); + } + } else { + setupDownloadJob(loop); + } +} + +void FlameCreationTask::setupDownloadJob(QEventLoop& loop) +{ + m_filesJob.reset(new NetJob(tr("Mod Download Flame"), APPLICATION->network())); + auto results = m_modIdResolver->getResults().files; + + for (const auto& result : results) { + auto fileName = result.version.fileName; + fileName = FS::RemoveInvalidPathChars(fileName); + auto relpath = FS::PathCombine(result.targetFolder, fileName); + + if (!result.required && !m_selectedOptionalMods.contains(relpath)) { + relpath += ".disabled"; + } + + relpath = FS::PathCombine("minecraft", relpath); + auto path = FS::PathCombine(m_stagingPath, relpath); + + if (!result.version.downloadUrl.isEmpty()) { + qDebug() << "Will download" << result.version.downloadUrl << "to" << path; + auto dl = Net::ApiDownload::makeFile(result.version.downloadUrl, path); + m_filesJob->addNetAction(dl); + } + } + + connect(m_filesJob.get(), &NetJob::finished, this, [this, &loop]() { + m_filesJob.reset(); + validateOtherResources(loop); + }); + connect(m_filesJob.get(), &NetJob::failed, [this](QString reason) { + m_filesJob.reset(); + setError(reason); + }); + connect(m_filesJob.get(), &NetJob::progress, this, [this](qint64 current, qint64 total) { + setDetails(tr("%1 out of %2 complete").arg(current).arg(total)); + setProgress(current, total); + }); + connect(m_filesJob.get(), &NetJob::stepProgress, this, &FlameCreationTask::propagateStepProgress); + + setStatus(tr("Downloading mods...")); + m_filesJob->start(); +} + +/// @brief copy the matched blocked mods to the instance staging area +/// @param blocked_mods list of the blocked mods and their matched paths +void FlameCreationTask::copyBlockedMods(QList const& blocked_mods) +{ + setStatus(tr("Copying Blocked Mods...")); + setAbortable(false); + int i = 0; + int total = blocked_mods.length(); + setProgress(i, total); + for (auto const& mod : blocked_mods) { + if (!mod.matched) { + qDebug() << mod.name << "was not matched to a local file, skipping copy"; + continue; + } + + auto destPath = FS::PathCombine(m_stagingPath, "minecraft", mod.targetFolder, mod.name); + if (mod.disabled) + destPath += ".disabled"; + + setStatus(tr("Copying Blocked Mods (%1 out of %2 are done)").arg(QString::number(i), QString::number(total))); + + qDebug() << "Will try to copy" << mod.localPath << "to" << destPath; + + if (mod.move) { + if (!FS::move(mod.localPath, destPath)) { + qDebug() << "Move of" << mod.localPath << "to" << destPath << "Failed"; + } + } else { + if (!FS::copy(mod.localPath, destPath)()) { + qDebug() << "Copy of" << mod.localPath << "to" << destPath << "Failed"; + } + } + + i++; + setProgress(i, total); + } + + setAbortable(true); +} + +void FlameCreationTask::validateOtherResources(QEventLoop& loop) +{ + qDebug() << "Validating whether other resources are in the right place"; + QStringList zipMods; + for (auto [fileName, targetFolder] : m_otherResources) { + qDebug() << "Checking" << fileName << "..."; + auto localPath = FS::PathCombine(m_stagingPath, "minecraft", targetFolder, fileName); + + /// @brief check the target and move the the file + /// @return path where file can now be found + auto validatePath = [&localPath, this](QString fileName, QString targetFolder, QString realTarget) { + if (targetFolder != realTarget) { + qDebug() << "Target folder of" << fileName << "is incorrect, it belongs in" << realTarget; + auto destPath = FS::PathCombine(m_stagingPath, "minecraft", realTarget, fileName); + qDebug() << "Moving" << localPath << "to" << destPath; + if (FS::move(localPath, destPath)) { + return destPath; + } + } else { + qDebug() << "Target folder of" << fileName << "is correct at" << targetFolder; + } + return localPath; + }; + + auto installWorld = [this](QString worldPath) { + qDebug() << "Installing World from" << worldPath; + QFileInfo worldFileInfo(worldPath); + World w(worldFileInfo); + if (!w.isValid()) { + qDebug() << "World at" << worldPath << "is not valid, skipping install."; + } else { + w.install(FS::PathCombine(m_stagingPath, "minecraft", "saves")); + } + }; + + QFileInfo localFileInfo(localPath); + auto type = ResourceUtils::identify(localFileInfo); + + QString worldPath; + + switch (type) { + case ModPlatform::ResourceType::Mod: + validatePath(fileName, targetFolder, "mods"); + zipMods.push_back(fileName); + break; + case ModPlatform::ResourceType::ResourcePack: + validatePath(fileName, targetFolder, "resourcepacks"); + break; + case ModPlatform::ResourceType::TexturePack: + validatePath(fileName, targetFolder, "texturepacks"); + break; + case ModPlatform::ResourceType::DataPack: + validatePath(fileName, targetFolder, "datapacks"); + break; + case ModPlatform::ResourceType::ShaderPack: + // in theory flame API can't do this but who knows, that *may* change ? + // better to handle it if it *does* occur in the future + validatePath(fileName, targetFolder, "shaderpacks"); + break; + case ModPlatform::ResourceType::World: + worldPath = validatePath(fileName, targetFolder, "saves"); + installWorld(worldPath); + break; + case ModPlatform::ResourceType::Unknown: + /* fallthrough */ + default: + qDebug() << "Can't Identify" << fileName << "at" << localPath << ", leaving it where it is."; + break; + } + } + // TODO make this work with other sorts of resource + auto task = makeShared("CreateModMetadata", APPLICATION->settings()->get("NumberOfConcurrentTasks").toInt()); + auto results = m_modIdResolver->getResults().files; + auto folder = FS::PathCombine(m_stagingPath, "minecraft", "mods", ".index"); + for (auto file : results) { + if (file.targetFolder != "mods" || (file.version.fileName.endsWith(".zip") && !zipMods.contains(file.version.fileName))) { + continue; + } + task->addTask(makeShared(folder, file.pack, file.version)); + } + connect(task.get(), &Task::finished, &loop, &QEventLoop::quit); + m_processUpdateFileInfoJob = task; + task->start(); +} diff --git a/launcher/modplatform/flame/FlameInstanceCreationTask.h b/launcher/modplatform/flame/FlameInstanceCreationTask.h new file mode 100644 index 0000000..221ceaf --- /dev/null +++ b/launcher/modplatform/flame/FlameInstanceCreationTask.h @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "InstanceCreationTask.h" + +#include + +#include "minecraft/MinecraftInstance.h" + +#include "modplatform/flame/FileResolvingTask.h" + +#include "net/NetJob.h" + +#include "ui/dialogs/BlockedModsDialog.h" + +class FlameCreationTask final : public InstanceCreationTask { + Q_OBJECT + + public: + FlameCreationTask(const QString& staging_path, + SettingsObject* global_settings, + QWidget* parent, + QString id, + QString version_id, + QString original_instance_id = {}) + : InstanceCreationTask(), m_parent(parent), m_managedId(std::move(id)), m_managedVersionId(std::move(version_id)) + { + setStagingPath(staging_path); + setParentSettings(global_settings); + + m_original_instance_id = std::move(original_instance_id); + } + + bool abort() override; + + bool updateInstance() override; + std::unique_ptr createInstance() override; + + private slots: + void idResolverSucceeded(QEventLoop&); + void setupDownloadJob(QEventLoop&); + void copyBlockedMods(QList const& blocked_mods); + void validateOtherResources(QEventLoop& loop); + QString getVersionForLoader(QString uid, QString loaderType, QString version, QString mcVersion); + + private: + QWidget* m_parent = nullptr; + + shared_qobject_ptr m_modIdResolver; + Flame::Manifest m_pack; + + // Handle to allow aborting + Task::Ptr m_processUpdateFileInfoJob = nullptr; + NetJob::Ptr m_filesJob = nullptr; + + QString m_managedId, m_managedVersionId; + + QList> m_otherResources; + + std::optional m_instance; + + QStringList m_selectedOptionalMods; +}; diff --git a/launcher/modplatform/flame/FlameModIndex.cpp b/launcher/modplatform/flame/FlameModIndex.cpp new file mode 100644 index 0000000..3b0f7f6 --- /dev/null +++ b/launcher/modplatform/flame/FlameModIndex.cpp @@ -0,0 +1,210 @@ +#include "FlameModIndex.h" + +#include "FileSystem.h" +#include "Json.h" +#include "minecraft/MinecraftInstance.h" +#include "minecraft/PackProfile.h" +#include "modplatform/ModIndex.h" +#include "modplatform/flame/FlameAPI.h" + +static FlameAPI api; + +void FlameMod::loadIndexedPack(ModPlatform::IndexedPack& pack, QJsonObject& obj) +{ + pack.addonId = Json::requireInteger(obj, "id"); + pack.provider = ModPlatform::ResourceProvider::FLAME; + pack.name = Json::requireString(obj, "name"); + pack.slug = Json::requireString(obj, "slug"); + pack.websiteUrl = obj["links"].toObject()["websiteUrl"].toString(""); + pack.description = obj["summary"].toString(""); + + QJsonObject logo = obj["logo"].toObject(); + pack.logoName = logo["title"].toString(); + pack.logoUrl = logo["thumbnailUrl"].toString(); + if (pack.logoUrl.isEmpty()) { + pack.logoUrl = logo["url"].toString(); + } + + auto authors = obj["authors"].toArray(); + if (!authors.isEmpty()) { + pack.authors.clear(); + for (auto authorIter : authors) { + auto author = Json::requireObject(authorIter); + ModPlatform::ModpackAuthor packAuthor; + packAuthor.name = Json::requireString(author, "name"); + packAuthor.url = Json::requireString(author, "url"); + pack.authors.append(packAuthor); + } + } + + pack.extraDataLoaded = false; + loadURLs(pack, obj); +} + +void FlameMod::loadURLs(ModPlatform::IndexedPack& pack, QJsonObject& obj) +{ + auto links_obj = obj["links"].toObject(); + + pack.extraData.issuesUrl = links_obj["issuesUrl"].toString(); + if (pack.extraData.issuesUrl.endsWith('/')) + pack.extraData.issuesUrl.chop(1); + + pack.extraData.sourceUrl = links_obj["sourceUrl"].toString(); + if (pack.extraData.sourceUrl.endsWith('/')) + pack.extraData.sourceUrl.chop(1); + + pack.extraData.wikiUrl = links_obj["wikiUrl"].toString(); + if (pack.extraData.wikiUrl.endsWith('/')) + pack.extraData.wikiUrl.chop(1); + + if (!pack.extraData.body.isEmpty()) + pack.extraDataLoaded = true; +} + +void FlameMod::loadBody(ModPlatform::IndexedPack& pack) +{ + pack.extraData.body = api.getModDescription(pack.addonId.toInt()); + + if (!pack.extraData.issuesUrl.isEmpty() || !pack.extraData.sourceUrl.isEmpty() || !pack.extraData.wikiUrl.isEmpty()) + pack.extraDataLoaded = true; +} + +static QString enumToString(int hash_algorithm) +{ + switch (hash_algorithm) { + default: + case 1: + return "sha1"; + case 2: + return "md5"; + } +} + +void FlameMod::loadIndexedPackVersions(ModPlatform::IndexedPack& pack, QJsonArray& arr) +{ + QList unsortedVersions; + for (auto versionIter : arr) { + auto obj = versionIter.toObject(); + + auto file = loadIndexedPackVersion(obj); + if (!file.addonId.isValid()) + file.addonId = pack.addonId; + + if (file.fileId.isValid()) // Heuristic to check if the returned value is valid + unsortedVersions.append(file); + } + + auto orderSortPredicate = [](const ModPlatform::IndexedVersion& a, const ModPlatform::IndexedVersion& b) -> bool { + // dates are in RFC 3339 format + return a.date > b.date; + }; + std::sort(unsortedVersions.begin(), unsortedVersions.end(), orderSortPredicate); + pack.versions = unsortedVersions; + pack.versionsLoaded = true; +} + +auto FlameMod::loadIndexedPackVersion(QJsonObject& obj, bool load_changelog) -> ModPlatform::IndexedVersion +{ + auto versionArray = Json::requireArray(obj, "gameVersions"); + + ModPlatform::IndexedVersion file; + for (auto mcVer : versionArray) { + auto str = mcVer.toString(); + + if (str.contains('.')) + file.mcVersion.append(str); + + file.side = ModPlatform::Side::NoSide; + if (auto loader = str.toLower(); loader == "neoforge") + file.loaders |= ModPlatform::NeoForge; + else if (loader == "forge") + file.loaders |= ModPlatform::Forge; + else if (loader == "cauldron") + file.loaders |= ModPlatform::Cauldron; + else if (loader == "liteloader") + file.loaders |= ModPlatform::LiteLoader; + else if (loader == "fabric") + file.loaders |= ModPlatform::Fabric; + else if (loader == "quilt") + file.loaders |= ModPlatform::Quilt; + else if (loader == "server" || loader == "client") { + if (file.side == ModPlatform::Side::NoSide) + file.side = ModPlatform::SideUtils::fromString(loader); + else if (file.side != ModPlatform::SideUtils::fromString(loader)) + file.side = ModPlatform::Side::UniversalSide; + } + } + + file.addonId = Json::requireInteger(obj, "modId"); + file.fileId = Json::requireInteger(obj, "id"); + file.date = Json::requireString(obj, "fileDate"); + file.version = Json::requireString(obj, "displayName"); + file.downloadUrl = obj["downloadUrl"].toString(); + file.fileName = Json::requireString(obj, "fileName"); + file.fileName = FS::RemoveInvalidPathChars(file.fileName); + + ModPlatform::IndexedVersionType ver_type; + switch (Json::requireInteger(obj, "releaseType")) { + case 1: + ver_type = ModPlatform::IndexedVersionType::Release; + break; + case 2: + ver_type = ModPlatform::IndexedVersionType::Beta; + break; + case 3: + ver_type = ModPlatform::IndexedVersionType::Alpha; + break; + default: + ver_type = ModPlatform::IndexedVersionType::Unknown; + break; + } + file.version_type = ver_type; + + auto hash_list = obj["hashes"].toArray(); + for (auto h : hash_list) { + auto hash_entry = h.toObject(); + auto hash_types = ModPlatform::ProviderCapabilities::hashType(ModPlatform::ResourceProvider::FLAME); + auto hash_algo = enumToString(hash_entry["algo"].toInt(1)); + if (hash_types.contains(hash_algo)) { + file.hash = Json::requireString(hash_entry, "value"); + file.hash_type = hash_algo; + break; + } + } + + auto dependencies = obj["dependencies"].toArray(); + for (auto d : dependencies) { + auto dep = d.toObject(); + ModPlatform::Dependency dependency; + dependency.addonId = Json::requireInteger(dep, "modId"); + switch (Json::requireInteger(dep, "relationType")) { + case 1: // EmbeddedLibrary + dependency.type = ModPlatform::DependencyType::EMBEDDED; + break; + case 2: // OptionalDependency + dependency.type = ModPlatform::DependencyType::OPTIONAL; + break; + case 3: // RequiredDependency + dependency.type = ModPlatform::DependencyType::REQUIRED; + break; + case 4: // Tool + dependency.type = ModPlatform::DependencyType::TOOL; + break; + case 5: // Incompatible + dependency.type = ModPlatform::DependencyType::INCOMPATIBLE; + break; + case 6: // Include + dependency.type = ModPlatform::DependencyType::INCLUDE; + break; + default: + dependency.type = ModPlatform::DependencyType::UNKNOWN; + break; + } + file.dependencies.append(dependency); + } + + if (load_changelog) + file.changelog = api.getModFileChangelog(file.addonId.toInt(), file.fileId.toInt()); + + return file; +} diff --git a/launcher/modplatform/flame/FlameModIndex.h b/launcher/modplatform/flame/FlameModIndex.h new file mode 100644 index 0000000..2631fab --- /dev/null +++ b/launcher/modplatform/flame/FlameModIndex.h @@ -0,0 +1,18 @@ +// +// Created by timoreo on 16/01/2022. +// + +#pragma once + +#include "modplatform/ModIndex.h" + +#include "BaseInstance.h" + +namespace FlameMod { + +void loadIndexedPack(ModPlatform::IndexedPack& m, QJsonObject& obj); +void loadURLs(ModPlatform::IndexedPack& m, QJsonObject& obj); +void loadBody(ModPlatform::IndexedPack& m); +void loadIndexedPackVersions(ModPlatform::IndexedPack& pack, QJsonArray& arr); +ModPlatform::IndexedVersion loadIndexedPackVersion(QJsonObject& obj, bool load_changelog = false); +} // namespace FlameMod diff --git a/launcher/modplatform/flame/FlamePackExportTask.cpp b/launcher/modplatform/flame/FlamePackExportTask.cpp new file mode 100644 index 0000000..8be4fe9 --- /dev/null +++ b/launcher/modplatform/flame/FlamePackExportTask.cpp @@ -0,0 +1,433 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 TheKodeToad + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "FlamePackExportTask.h" +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include "Application.h" +#include "Json.h" +#include "minecraft/PackProfile.h" +#include "minecraft/mod/ModFolderModel.h" +#include "modplatform/ModIndex.h" +#include "modplatform/flame/FlameModIndex.h" +#include "modplatform/helpers/HashUtils.h" +#include "tasks/Task.h" + +#include "archive/ExportToZipTask.h" + +const QString FlamePackExportTask::TEMPLATE = "
  • {name}{authors}
  • \n"; +const QStringList FlamePackExportTask::FILE_EXTENSIONS({ "jar", "zip" }); + +FlamePackExportTask::FlamePackExportTask(FlamePackExportOptions&& options) + : m_options(std::move(options)), m_gameRoot(m_options.instance->gameRoot()) +{} + +void FlamePackExportTask::executeTask() +{ + setStatus(tr("Searching for files...")); + setProgress(0, 5); + collectFiles(); +} + +bool FlamePackExportTask::abort() +{ + if (task) { + task->abort(); + return true; + } + return false; +} + +void FlamePackExportTask::collectFiles() +{ + setAbortable(false); + QCoreApplication::processEvents(); + + m_files.clear(); + if (!MMCZip::collectFileListRecursively(m_options.instance->gameRoot(), nullptr, &m_files, m_options.filter)) { + emitFailed(tr("Could not search for files")); + return; + } + + pendingHashes.clear(); + resolvedFiles.clear(); + + m_options.instance->loaderModList()->update(); + connect(m_options.instance->loaderModList(), &ModFolderModel::updateFinished, this, &FlamePackExportTask::collectHashes); +} + +void FlamePackExportTask::collectHashes() +{ + setAbortable(true); + setStatus(tr("Finding file hashes...")); + setProgress(1, 5); + auto allMods = m_options.instance->loaderModList()->allMods(); + ConcurrentTask::Ptr hashingTask(new ConcurrentTask("MakeHashesTask", APPLICATION->settings()->get("NumberOfConcurrentTasks").toInt())); + task.reset(hashingTask); + for (const QFileInfo& file : m_files) { + const QString relative = m_gameRoot.relativeFilePath(file.absoluteFilePath()); + // require sensible file types + if (!std::any_of(FILE_EXTENSIONS.begin(), FILE_EXTENSIONS.end(), [&relative](const QString& extension) { + return relative.endsWith('.' + extension) || relative.endsWith('.' + extension + ".disabled"); + })) + continue; + + if (relative.startsWith("resourcepacks/") && + (relative.endsWith(".zip") || relative.endsWith(".zip.disabled"))) { // is resourcepack + auto hashTask = Hashing::createHasher(file.absoluteFilePath(), ModPlatform::ResourceProvider::FLAME); + connect(hashTask.get(), &Hashing::Hasher::resultsReady, [this, relative, file](QString hash) { + if (m_state == Task::State::Running) { + pendingHashes.insert(hash, { relative, file.absoluteFilePath(), relative.endsWith(".zip") }); + } + }); + connect(hashTask.get(), &Task::failed, this, &FlamePackExportTask::emitFailed); + hashingTask->addTask(hashTask); + continue; + } + + if (auto modIter = std::find_if(allMods.begin(), allMods.end(), [&file](Mod* mod) { return mod->fileinfo() == file; }); + modIter != allMods.end()) { + const Mod* mod = *modIter; + if (!mod || mod->type() == ResourceType::FOLDER) { + continue; + } + if (mod->metadata() && mod->metadata()->provider == ModPlatform::ResourceProvider::FLAME) { + resolvedFiles.insert(mod->fileinfo().absoluteFilePath(), + { mod->metadata()->project_id.toInt(), mod->metadata()->file_id.toInt(), mod->enabled(), true, + mod->metadata()->name, mod->metadata()->slug, mod->authors().join(", ") }); + continue; + } + + auto hashTask = Hashing::createHasher(mod->fileinfo().absoluteFilePath(), ModPlatform::ResourceProvider::FLAME); + connect(hashTask.get(), &Hashing::Hasher::resultsReady, [this, mod](QString hash) { + if (m_state == Task::State::Running) { + pendingHashes.insert(hash, { mod->name(), mod->fileinfo().absoluteFilePath(), mod->enabled(), true }); + } + }); + connect(hashTask.get(), &Task::failed, this, &FlamePackExportTask::emitFailed); + hashingTask->addTask(hashTask); + } + } + auto progressStep = std::make_shared(); + connect(hashingTask.get(), &Task::finished, this, [this, progressStep] { + progressStep->state = TaskStepState::Succeeded; + stepProgress(*progressStep); + }); + + connect(hashingTask.get(), &Task::succeeded, this, &FlamePackExportTask::makeApiRequest); + connect(hashingTask.get(), &Task::failed, this, [this, progressStep](QString reason) { + progressStep->state = TaskStepState::Failed; + stepProgress(*progressStep); + emitFailed(reason); + }); + connect(hashingTask.get(), &Task::stepProgress, this, &FlamePackExportTask::propagateStepProgress); + + connect(hashingTask.get(), &Task::progress, this, [this, progressStep](qint64 current, qint64 total) { + progressStep->update(current, total); + stepProgress(*progressStep); + }); + connect(hashingTask.get(), &Task::status, this, [this, progressStep](QString status) { + progressStep->status = status; + stepProgress(*progressStep); + }); + connect(hashingTask.get(), &Task::aborted, this, &FlamePackExportTask::emitAborted); + hashingTask->start(); +} + +void FlamePackExportTask::makeApiRequest() +{ + if (pendingHashes.isEmpty()) { + buildZip(); + return; + } + + setStatus(tr("Finding versions for hashes...")); + setProgress(2, 5); + + QList fingerprints; + for (auto& murmur : pendingHashes.keys()) { + fingerprints.push_back(murmur.toUInt()); + } + + auto [matchTask, response] = api.matchFingerprints(fingerprints); + task = matchTask; + + connect(task.get(), &Task::succeeded, this, [this, response] { + QJsonParseError parseError{}; + QJsonDocument doc = QJsonDocument::fromJson(*response, &parseError); + if (parseError.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from CurseForge::CurrentVersions at" << parseError.offset + << "reason:" << parseError.errorString(); + qWarning() << *response; + + emitFailed(parseError.errorString()); + return; + } + + try { + auto docObj = Json::requireObject(doc); + auto dataObj = Json::requireObject(docObj, "data"); + auto dataArr = Json::requireArray(dataObj, "exactMatches"); + + if (dataArr.isEmpty()) { + qWarning() << "No matches found for fingerprint search!"; + + getProjectsInfo(); + return; + } + for (auto match : dataArr) { + auto matchObj = match.toObject(); + auto fileObj = matchObj["file"].toObject(); + + if (matchObj.isEmpty() || fileObj.isEmpty()) { + qWarning() << "Fingerprint match is empty!"; + + return; + } + + auto fingerprint = QString::number(fileObj["fileFingerprint"].toInteger()); + auto mod = pendingHashes.find(fingerprint); + if (mod == pendingHashes.end()) { + qWarning() << "Invalid fingerprint from the API response."; + continue; + } + + setStatus(tr("Parsing API response from CurseForge for '%1'...").arg(mod->name)); + if (fileObj["isAvailable"].toBool()) + resolvedFiles.insert(mod->path, { Json::requireInteger(fileObj, "modId"), Json::requireInteger(fileObj, "id"), + mod->enabled, mod->isMod }); + } + + } catch (Json::JsonException& e) { + qDebug() << e.cause(); + qDebug() << doc; + } + pendingHashes.clear(); + getProjectsInfo(); + }); + connect(task.get(), &Task::failed, this, &FlamePackExportTask::getProjectsInfo); + connect(task.get(), &Task::aborted, this, &FlamePackExportTask::emitAborted); + task->start(); +} + +void FlamePackExportTask::getProjectsInfo() +{ + setStatus(tr("Finding project info from CurseForge...")); + setProgress(3, 5); + QStringList addonIds; + for (const auto& resolved : resolvedFiles) { + if (resolved.slug.isEmpty()) { + addonIds << QString::number(resolved.addonId); + } + } + + Task::Ptr projTask; + QByteArray* response; + + if (addonIds.isEmpty()) { + buildZip(); + return; + } else if (addonIds.size() == 1) { + std::tie(projTask, response) = api.getProject(*addonIds.begin()); + } else { + std::tie(projTask, response) = api.getProjects(addonIds); + } + + connect(projTask.get(), &Task::succeeded, this, [this, response, addonIds] { + QJsonParseError parseError{}; + auto doc = QJsonDocument::fromJson(*response, &parseError); + if (parseError.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from CurseForge projects task at" << parseError.offset + << "reason:" << parseError.errorString(); + qWarning() << *response; + emitFailed(parseError.errorString()); + return; + } + + try { + QJsonArray entries; + if (addonIds.size() == 1) + entries = { Json::requireObject(Json::requireObject(doc), "data") }; + else + entries = Json::requireArray(Json::requireObject(doc), "data"); + + for (auto entry : entries) { + auto entryObj = Json::requireObject(entry); + + try { + setStatus(tr("Parsing API response from CurseForge for '%1'...").arg(Json::requireString(entryObj, "name"))); + + ModPlatform::IndexedPack pack; + FlameMod::loadIndexedPack(pack, entryObj); + for (auto key : resolvedFiles.keys()) { + auto val = resolvedFiles.value(key); + if (val.addonId == pack.addonId) { + val.name = pack.name; + val.slug = pack.slug; + QStringList authors; + for (auto author : pack.authors) + authors << author.name; + + val.authors = authors.join(", "); + resolvedFiles[key] = val; + } + } + + } catch (Json::JsonException& e) { + qDebug() << e.cause(); + qDebug() << entries; + } + } + } catch (Json::JsonException& e) { + qDebug() << e.cause(); + qDebug() << doc; + } + buildZip(); + }); + connect(projTask.get(), &Task::failed, this, &FlamePackExportTask::emitFailed); + connect(projTask.get(), &Task::aborted, this, &FlamePackExportTask::emitAborted); + task.reset(projTask); + task->start(); +} + +void FlamePackExportTask::buildZip() +{ + setStatus(tr("Adding files...")); + setProgress(4, 5); + + auto zipTask = makeShared(m_options.output, m_gameRoot, m_files, "overrides/", true); + zipTask->addExtraFile("manifest.json", generateIndex()); + zipTask->addExtraFile("modlist.html", generateHTML()); + + QStringList exclude; + std::transform(resolvedFiles.keyBegin(), resolvedFiles.keyEnd(), std::back_insert_iterator(exclude), + [this](QString file) { return m_gameRoot.relativeFilePath(file); }); + zipTask->setExcludeFiles(exclude); + + auto progressStep = std::make_shared(); + connect(zipTask.get(), &Task::finished, this, [this, progressStep] { + progressStep->state = TaskStepState::Succeeded; + stepProgress(*progressStep); + }); + + connect(zipTask.get(), &Task::succeeded, this, &FlamePackExportTask::emitSucceeded); + connect(zipTask.get(), &Task::aborted, this, &FlamePackExportTask::emitAborted); + connect(zipTask.get(), &Task::failed, this, [this, progressStep](QString reason) { + progressStep->state = TaskStepState::Failed; + stepProgress(*progressStep); + emitFailed(reason); + }); + connect(zipTask.get(), &Task::stepProgress, this, &FlamePackExportTask::propagateStepProgress); + + connect(zipTask.get(), &Task::progress, this, [this, progressStep](qint64 current, qint64 total) { + progressStep->update(current, total); + stepProgress(*progressStep); + }); + connect(zipTask.get(), &Task::status, this, [this, progressStep](QString status) { + progressStep->status = status; + stepProgress(*progressStep); + }); + task.reset(zipTask); + zipTask->start(); +} + +QByteArray FlamePackExportTask::generateIndex() +{ + QJsonObject obj; + obj["manifestType"] = "minecraftModpack"; + obj["manifestVersion"] = 1; + obj["name"] = m_options.name; + obj["version"] = m_options.version; + obj["author"] = m_options.author; + obj["overrides"] = "overrides"; + + QJsonObject version; + + auto profile = m_options.instance->getPackProfile(); + // collect all supported components + const ComponentPtr minecraft = profile->getComponent("net.minecraft"); + const ComponentPtr quilt = profile->getComponent("org.quiltmc.quilt-loader"); + const ComponentPtr fabric = profile->getComponent("net.fabricmc.fabric-loader"); + const ComponentPtr forge = profile->getComponent("net.minecraftforge"); + const ComponentPtr neoforge = profile->getComponent("net.neoforged"); + + // convert all available components to mrpack dependencies + if (minecraft != nullptr) + version["version"] = minecraft->m_version; + QString id; + if (quilt != nullptr) + id = "quilt-" + quilt->m_version; + else if (fabric != nullptr) + id = "fabric-" + fabric->m_version; + else if (forge != nullptr) + id = "forge-" + forge->m_version; + else if (neoforge != nullptr) { + id = "neoforge-"; + if (minecraft->m_version == "1.20.1") + id += "1.20.1-"; + id += neoforge->m_version; + } + version["modLoaders"] = QJsonArray(); + if (!id.isEmpty()) { + QJsonObject loader; + loader["id"] = id; + loader["primary"] = true; + version["modLoaders"] = QJsonArray({ loader }); + } + + if (m_options.recommendedRAM > 0) + version["recommendedRam"] = m_options.recommendedRAM; + + obj["minecraft"] = version; + + QJsonArray files; + for (auto mod : resolvedFiles) { + QJsonObject file; + file["projectID"] = mod.addonId; + file["fileID"] = mod.version; + file["required"] = mod.enabled || !m_options.optionalFiles; + files << file; + } + obj["files"] = files; + + return QJsonDocument(obj).toJson(QJsonDocument::Compact); +} + +QByteArray FlamePackExportTask::generateHTML() +{ + QString content = ""; + for (auto mod : resolvedFiles) { + if (mod.isMod) { + content += QString(TEMPLATE) + .replace("{name}", mod.name.toHtmlEscaped()) + .replace("{url}", ModPlatform::getMetaURL(ModPlatform::ResourceProvider::FLAME, mod.addonId).toHtmlEscaped()) + .replace("{authors}", !mod.authors.isEmpty() ? QString(" (by %1)").arg(mod.authors).toHtmlEscaped() : ""); + } + } + content = "
      " + content + "
    "; + return content.toUtf8(); +} diff --git a/launcher/modplatform/flame/FlamePackExportTask.h b/launcher/modplatform/flame/FlamePackExportTask.h new file mode 100644 index 0000000..f6a9024 --- /dev/null +++ b/launcher/modplatform/flame/FlamePackExportTask.h @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 TheKodeToad + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include "MMCZip.h" +#include "minecraft/MinecraftInstance.h" +#include "modplatform/flame/FlameAPI.h" +#include "tasks/Task.h" + +struct FlamePackExportOptions { + QString name; + QString version; + QString author; + bool optionalFiles; + MinecraftInstance* instance; + QString output; + MMCZip::FilterFileFunction filter; + int recommendedRAM; +}; + +class FlamePackExportTask : public Task { + Q_OBJECT + public: + FlamePackExportTask(FlamePackExportOptions&& options); + + protected: + void executeTask() override; + bool abort() override; + + private: + static const QString TEMPLATE; + static const QStringList FILE_EXTENSIONS; + + // inputs + + struct ResolvedFile { + int addonId; + int version; + bool enabled; + bool isMod; + + QString name; + QString slug; + QString authors; + }; + struct HashInfo { + QString name; + QString path; + bool enabled; + bool isMod; + }; + + FlamePackExportOptions m_options; + QDir m_gameRoot; + + FlameAPI api; + + QFileInfoList m_files; + QMap pendingHashes{}; + QMap resolvedFiles{}; + Task::Ptr task; + + void collectFiles(); + void collectHashes(); + void makeApiRequest(); + void getProjectsInfo(); + void buildZip(); + + QByteArray generateIndex(); + QByteArray generateHTML(); +}; diff --git a/launcher/modplatform/flame/PackManifest.cpp b/launcher/modplatform/flame/PackManifest.cpp new file mode 100644 index 0000000..dc176d7 --- /dev/null +++ b/launcher/modplatform/flame/PackManifest.cpp @@ -0,0 +1,71 @@ +#include "PackManifest.h" +#include "Json.h" + +static void loadFileV1(Flame::File& f, QJsonObject& file) +{ + f.projectId = Json::requireInteger(file, "projectID"); + f.fileId = Json::requireInteger(file, "fileID"); + f.required = file["required"].toBool(true); +} + +static void loadModloaderV1(Flame::Modloader& m, QJsonObject& modLoader) +{ + m.id = Json::requireString(modLoader, "id"); + m.primary = modLoader["primary"].toBool(); +} + +static void loadMinecraftV1(Flame::Minecraft& m, QJsonObject& minecraft) +{ + m.version = Json::requireString(minecraft, "version"); + // extra libraries... apparently only used for a custom Minecraft launcher in the 1.2.5 FTB retro pack + // intended use is likely hardcoded in the 'Flame' client, the manifest says nothing + m.libraries = minecraft["libraries"].toString(); + auto arr = minecraft["modLoaders"].toArray(); + for (QJsonValueRef item : arr) { + auto obj = Json::requireObject(item); + Flame::Modloader loader; + loadModloaderV1(loader, obj); + m.modLoaders.append(loader); + } + m.recommendedRAM = minecraft["recommendedRam"].toInt(); +} + +static void loadManifestV1(Flame::Manifest& pack, QJsonObject& manifest) +{ + auto mc = Json::requireObject(manifest, "minecraft"); + + loadMinecraftV1(pack.minecraft, mc); + + pack.name = manifest["name"].toString("Unnamed"); + pack.version = manifest["version"].toString(); + pack.author = manifest["author"].toString("Anonymous"); + + auto arr = manifest["files"].toArray(); + for (auto item : arr) { + auto obj = Json::requireObject(item); + + Flame::File file; + loadFileV1(file, obj); + Q_ASSERT(file.projectId != 0); + pack.files.insert(file.fileId, file); + } + + pack.overrides = manifest["overrides"].toString("overrides"); + + pack.is_loaded = true; +} + +void Flame::loadManifest(Flame::Manifest& m, const QString& filepath) +{ + auto doc = Json::requireDocument(filepath); + auto obj = Json::requireObject(doc); + m.manifestType = Json::requireString(obj, "manifestType"); + if (m.manifestType != "minecraftModpack") { + throw JSONValidationError("Not a modpack manifest!"); + } + m.manifestVersion = Json::requireInteger(obj, "manifestVersion"); + if (m.manifestVersion != 1) { + throw JSONValidationError(QString("Unknown manifest version (%1)").arg(m.manifestVersion)); + } + loadManifestV1(m, obj); +} diff --git a/launcher/modplatform/flame/PackManifest.h b/launcher/modplatform/flame/PackManifest.h new file mode 100644 index 0000000..049a998 --- /dev/null +++ b/launcher/modplatform/flame/PackManifest.h @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include "modplatform/ModIndex.h" +#include "modplatform/ResourceType.h" + +namespace Flame { +struct File { + int projectId = 0; + int fileId = 0; + // NOTE: the opposite to 'optional' + bool required = true; + + ModPlatform::IndexedPack pack; + ModPlatform::IndexedVersion version; + + // our + QString targetFolder = QStringLiteral("mods"); + ModPlatform::ResourceType resourceType; +}; + +struct Modloader { + QString id; + bool primary = false; +}; + +struct Minecraft { + QString version; + QString libraries; + QList modLoaders; + int recommendedRAM; +}; + +struct Manifest { + QString manifestType; + int manifestVersion = 0; + Flame::Minecraft minecraft; + QString name; + QString version; + QString author; + // File id -> File + QMap files; + QString overrides; + + bool is_loaded = false; +}; + +void loadManifest(Flame::Manifest& m, const QString& filepath); +} // namespace Flame diff --git a/launcher/modplatform/ftb/FTBPackInstallTask.cpp b/launcher/modplatform/ftb/FTBPackInstallTask.cpp new file mode 100644 index 0000000..6081807 --- /dev/null +++ b/launcher/modplatform/ftb/FTBPackInstallTask.cpp @@ -0,0 +1,390 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 flowln + * Copyright (c) 2022 Jamie Mansfield + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2020-2021 Jamie Mansfield + * Copyright 2020-2021 Petr Mrazek + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "FTBPackInstallTask.h" + +#include "FileSystem.h" +#include "Json.h" +#include "minecraft/MinecraftInstance.h" +#include "minecraft/PackProfile.h" +#include "modplatform/flame/FileResolvingTask.h" +#include "modplatform/flame/PackManifest.h" +#include "net/ChecksumValidator.h" +#include "settings/INISettingsObject.h" + +#include "Application.h" +#include "BuildConfig.h" +#include "ui/dialogs/BlockedModsDialog.h" + +namespace FTB { + +PackInstallTask::PackInstallTask(Modpack pack, QString version, QWidget* parent) + : m_pack(std::move(pack)), m_versionName(std::move(version)), m_parent(parent) +{} + +bool PackInstallTask::abort() +{ + if (!canAbort()) + return false; + + bool aborted = true; + + if (m_net_job) + aborted &= m_net_job->abort(); + if (m_modIdResolverTask) + aborted &= m_modIdResolverTask->abort(); + + return aborted ? InstanceTask::abort() : false; +} + +void PackInstallTask::executeTask() +{ + setStatus(tr("Getting the manifest...")); + setAbortable(false); + + // Find pack version + auto version_it = std::find_if(m_pack.versions.constBegin(), m_pack.versions.constEnd(), + [this](const FTB::VersionInfo& a) { return a.name == m_versionName; }); + + if (version_it == m_pack.versions.constEnd()) { + emitFailed(tr("Failed to find pack version %1").arg(m_versionName)); + return; + } + + auto version = *version_it; + + auto netJob = makeShared("FTB::VersionFetch", APPLICATION->network()); + + auto searchUrl = QString(BuildConfig.FTB_API_BASE_URL + "/modpack/%1/%2").arg(m_pack.id).arg(version.id); + + auto [action, response] = Net::Download::makeByteArray(QUrl(searchUrl)); + netJob->addNetAction(action); + + QObject::connect(netJob.get(), &NetJob::succeeded, this, [this, response] { onManifestDownloadSucceeded(response); }); + QObject::connect(netJob.get(), &NetJob::failed, this, &PackInstallTask::onManifestDownloadFailed); + QObject::connect(netJob.get(), &NetJob::aborted, this, &PackInstallTask::abort); + QObject::connect(netJob.get(), &NetJob::progress, this, &PackInstallTask::setProgress); + + m_net_job = netJob; + + setAbortable(true); + netJob->start(); +} + +void PackInstallTask::onManifestDownloadSucceeded(QByteArray* responsePtr) +{ + // NOTE(TheKodeToad): moving the response out to avoid it from being destroyed by m_net_job.reset() + QByteArray response = std::move(*responsePtr); + m_net_job.reset(); + + QJsonParseError parse_error{}; + QJsonDocument doc = QJsonDocument::fromJson(response, &parse_error); + if (parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from FTB at " << parse_error.offset << " reason: " << parse_error.errorString(); + qWarning() << response; + return; + } + + FTB::Version version; + try { + auto obj = Json::requireObject(doc); + FTB::loadVersion(version, obj); + } catch (const JSONValidationError& e) { + emitFailed(tr("Could not understand pack manifest:\n") + e.cause()); + return; + } + + m_version = version; + + resolveMods(); +} + +void PackInstallTask::resolveMods() +{ + setStatus(tr("Resolving mods...")); + setAbortable(false); + setProgress(0, 100); + + m_fileIds.clear(); + + Flame::Manifest manifest; + for (const auto& file : m_version.files) { + if (!file.serverOnly && file.url.isEmpty()) { + if (file.curseforge.file_id <= 0) { + emitFailed(tr("Invalid manifest: There's no information available to download the file '%1'!").arg(file.name)); + return; + } + + Flame::File flameFile; + flameFile.projectId = file.curseforge.project_id; + flameFile.fileId = file.curseforge.file_id; + + manifest.files.insert(flameFile.fileId, flameFile); + m_fileIds.append(flameFile.fileId); + } else { + m_fileIds.append(-1); + } + } + + m_modIdResolverTask.reset(new Flame::FileResolvingTask(manifest)); + + connect(m_modIdResolverTask.get(), &Flame::FileResolvingTask::succeeded, this, &PackInstallTask::onResolveModsSucceeded); + connect(m_modIdResolverTask.get(), &Flame::FileResolvingTask::failed, this, &PackInstallTask::onResolveModsFailed); + connect(m_modIdResolverTask.get(), &Flame::FileResolvingTask::aborted, this, &PackInstallTask::abort); + connect(m_modIdResolverTask.get(), &Flame::FileResolvingTask::progress, this, &PackInstallTask::setProgress); + + setAbortable(true); + + m_modIdResolverTask->start(); +} + +void PackInstallTask::onResolveModsSucceeded() +{ + auto anyBlocked = false; + + Flame::Manifest results = m_modIdResolverTask->getResults(); + for (int index = 0; index < m_fileIds.size(); index++) { + const auto file_id = m_fileIds.at(index); + if (file_id < 0) + continue; + + Flame::File resultsFile = results.files[file_id]; + VersionFile& localFile = m_version.files[index]; + + // First check for blocked mods + if (resultsFile.version.downloadUrl.isEmpty()) { + BlockedMod blocked_mod; + blocked_mod.name = resultsFile.version.fileName; + blocked_mod.websiteUrl = QString("%1/download/%2").arg(resultsFile.pack.websiteUrl, QString::number(resultsFile.fileId)); + blocked_mod.hash = resultsFile.version.hash; + blocked_mod.matched = false; + blocked_mod.localPath = ""; + blocked_mod.targetFolder = resultsFile.targetFolder; + + m_blockedMods.append(blocked_mod); + + anyBlocked = true; + } else { + localFile.url = resultsFile.version.downloadUrl; + } + } + + m_modIdResolverTask.reset(); + + if (anyBlocked) { + qDebug() << "Blocked files found, displaying file list"; + + BlockedModsDialog message_dialog(m_parent, tr("Blocked files found"), + tr("The following files are not available for download in third party launchers.
    " + "You will need to manually download them and add them to the instance."), + m_blockedMods); + + message_dialog.setModal(true); + + if (message_dialog.exec() == QDialog::Accepted) { + qDebug() << "Post dialog blocked mods list: " << m_blockedMods; + createInstance(); + } else { + abort(); + } + + } else { + createInstance(); + } +} + +void PackInstallTask::createInstance() +{ + setAbortable(false); + + setStatus(tr("Creating the instance...")); + QCoreApplication::processEvents(); + + auto instanceConfigPath = FS::PathCombine(m_stagingPath, "instance.cfg"); + auto instanceSettings = std::make_unique(instanceConfigPath); + + MinecraftInstance instance(m_globalSettings, std::move(instanceSettings), m_stagingPath); + auto components = instance.getPackProfile(); + components->buildingFromScratch(); + + for (auto target : m_version.targets) { + if (target.type == "game" && target.name == "minecraft") { + components->setComponentVersion("net.minecraft", target.version, true); + break; + } + } + + for (auto target : m_version.targets) { + if (target.type != "modloader") + continue; + + if (target.name == "forge") { + components->setComponentVersion("net.minecraftforge", target.version); + } else if (target.name == "fabric") { + components->setComponentVersion("net.fabricmc.fabric-loader", target.version); + } else if (target.name == "neoforge") { + components->setComponentVersion("net.neoforged", target.version); + } else if (target.name == "quilt") { + components->setComponentVersion("org.quiltmc.quilt-loader", target.version); + } + } + + // install any jar mods + QDir jarModsDir(FS::PathCombine(m_stagingPath, "minecraft", "jarmods")); + if (jarModsDir.exists()) { + QStringList jarMods; + + for (const auto& info : jarModsDir.entryInfoList(QDir::NoDotAndDotDot | QDir::Files)) { + jarMods.push_back(info.absoluteFilePath()); + } + + components->installJarMods(jarMods); + } + + components->saveNow(); + + instance.setName(name()); + instance.setIconKey(m_instIcon); + instance.setManagedPack("ftb", QString::number(m_pack.id), m_pack.name, QString::number(m_version.id), m_version.name); + + instance.saveNow(); + + onCreateInstanceSucceeded(); +} + +void PackInstallTask::onCreateInstanceSucceeded() +{ + downloadPack(); +} + +void PackInstallTask::downloadPack() +{ + setStatus(tr("Downloading mods...")); + setAbortable(false); + + auto jobPtr = makeShared(tr("Mod download"), APPLICATION->network()); + for (const auto& file : m_version.files) { + if (file.serverOnly || file.url.isEmpty()) + continue; + + auto path = FS::PathCombine(m_stagingPath, ".minecraft", file.path, file.name); + qDebug() << "Will try to download" << file.url << "to" << path; + + QFileInfo file_info(file.name); + + auto dl = Net::Download::makeFile(file.url, path); + if (!file.sha1.isEmpty()) { + dl->addValidator(new Net::ChecksumValidator(QCryptographicHash::Sha1, file.sha1)); + } + + jobPtr->addNetAction(dl); + } + + jobPtr->setMaxConcurrent(1); // FTB blocks multiple requests at a time + connect(jobPtr.get(), &NetJob::succeeded, this, &PackInstallTask::onModDownloadSucceeded); + connect(jobPtr.get(), &NetJob::failed, this, &PackInstallTask::onModDownloadFailed); + connect(jobPtr.get(), &NetJob::aborted, this, &PackInstallTask::abort); + connect(jobPtr.get(), &NetJob::progress, this, &PackInstallTask::setProgress); + + m_net_job = jobPtr; + + setAbortable(true); + jobPtr->start(); +} + +void PackInstallTask::onModDownloadSucceeded() +{ + m_net_job.reset(); + if (!m_blockedMods.isEmpty()) { + copyBlockedMods(); + } + emitSucceeded(); +} + +void PackInstallTask::onManifestDownloadFailed(QString reason) +{ + m_net_job.reset(); + emitFailed(reason); +} +void PackInstallTask::onResolveModsFailed(QString reason) +{ + m_net_job.reset(); + emitFailed(reason); +} +void PackInstallTask::onCreateInstanceFailed(QString reason) +{ + emitFailed(reason); +} +void PackInstallTask::onModDownloadFailed(QString reason) +{ + m_net_job.reset(); + emitFailed(reason); +} + +/// @brief copy the matched blocked mods to the instance staging area +void PackInstallTask::copyBlockedMods() +{ + setStatus(tr("Copying Blocked Mods...")); + setAbortable(false); + int i = 0; + int total = m_blockedMods.length(); + setProgress(i, total); + for (const auto& mod : m_blockedMods) { + if (!mod.matched) { + qDebug() << mod.name << "was not matched to a local file, skipping copy"; + continue; + } + + auto dest_path = FS::PathCombine(m_stagingPath, ".minecraft", mod.targetFolder, mod.name); + + setStatus(tr("Copying Blocked Mods (%1 out of %2 are done)").arg(QString::number(i), QString::number(total))); + + qDebug() << "Will try to copy" << mod.localPath << "to" << dest_path; + + if (!FS::copy(mod.localPath, dest_path)()) { + qDebug() << "Copy of" << mod.localPath << "to" << dest_path << "Failed"; + } + + i++; + setProgress(i, total); + } + + setAbortable(true); +} + +} // namespace FTB diff --git a/launcher/modplatform/ftb/FTBPackInstallTask.h b/launcher/modplatform/ftb/FTBPackInstallTask.h new file mode 100644 index 0000000..49d2bb9 --- /dev/null +++ b/launcher/modplatform/ftb/FTBPackInstallTask.h @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 flowln + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2020-2021 Jamie Mansfield + * Copyright 2020-2021 Petr Mrazek + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "FTBPackManifest.h" + +#include "InstanceTask.h" +#include "QObjectPtr.h" +#include "modplatform/flame/FileResolvingTask.h" +#include "net/NetJob.h" +#include "ui/dialogs/BlockedModsDialog.h" + +#include + +namespace FTB { + +class PackInstallTask final : public InstanceTask { + Q_OBJECT + + public: + explicit PackInstallTask(Modpack pack, QString version, QWidget* parent = nullptr); + ~PackInstallTask() override = default; + + bool abort() override; + + protected: + void executeTask() override; + + private slots: + void onManifestDownloadSucceeded(QByteArray* responsePtr); + void onResolveModsSucceeded(); + void onCreateInstanceSucceeded(); + void onModDownloadSucceeded(); + + void onManifestDownloadFailed(QString reason); + void onResolveModsFailed(QString reason); + void onCreateInstanceFailed(QString reason); + void onModDownloadFailed(QString reason); + + private: + void resolveMods(); + void createInstance(); + void downloadPack(); + void copyBlockedMods(); + + private: + NetJob::Ptr m_net_job = nullptr; + shared_qobject_ptr m_modIdResolverTask = nullptr; + + QList m_fileIds; + + Modpack m_pack; + QString m_versionName; + Version m_version; + + QMap m_filesToCopy; + QList m_blockedMods; + + // FIXME: nuke + QWidget* m_parent; +}; + +} // namespace FTB diff --git a/launcher/modplatform/ftb/FTBPackManifest.cpp b/launcher/modplatform/ftb/FTBPackManifest.cpp new file mode 100644 index 0000000..da633a1 --- /dev/null +++ b/launcher/modplatform/ftb/FTBPackManifest.cpp @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * PolyMC - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2020 Jamie Mansfield + * Copyright 2020-2021 Petr Mrazek + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "FTBPackManifest.h" + +#include "Json.h" + +static void loadSpecs(FTB::Specs& s, QJsonObject& obj) +{ + s.id = Json::requireInteger(obj, "id"); + s.minimum = Json::requireInteger(obj, "minimum"); + s.recommended = Json::requireInteger(obj, "recommended"); +} + +static void loadTag(FTB::Tag& t, QJsonObject& obj) +{ + t.id = Json::requireInteger(obj, "id"); + t.name = Json::requireString(obj, "name"); +} + +static void loadArt(FTB::Art& a, QJsonObject& obj) +{ + a.id = Json::requireInteger(obj, "id"); + a.url = Json::requireString(obj, "url"); + a.type = Json::requireString(obj, "type"); + a.width = Json::requireInteger(obj, "width"); + a.height = Json::requireInteger(obj, "height"); + a.compressed = Json::requireBoolean(obj, "compressed"); + a.sha1 = Json::requireString(obj, "sha1"); + a.size = obj["size"].toInt(); + a.updated = Json::requireInteger(obj, "updated"); +} + +static void loadAuthor(FTB::Author& a, QJsonObject& obj) +{ + a.id = Json::requireInteger(obj, "id"); + a.name = Json::requireString(obj, "name"); + a.type = Json::requireString(obj, "type"); + a.website = Json::requireString(obj, "website"); + a.updated = Json::requireInteger(obj, "updated"); +} + +static void loadVersionInfo(FTB::VersionInfo& v, QJsonObject& obj) +{ + v.id = Json::requireInteger(obj, "id"); + v.name = Json::requireString(obj, "name"); + v.type = Json::requireString(obj, "type"); + v.updated = Json::requireInteger(obj, "updated"); + auto specs = Json::requireObject(obj, "specs"); + loadSpecs(v.specs, specs); +} + +void FTB::loadModpack(FTB::Modpack& m, QJsonObject& obj) +{ + m.id = Json::requireInteger(obj, "id"); + m.name = Json::requireString(obj, "name"); + m.safeName = Json::requireString(obj, "name").replace(QRegularExpression("[^A-Za-z0-9]"), "").toLower() + ".png"; + m.synopsis = Json::requireString(obj, "synopsis"); + m.description = Json::requireString(obj, "description"); + m.type = Json::requireString(obj, "type"); + m.featured = Json::requireBoolean(obj, "featured"); + m.installs = Json::requireInteger(obj, "installs"); + m.plays = Json::requireInteger(obj, "plays"); + m.updated = Json::requireInteger(obj, "updated"); + m.refreshed = obj["refreshed"].toInt(); + auto artArr = Json::requireArray(obj, "art"); + for (QJsonValueRef artRaw : artArr) { + auto artObj = Json::requireObject(artRaw); + FTB::Art art; + loadArt(art, artObj); + m.art.append(art); + } + auto authorArr = Json::requireArray(obj, "authors"); + for (QJsonValueRef authorRaw : authorArr) { + auto authorObj = Json::requireObject(authorRaw); + FTB::Author author; + loadAuthor(author, authorObj); + m.authors.append(author); + } + auto versionArr = Json::requireArray(obj, "versions"); + for (QJsonValueRef versionRaw : versionArr) { + auto versionObj = Json::requireObject(versionRaw); + FTB::VersionInfo version; + loadVersionInfo(version, versionObj); + m.versions.append(version); + } + auto tagArr = Json::requireArray(obj, "tags"); + for (QJsonValueRef tagRaw : tagArr) { + auto tagObj = Json::requireObject(tagRaw); + FTB::Tag tag; + loadTag(tag, tagObj); + m.tags.append(tag); + } + m.updated = Json::requireInteger(obj, "updated"); +} + +static void loadVersionTarget(FTB::VersionTarget& a, QJsonObject& obj) +{ + a.id = Json::requireInteger(obj, "id"); + a.name = Json::requireString(obj, "name"); + a.type = Json::requireString(obj, "type"); + a.version = Json::requireString(obj, "version"); + a.updated = Json::requireInteger(obj, "updated"); +} + +static void loadVersionFile(FTB::VersionFile& a, QJsonObject& obj) +{ + a.id = Json::requireInteger(obj, "id"); + a.type = Json::requireString(obj, "type"); + a.path = Json::requireString(obj, "path"); + a.name = Json::requireString(obj, "name"); + a.version = Json::requireString(obj, "version"); + a.url = obj["url"].toString(); // optional + a.sha1 = Json::requireString(obj, "sha1"); + a.size = obj["size"].toInt(); + a.clientOnly = Json::requireBoolean(obj, "clientonly"); + a.serverOnly = Json::requireBoolean(obj, "serveronly"); + a.optional = Json::requireBoolean(obj, "optional"); + a.updated = Json::requireInteger(obj, "updated"); + auto curseforgeObj = obj["curseforge"].toObject(); // optional + a.curseforge.project_id = curseforgeObj["project"].toInt(); + a.curseforge.file_id = curseforgeObj["file"].toInt(); +} + +void FTB::loadVersion(FTB::Version& m, QJsonObject& obj) +{ + m.id = Json::requireInteger(obj, "id"); + m.parent = Json::requireInteger(obj, "parent"); + m.name = Json::requireString(obj, "name"); + m.type = Json::requireString(obj, "type"); + m.installs = Json::requireInteger(obj, "installs"); + m.plays = Json::requireInteger(obj, "plays"); + m.updated = Json::requireInteger(obj, "updated"); + m.refreshed = obj["refreshed"].toInt(); + auto specs = Json::requireObject(obj, "specs"); + loadSpecs(m.specs, specs); + auto targetArr = Json::requireArray(obj, "targets"); + for (QJsonValueRef targetRaw : targetArr) { + auto versionObj = Json::requireObject(targetRaw); + FTB::VersionTarget target; + loadVersionTarget(target, versionObj); + m.targets.append(target); + } + auto fileArr = Json::requireArray(obj, "files"); + for (QJsonValueRef fileRaw : fileArr) { + auto fileObj = Json::requireObject(fileRaw); + FTB::VersionFile file; + loadVersionFile(file, fileObj); + m.files.append(file); + } +} diff --git a/launcher/modplatform/ftb/FTBPackManifest.h b/launcher/modplatform/ftb/FTBPackManifest.h new file mode 100644 index 0000000..704bde3 --- /dev/null +++ b/launcher/modplatform/ftb/FTBPackManifest.h @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * PolyMC - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2020-2021 Jamie Mansfield + * Copyright 2020 Petr Mrazek + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include +#include + +namespace FTB { + +struct Specs { + int id; + int minimum; + int recommended; +}; + +struct Tag { + int id; + QString name; +}; + +struct Art { + int id; + QString url; + QString type; + int width; + int height; + bool compressed; + QString sha1; + int size; + int64_t updated; +}; + +struct Author { + int id; + QString name; + QString type; + QString website; + int64_t updated; +}; + +struct VersionInfo { + int id; + QString name; + QString type; + int64_t updated; + Specs specs; +}; + +struct Modpack { + int id; + QString name; + QString synopsis; + QString description; + QString type; + bool featured; + int installs; + int plays; + int64_t updated; + int64_t refreshed; + QVector art; + QVector authors; + QVector versions; + QVector tags; + QString safeName; +}; + +struct VersionTarget { + int id; + QString type; + QString name; + QString version; + int64_t updated; +}; + +struct VersionFileCurseForge { + int project_id; + int file_id; +}; + +struct VersionFile { + int id; + QString type; + QString path; + QString name; + QString version; + QString url; + QString sha1; + int size; + bool clientOnly; + bool serverOnly; + bool optional; + int64_t updated; + VersionFileCurseForge curseforge; +}; + +struct Version { + int id; + int parent; + QString name; + QString type; + int installs; + int plays; + int64_t updated; + int64_t refreshed; + Specs specs; + QVector targets; + QVector files; +}; + +struct VersionChangelog { + QString content; + int64_t updated; +}; + +void loadModpack(Modpack& m, QJsonObject& obj); + +void loadVersion(Version& m, QJsonObject& obj); +} // namespace FTB + +Q_DECLARE_METATYPE(FTB::Modpack) diff --git a/launcher/modplatform/helpers/ExportToModList.cpp b/launcher/modplatform/helpers/ExportToModList.cpp new file mode 100644 index 0000000..2432853 --- /dev/null +++ b/launcher/modplatform/helpers/ExportToModList.cpp @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +#include "ExportToModList.h" +#include +#include +#include + +namespace ExportToModList { +QString toHTML(QList mods, OptionalData extraData) +{ + QStringList lines; + for (auto mod : mods) { + auto meta = mod->metadata(); + auto modName = mod->name().toHtmlEscaped(); + if (extraData & Url) { + auto url = mod->homepage().toHtmlEscaped(); + if (!url.isEmpty()) + modName = QString("%2").arg(url, modName); + } + auto line = modName; + if (extraData & Version) { + auto ver = mod->version(); + if (ver.isEmpty() && meta != nullptr) + ver = meta->version().toString(); + if (!ver.isEmpty()) + line += QString(" [%1]").arg(ver.toHtmlEscaped()); + } + if (extraData & Authors && !mod->authors().isEmpty()) + line += " by " + mod->authors().join(", ").toHtmlEscaped(); + if (extraData & FileName) + line += QString(" (%1)").arg(mod->fileinfo().fileName().toHtmlEscaped()); + + lines.append(QString("
  • %1
  • ").arg(line)); + } + return QString("
      \n\t%1\n
    ").arg(lines.join("\n\t")); +} + +QString toMarkdownEscaped(QString src) +{ + for (auto ch : "\\`*_{}[]<>()#+-.!|") + src.replace(ch, QString("\\%1").arg(ch)); + return src; +} + +QString toMarkdown(QList mods, OptionalData extraData) +{ + QStringList lines; + + for (auto mod : mods) { + auto meta = mod->metadata(); + auto modName = toMarkdownEscaped(mod->name()); + if (extraData & Url) { + auto url = mod->homepage(); + if (!url.isEmpty()) + modName = QString("[%1](%2)").arg(modName, url); + } + auto line = modName; + if (extraData & Version) { + auto ver = toMarkdownEscaped(mod->version()); + if (ver.isEmpty() && meta != nullptr) + ver = toMarkdownEscaped(meta->version().toString()); + if (!ver.isEmpty()) + line += QString(" [%1]").arg(ver); + } + if (extraData & Authors && !mod->authors().isEmpty()) + line += " by " + toMarkdownEscaped(mod->authors().join(", ")); + if (extraData & FileName) + line += QString(" (%1)").arg(toMarkdownEscaped(mod->fileinfo().fileName())); + lines << "- " + line; + } + return lines.join("\n"); +} + +QString toPlainTXT(QList mods, OptionalData extraData) +{ + QStringList lines; + for (auto mod : mods) { + auto meta = mod->metadata(); + auto modName = mod->name(); + + auto line = modName; + if (extraData & Url) { + auto url = mod->homepage(); + if (!url.isEmpty()) + line += QString(" (%1)").arg(url); + } + if (extraData & Version) { + auto ver = mod->version(); + if (ver.isEmpty() && meta != nullptr) + ver = meta->version().toString(); + if (!ver.isEmpty()) + line += QString(" [%1]").arg(ver); + } + if (extraData & Authors && !mod->authors().isEmpty()) + line += " by " + mod->authors().join(", "); + if (extraData & FileName) + line += QString(" (%1)").arg(mod->fileinfo().fileName()); + lines << line; + } + return lines.join("\n"); +} + +QString toJSON(QList mods, OptionalData extraData) +{ + QJsonArray lines; + for (auto mod : mods) { + auto meta = mod->metadata(); + auto modName = mod->name(); + QJsonObject line; + line["name"] = modName; + if (extraData & Url) { + auto url = mod->homepage(); + if (!url.isEmpty()) + line["url"] = url; + } + if (extraData & Version) { + auto ver = mod->version(); + if (ver.isEmpty() && meta != nullptr) + ver = meta->version().toString(); + if (!ver.isEmpty()) + line["version"] = ver; + } + if (extraData & Authors && !mod->authors().isEmpty()) + line["authors"] = QJsonArray::fromStringList(mod->authors()); + if (extraData & FileName) + line["filename"] = mod->fileinfo().fileName(); + lines << line; + } + QJsonDocument doc; + doc.setArray(lines); + return doc.toJson(); +} + +QString toCSV(QList mods, OptionalData extraData) +{ + QStringList lines; + for (auto mod : mods) { + QStringList data; + auto meta = mod->metadata(); + auto modName = mod->name(); + + data << modName; + if (extraData & Url) + data << mod->homepage(); + if (extraData & Version) { + auto ver = mod->version(); + if (ver.isEmpty() && meta != nullptr) + ver = meta->version().toString(); + data << ver; + } + if (extraData & Authors) { + QString authors; + if (mod->authors().length() == 1) + authors = mod->authors().back(); + else if (mod->authors().length() > 1) + authors = QString("\"%1\"").arg(mod->authors().join(",")); + data << authors; + } + if (extraData & FileName) + data << mod->fileinfo().fileName(); + lines << data.join(","); + } + return lines.join("\n"); +} + +QString exportToModList(QList mods, Formats format, OptionalData extraData) +{ + switch (format) { + case HTML: + return toHTML(mods, extraData); + case MARKDOWN: + return toMarkdown(mods, extraData); + case PLAINTXT: + return toPlainTXT(mods, extraData); + case JSON: + return toJSON(mods, extraData); + case CSV: + return toCSV(mods, extraData); + default: { + return QString("unknown format:%1").arg(format); + } + } +} + +QString exportToModList(QList mods, QString lineTemplate) +{ + QStringList lines; + for (auto mod : mods) { + auto meta = mod->metadata(); + auto modName = mod->name(); + auto modID = mod->mod_id(); + auto url = mod->homepage(); + auto ver = mod->version(); + if (ver.isEmpty() && meta != nullptr) + ver = meta->version().toString(); + auto authors = mod->authors().join(", "); + auto filename = mod->fileinfo().fileName(); + lines << QString(lineTemplate) + .replace("{name}", modName) + .replace("{mod_id}", modID) + .replace("{url}", url) + .replace("{version}", ver) + .replace("{authors}", authors) + .replace("{filename}", filename); + } + return lines.join("\n"); +} +} // namespace ExportToModList diff --git a/launcher/modplatform/helpers/ExportToModList.h b/launcher/modplatform/helpers/ExportToModList.h new file mode 100644 index 0000000..ab7797f --- /dev/null +++ b/launcher/modplatform/helpers/ExportToModList.h @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +#pragma once +#include +#include +#include "minecraft/mod/Mod.h" + +namespace ExportToModList { + +enum Formats { HTML, MARKDOWN, PLAINTXT, JSON, CSV, CUSTOM }; +enum OptionalData { Authors = 1 << 0, Url = 1 << 1, Version = 1 << 2, FileName = 1 << 3 }; +QString exportToModList(QList mods, Formats format, OptionalData extraData); +QString exportToModList(QList mods, QString lineTemplate); +} // namespace ExportToModList diff --git a/launcher/modplatform/helpers/HashUtils.cpp b/launcher/modplatform/helpers/HashUtils.cpp new file mode 100644 index 0000000..1804025 --- /dev/null +++ b/launcher/modplatform/helpers/HashUtils.cpp @@ -0,0 +1,165 @@ +#include "HashUtils.h" + +#include +#include +#include +#include + +#include + +namespace Hashing { + +Hasher::Ptr createHasher(QString file_path, ModPlatform::ResourceProvider provider) +{ + switch (provider) { + case ModPlatform::ResourceProvider::MODRINTH: + return makeShared(file_path, + ModPlatform::ProviderCapabilities::hashType(ModPlatform::ResourceProvider::MODRINTH).first()); + case ModPlatform::ResourceProvider::FLAME: + return makeShared(file_path, Algorithm::Murmur2); + default: + qCritical() << "[Hashing]" << "Unrecognized mod platform!"; + return nullptr; + } +} + +Hasher::Ptr createHasher(QString file_path, QString type) +{ + return makeShared(file_path, type); +} + +class QIODeviceReader : public Murmur2::Reader { + public: + QIODeviceReader(QIODevice* device) : m_device(device) {} + virtual ~QIODeviceReader() = default; + virtual int read(char* s, int n) { return m_device->read(s, n); } + virtual bool eof() { return m_device->atEnd(); } + virtual void goToBeginning() { m_device->seek(0); } + virtual void close() { m_device->close(); } + + private: + QIODevice* m_device; +}; + +QString algorithmToString(Algorithm type) +{ + switch (type) { + case Algorithm::Md4: + return "md4"; + case Algorithm::Md5: + return "md5"; + case Algorithm::Sha1: + return "sha1"; + case Algorithm::Sha256: + return "sha256"; + case Algorithm::Sha512: + return "sha512"; + case Algorithm::Murmur2: + return "murmur2"; + // case Algorithm::Unknown: + default: + break; + } + return "unknown"; +} + +Algorithm algorithmFromString(QString type) +{ + if (type == "md4") + return Algorithm::Md4; + if (type == "md5") + return Algorithm::Md5; + if (type == "sha1") + return Algorithm::Sha1; + if (type == "sha256") + return Algorithm::Sha256; + if (type == "sha512") + return Algorithm::Sha512; + if (type == "murmur2") + return Algorithm::Murmur2; + return Algorithm::Unknown; +} + +QString hash(QIODevice* device, Algorithm type) +{ + if (!device->isOpen() && !device->open(QFile::ReadOnly)) + return ""; + QCryptographicHash::Algorithm alg = QCryptographicHash::Sha1; + switch (type) { + case Algorithm::Md4: + alg = QCryptographicHash::Algorithm::Md4; + break; + case Algorithm::Md5: + alg = QCryptographicHash::Algorithm::Md5; + break; + case Algorithm::Sha1: + alg = QCryptographicHash::Algorithm::Sha1; + break; + case Algorithm::Sha256: + alg = QCryptographicHash::Algorithm::Sha256; + break; + case Algorithm::Sha512: + alg = QCryptographicHash::Algorithm::Sha512; + break; + case Algorithm::Murmur2: { // CF-specific + auto should_filter_out = [](char c) { return (c == 9 || c == 10 || c == 13 || c == 32); }; + auto reader = std::make_unique(device); + auto result = QString::number(Murmur2::hash(reader.get(), 4 * MiB, should_filter_out)); + device->close(); + return result; + } + case Algorithm::Unknown: + device->close(); + return ""; + } + + QCryptographicHash hash(alg); + if (!hash.addData(device)) + qCritical() << "Failed to read JAR to create hash!"; + + Q_ASSERT(hash.result().length() == hash.hashLength(alg)); + auto result = hash.result().toHex(); + device->close(); + return result; +} + +QString hash(QString fileName, Algorithm type) +{ + QFile file(fileName); + return hash(&file, type); +} + +QString hash(QByteArray data, Algorithm type) +{ + QBuffer buff(&data); + return hash(&buff, type); +} + +void Hasher::executeTask() +{ + m_future = QtConcurrent::run( + QThreadPool::globalInstance(), [](QString fileName, Algorithm type) { return hash(fileName, type); }, m_path, m_alg); + connect(&m_watcher, &QFutureWatcher::finished, this, [this] { + if (m_future.isCanceled()) { + emitAborted(); + } else if (m_result = m_future.result(); m_result.isEmpty()) { + emitFailed("Empty hash!"); + } else { + emit resultsReady(m_result); + emitSucceeded(); + } + }); + m_watcher.setFuture(m_future); +} + +bool Hasher::abort() +{ + if (m_future.isRunning()) { + m_future.cancel(); + // NOTE: Here we don't do `emitAborted()` because it will be done when `m_build_zip_future` actually cancels, which may not + // occur immediately. + return true; + } + return false; +} +} // namespace Hashing diff --git a/launcher/modplatform/helpers/HashUtils.h b/launcher/modplatform/helpers/HashUtils.h new file mode 100644 index 0000000..5d8b7d1 --- /dev/null +++ b/launcher/modplatform/helpers/HashUtils.h @@ -0,0 +1,51 @@ +#pragma once + +#include +#include +#include +#include + +#include "modplatform/ModIndex.h" +#include "tasks/Task.h" + +namespace Hashing { + +enum class Algorithm { Md4, Md5, Sha1, Sha256, Sha512, Murmur2, Unknown }; + +QString algorithmToString(Algorithm type); +Algorithm algorithmFromString(QString type); +QString hash(QIODevice* device, Algorithm type); +QString hash(QString fileName, Algorithm type); +QString hash(QByteArray data, Algorithm type); + +class Hasher : public Task { + Q_OBJECT + public: + using Ptr = shared_qobject_ptr; + + Hasher(QString file_path, Algorithm alg) : m_path(file_path), m_alg(alg) {} + Hasher(QString file_path, QString alg) : Hasher(file_path, algorithmFromString(alg)) {} + + bool abort() override; + + void executeTask() override; + + QString getResult() const { return m_result; }; + QString getPath() const { return m_path; }; + + signals: + void resultsReady(QString hash); + + private: + QString m_result; + QString m_path; + Algorithm m_alg; + + QFuture m_future; + QFutureWatcher m_watcher; +}; + +Hasher::Ptr createHasher(QString file_path, ModPlatform::ResourceProvider provider); +Hasher::Ptr createHasher(QString file_path, QString type); + +} // namespace Hashing diff --git a/launcher/modplatform/helpers/OverrideUtils.cpp b/launcher/modplatform/helpers/OverrideUtils.cpp new file mode 100644 index 0000000..e64b30f --- /dev/null +++ b/launcher/modplatform/helpers/OverrideUtils.cpp @@ -0,0 +1,65 @@ +#include "OverrideUtils.h" + +#include + +#include "FileSystem.h" + +namespace Override { + +void createOverrides(const QString& name, const QString& parent_folder, const QString& override_path) +{ + QString file_path(FS::PathCombine(parent_folder, name + ".txt")); + if (QFile::exists(file_path)) + FS::deletePath(file_path); + + FS::ensureFilePathExists(file_path); + + QFile file(file_path); + if (!file.open(QFile::WriteOnly)) { + qWarning() << "Failed to open file" << file.fileName() << "for writing:" << file.errorString(); + return; + } + + QDirIterator override_iterator(override_path, QDirIterator::Subdirectories); + while (override_iterator.hasNext()) { + auto override_file_path = override_iterator.next(); + QFileInfo info(override_file_path); + if (info.isFile()) { + // Absolute path with temp directory -> relative path + override_file_path = override_file_path.split(name).last().remove(0, 1); + + file.write(override_file_path.toUtf8()); + file.write("\n"); + } + } + + file.close(); +} + +QStringList readOverrides(const QString& name, const QString& parent_folder) +{ + QString file_path(FS::PathCombine(parent_folder, name + ".txt")); + + QFile file(file_path); + if (!file.exists()) + return {}; + + QStringList previous_overrides; + + if (!file.open(QFile::ReadOnly)) { + qWarning() << "Failed to open file" << file.fileName() << "for reading:" << file.errorString(); + return previous_overrides; + } + + QString entry; + do { + entry = file.readLine(); + previous_overrides.append(entry.trimmed()); + } while (!entry.isEmpty()); + + file.close(); + + return previous_overrides; +} + +} // namespace Override diff --git a/launcher/modplatform/helpers/OverrideUtils.h b/launcher/modplatform/helpers/OverrideUtils.h new file mode 100644 index 0000000..536261a --- /dev/null +++ b/launcher/modplatform/helpers/OverrideUtils.h @@ -0,0 +1,20 @@ +#pragma once + +#include + +namespace Override { + +/** This creates a file in `parent_folder` that holds information about which + * overrides are in `override_path`. + * + * If there's already an existing such file, it will be ovewritten. + */ +void createOverrides(const QString& name, const QString& parent_folder, const QString& override_path); + +/** This reads an existing overrides archive, returning a list of overrides. + * + * If there's no such file in `parent_folder`, it will return an empty list. + */ +QStringList readOverrides(const QString& name, const QString& parent_folder); + +} // namespace Override diff --git a/launcher/modplatform/import_ftb/PackHelpers.cpp b/launcher/modplatform/import_ftb/PackHelpers.cpp new file mode 100644 index 0000000..101ddae --- /dev/null +++ b/launcher/modplatform/import_ftb/PackHelpers.cpp @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "modplatform/import_ftb/PackHelpers.h" + +#include +#include +#include +#include + +#include "FileSystem.h" +#include "Json.h" + +namespace FTBImportAPP { + +QIcon loadFTBIcon(const QString& imagePath) +{ + // Map of type byte to image type string + static const QHash imageTypeMap = { { 0x00, "png" }, { 0x01, "jpg" }, { 0x02, "gif" }, { 0x03, "webp" } }; + QFile file(imagePath); + if (!file.exists() || !file.open(QIODevice::ReadOnly)) { + return QIcon(); + } + char type; + if (!file.getChar(&type)) { + qDebug() << "Missing FTB image type header at" << imagePath; + return QIcon(); + } + if (!imageTypeMap.contains(type)) { + qDebug().nospace().noquote() << "Don't recognize FTB image type 0x" << QString::number(type, 16); + return QIcon(); + } + + auto imageType = imageTypeMap[type]; + // Extract actual image data beyond the first byte + QImageReader reader(&file, imageType); + auto pixmap = QPixmap::fromImageReader(&reader); + if (pixmap.isNull()) { + qDebug() << "The FTB image at" << imagePath << "is not valid"; + return QIcon(); + } + return QIcon(pixmap); +} + +Modpack parseDirectory(QString path) +{ + Modpack modpack{ path }; + auto instanceFile = QFileInfo(FS::PathCombine(path, "instance.json")); + if (!instanceFile.exists() || !instanceFile.isFile()) + return {}; + try { + auto doc = Json::requireDocument(instanceFile.absoluteFilePath(), "FTB_APP instance JSON file"); + const auto root = doc.object(); + modpack.uuid = Json::requireString(root, "uuid", "uuid"); + modpack.id = Json::requireInteger(root, "id", "id"); + modpack.versionId = Json::requireInteger(root, "versionId", "versionId"); + modpack.name = Json::requireString(root, "name", "name"); + modpack.version = Json::requireString(root, "version", "version"); + modpack.mcVersion = Json::requireString(root, "mcVersion", "mcVersion"); + modpack.jvmArgs = root["jvmArgs"].toVariant(); + modpack.totalPlayTime = Json::requireInteger(root, "totalPlayTime", "totalPlayTime"); + + auto modLoader = Json::requireString(root, "modLoader", "modLoader"); + if (!modLoader.isEmpty()) { + const auto parts = modLoader.split('-', Qt::KeepEmptyParts); + if (parts.size() >= 2) { + const auto loader = parts.first().toLower(); + modpack.loaderVersion = parts.at(1).trimmed(); + if (loader == "neoforge") { + modpack.loaderType = ModPlatform::NeoForge; + } else if (loader == "forge") { + modpack.loaderType = ModPlatform::Forge; + } else if (loader == "fabric") { + modpack.loaderType = ModPlatform::Fabric; + } else if (loader == "quilt") { + modpack.loaderType = ModPlatform::Quilt; + } + } + } + } catch (const Exception& e) { + qDebug() << "Couldn't load ftb instance json:" << e.cause(); + return {}; + } + if (!modpack.loaderType.has_value()) { + legacyInstanceParsing(path, &modpack.loaderType, &modpack.loaderVersion); + } + + auto iconFile = QFileInfo(FS::PathCombine(path, "folder.jpg")); + if (iconFile.exists() && iconFile.isFile()) { + modpack.icon = QIcon(iconFile.absoluteFilePath()); + } else { // the logo is a file that the first bit denotes the image tipe followed by the actual image data + modpack.icon = loadFTBIcon(FS::PathCombine(path, ".ftbapp", "logo")); + } + return modpack; +} + +void legacyInstanceParsing(QString path, std::optional* loaderType, QString* loaderVersion) +{ + auto versionsFile = QFileInfo(FS::PathCombine(path, ".ftbapp", "version.json")); + if (!versionsFile.exists() || !versionsFile.isFile()) { + versionsFile = QFileInfo(FS::PathCombine(path, "version.json")); + } + if (!versionsFile.exists() || !versionsFile.isFile()) { + qDebug() << "Couldn't find ftb version json"; + return; + } + try { + auto doc = Json::requireDocument(versionsFile.absoluteFilePath(), "FTB_APP version JSON file"); + const auto root = doc.object(); + auto targets = Json::requireArray(root, "targets", "targets"); + + for (auto target : targets) { + auto obj = Json::requireObject(target, "target"); + auto name = Json::requireString(obj, "name", "name"); + auto version = Json::requireString(obj, "version", "version"); + if (name == "neoforge") { + *loaderType = ModPlatform::NeoForge; + *loaderVersion = version; + break; + } else if (name == "forge") { + *loaderType = ModPlatform::Forge; + *loaderVersion = version; + break; + } else if (name == "fabric") { + *loaderType = ModPlatform::Fabric; + *loaderVersion = version; + break; + } else if (name == "quilt") { + *loaderType = ModPlatform::Quilt; + *loaderVersion = version; + break; + } + } + } catch (const Exception& e) { + qDebug() << "Couldn't load ftb version json:" << e.cause(); + return; + } +} +} // namespace FTBImportAPP diff --git a/launcher/modplatform/import_ftb/PackHelpers.h b/launcher/modplatform/import_ftb/PackHelpers.h new file mode 100644 index 0000000..f010313 --- /dev/null +++ b/launcher/modplatform/import_ftb/PackHelpers.h @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +#pragma once + +#include +#include +#include +#include +#include +#include "modplatform/ResourceAPI.h" + +namespace FTBImportAPP { + +struct Modpack { + QString path; + + // json data + QString uuid; + int id; + int versionId; + QString name; + QString version; + QString mcVersion; + int totalPlayTime; + // not needed for instance creation + QVariant jvmArgs; + + std::optional loaderType; + QString loaderVersion; + + QIcon icon; +}; + +using ModpackList = QList; + +Modpack parseDirectory(QString path); +void legacyInstanceParsing(QString path, std::optional* loaderType, QString* loaderVersion); +} // namespace FTBImportAPP + +// We need it for the proxy model +Q_DECLARE_METATYPE(FTBImportAPP::Modpack) diff --git a/launcher/modplatform/import_ftb/PackInstallTask.cpp b/launcher/modplatform/import_ftb/PackInstallTask.cpp new file mode 100644 index 0000000..878ef26 --- /dev/null +++ b/launcher/modplatform/import_ftb/PackInstallTask.cpp @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "PackInstallTask.h" + +#include + +#include "BaseInstance.h" +#include "FileSystem.h" +#include "minecraft/MinecraftInstance.h" +#include "minecraft/PackProfile.h" +#include "modplatform/ResourceAPI.h" +#include "modplatform/import_ftb/PackHelpers.h" +#include "settings/INISettingsObject.h" + +namespace FTBImportAPP { + +void PackInstallTask::executeTask() +{ + setStatus(tr("Copying files...")); + setAbortable(false); + progress(1, 2); + + m_copyFuture = QtConcurrent::run(QThreadPool::globalInstance(), [this] { + FS::copy folderCopy(m_pack.path, FS::PathCombine(m_stagingPath, "minecraft")); + return folderCopy(); + }); + connect(&m_copyFutureWatcher, &QFutureWatcher::finished, this, &PackInstallTask::copySettings); + connect(&m_copyFutureWatcher, &QFutureWatcher::canceled, this, &PackInstallTask::emitAborted); + m_copyFutureWatcher.setFuture(m_copyFuture); +} + +void PackInstallTask::copySettings() +{ + setStatus(tr("Copying settings...")); + progress(2, 2); + + QString instanceConfigPath = FS::PathCombine(m_stagingPath, "instance.cfg"); + MinecraftInstance instance(m_globalSettings, std::make_unique(instanceConfigPath), m_stagingPath); + + { + SettingsObject::Lock lock(instance.settings()); + instance.settings()->set("InstanceType", "OneSix"); + instance.settings()->set("totalTimePlayed", m_pack.totalPlayTime / 1000); + + if (m_pack.jvmArgs.isValid() && !m_pack.jvmArgs.toString().isEmpty()) { + instance.settings()->set("OverrideJavaArgs", true); + instance.settings()->set("JvmArgs", m_pack.jvmArgs.toString()); + } + + auto components = instance.getPackProfile(); + components->buildingFromScratch(); + components->setComponentVersion("net.minecraft", m_pack.mcVersion, true); + + auto modloader = m_pack.loaderType; + if (modloader.has_value()) + switch (modloader.value()) { + case ModPlatform::NeoForge: { + components->setComponentVersion("net.neoforged", m_pack.loaderVersion, true); + break; + } + case ModPlatform::Forge: { + components->setComponentVersion("net.minecraftforge", m_pack.loaderVersion, true); + break; + } + case ModPlatform::Fabric: { + components->setComponentVersion("net.fabricmc.fabric-loader", m_pack.loaderVersion, true); + break; + } + case ModPlatform::Quilt: { + components->setComponentVersion("org.quiltmc.quilt-loader", m_pack.loaderVersion, true); + break; + } + case ModPlatform::Cauldron: + break; + case ModPlatform::LiteLoader: + break; + case ModPlatform::DataPack: + break; + case ModPlatform::Babric: + break; + case ModPlatform::BTA: + break; + case ModPlatform::LegacyFabric: + break; + case ModPlatform::Ornithe: + break; + case ModPlatform::Rift: + break; + } + components->saveNow(); + + instance.setName(name()); + if (m_instIcon == "default") + m_instIcon = "ftb_logo"; + instance.setIconKey(m_instIcon); + } + emitSucceeded(); +} + +} // namespace FTBImportAPP diff --git a/launcher/modplatform/import_ftb/PackInstallTask.h b/launcher/modplatform/import_ftb/PackInstallTask.h new file mode 100644 index 0000000..842e4b3 --- /dev/null +++ b/launcher/modplatform/import_ftb/PackInstallTask.h @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include + +#include "InstanceTask.h" +#include "PackHelpers.h" + +namespace FTBImportAPP { + +class PackInstallTask : public InstanceTask { + Q_OBJECT + + public: + explicit PackInstallTask(const Modpack& pack) : m_pack(pack) {} + virtual ~PackInstallTask() = default; + + protected: + virtual void executeTask() override; + + private slots: + void copySettings(); + + private: + QFuture m_copyFuture; + QFutureWatcher m_copyFutureWatcher; + + const Modpack m_pack; +}; + +} // namespace FTBImportAPP diff --git a/launcher/modplatform/legacy_ftb/PackFetchTask.cpp b/launcher/modplatform/legacy_ftb/PackFetchTask.cpp new file mode 100644 index 0000000..7d1807a --- /dev/null +++ b/launcher/modplatform/legacy_ftb/PackFetchTask.cpp @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "PackFetchTask.h" +#include "PrivatePackManager.h" + +#include +#include "Application.h" +#include "BuildConfig.h" + +#include "net/ApiDownload.h" + +namespace LegacyFTB { + +void PackFetchTask::fetch() +{ + publicPacks.clear(); + thirdPartyPacks.clear(); + + jobPtr.reset(new NetJob("LegacyFTB::ModpackFetch", m_network)); + + QUrl publicPacksUrl = QUrl(BuildConfig.LEGACY_FTB_CDN_BASE_URL + "static/modpacks.xml"); + qDebug() << "Downloading public version info from" << publicPacksUrl.toString(); + + auto [publicAction, publicResponse] = Net::ApiDownload::makeByteArray(publicPacksUrl); + jobPtr->addNetAction(publicAction); + + QUrl thirdPartyUrl = QUrl(BuildConfig.LEGACY_FTB_CDN_BASE_URL + "static/thirdparty.xml"); + qDebug() << "Downloading thirdparty version info from" << thirdPartyUrl.toString(); + + auto [thirdPartyAction, thirdPartyResponse] = Net::Download::makeByteArray(thirdPartyUrl); + jobPtr->addNetAction(thirdPartyAction); + + connect(jobPtr.get(), &NetJob::succeeded, this, + [this, publicResponse, thirdPartyResponse] { fileDownloadFinished(publicResponse, thirdPartyResponse); }); + connect(jobPtr.get(), &NetJob::failed, this, &PackFetchTask::fileDownloadFailed); + connect(jobPtr.get(), &NetJob::aborted, this, &PackFetchTask::fileDownloadAborted); + + jobPtr->start(); +} + +void PackFetchTask::fetchPrivate(const QStringList& toFetch) +{ + QString privatePackBaseUrl = BuildConfig.LEGACY_FTB_CDN_BASE_URL + "static/%1.xml"; + + for (auto& packCode : toFetch) { + NetJob* job = new NetJob("Fetching private pack", m_network); + + auto [action, data] = Net::ApiDownload::makeByteArray(privatePackBaseUrl.arg(packCode)); + job->addNetAction(action); + job->setAskRetry(false); + + connect(job, &NetJob::succeeded, this, [this, job, data, packCode] { + ModpackList packs; + parseAndAddPacks(*data, PackType::Private, packs); + for (auto& currentPack : packs) { + currentPack.packCode = packCode; + emit privateFileDownloadFinished(currentPack); + } + + job->deleteLater(); + }); + + connect(job, &NetJob::failed, this, [this, job, packCode](QString reason) { + emit privateFileDownloadFailed(reason, packCode); + job->deleteLater(); + }); + + connect(job, &NetJob::aborted, this, [this, job] { + job->deleteLater(); + + emit aborted(); + }); + + job->start(); + } +} + +void PackFetchTask::fileDownloadFinished(QByteArray* publicPtr, QByteArray* thirdPartyPtr) +{ + QStringList failedLists; + + if (!parseAndAddPacks(*publicPtr, PackType::Public, publicPacks)) { + failedLists.append(tr("Public Packs")); + } + + if (!parseAndAddPacks(*thirdPartyPtr, PackType::ThirdParty, thirdPartyPacks)) { + failedLists.append(tr("Third Party Packs")); + } + + // NOTE(TheKodeToad): we don't want to reset the jobPtr earlier as it may invalidate the responses! + jobPtr.reset(); + + if (failedLists.size() > 0) { + emit failed(tr("Failed to download some pack lists: %1").arg(failedLists.join("\n- "))); + } else { + emit finished(publicPacks, thirdPartyPacks); + } +} + +bool PackFetchTask::parseAndAddPacks(QByteArray& data, PackType packType, ModpackList& list) +{ + QDomDocument doc; + + QString errorMsg = "Unknown error."; + int errorLine = -1; + int errorCol = -1; + + if (!doc.setContent(data, false, &errorMsg, &errorLine, &errorCol)) { + auto fullErrMsg = QString("Failed to fetch modpack data: %1 %2:%3!").arg(errorMsg).arg(errorLine).arg(errorCol); + qWarning() << fullErrMsg; + return false; + } + + QDomNodeList nodes = doc.elementsByTagName("modpack"); + for (int i = 0; i < nodes.length(); i++) { + QDomElement element = nodes.at(i).toElement(); + + Modpack modpack; + modpack.name = element.attribute("name"); + modpack.currentVersion = element.attribute("version"); + modpack.mcVersion = element.attribute("mcVersion"); + modpack.description = element.attribute("description"); + modpack.mods = element.attribute("mods"); + modpack.logo = element.attribute("logo"); + modpack.oldVersions = element.attribute("oldVersions").split(";"); + modpack.broken = false; + modpack.bugged = false; + + // remove empty if the xml is bugged + for (QString curr : modpack.oldVersions) { + if (curr.isNull() || curr.isEmpty()) { + modpack.oldVersions.removeAll(curr); + modpack.bugged = true; + qWarning() << "Removed some empty versions from" << modpack.name; + } + } + + if (modpack.oldVersions.size() < 1) { + if (!modpack.currentVersion.isNull() && !modpack.currentVersion.isEmpty()) { + modpack.oldVersions.append(modpack.currentVersion); + qWarning() << "Added current version to oldVersions because oldVersions was empty! (" + modpack.name + ")"; + } else { + modpack.broken = true; + qWarning() << "Broken pack:" << modpack.name << "=> No valid version!"; + } + } + + modpack.author = element.attribute("author"); + + modpack.dir = element.attribute("dir"); + modpack.file = element.attribute("url"); + + modpack.type = packType; + + list.append(modpack); + } + + return true; +} + +void PackFetchTask::fileDownloadFailed(QString reason) +{ + qWarning() << "Fetching FTBPacks failed:" << reason; + emit failed(reason); +} + +void PackFetchTask::fileDownloadAborted() +{ + emit aborted(); +} + +} // namespace LegacyFTB diff --git a/launcher/modplatform/legacy_ftb/PackFetchTask.h b/launcher/modplatform/legacy_ftb/PackFetchTask.h new file mode 100644 index 0000000..3e1035b --- /dev/null +++ b/launcher/modplatform/legacy_ftb/PackFetchTask.h @@ -0,0 +1,43 @@ +#pragma once + +#include +#include +#include +#include "PackHelpers.h" +#include "net/NetJob.h" + +namespace LegacyFTB { + +class PackFetchTask : public QObject { + Q_OBJECT + + public: + PackFetchTask(QNetworkAccessManager* network) : QObject(nullptr), m_network(network) {}; + virtual ~PackFetchTask() = default; + + void fetch(); + void fetchPrivate(const QStringList& toFetch); + + private: + QNetworkAccessManager* m_network; + NetJob::Ptr jobPtr; + + bool parseAndAddPacks(QByteArray& data, PackType packType, ModpackList& list); + ModpackList publicPacks; + ModpackList thirdPartyPacks; + + protected slots: + void fileDownloadFinished(QByteArray* publicResponse, QByteArray* thirdPartyResponse); + void fileDownloadFailed(QString reason); + void fileDownloadAborted(); + + signals: + void finished(ModpackList publicPacks, ModpackList thirdPartyPacks); + void failed(QString reason); + void aborted(); + + void privateFileDownloadFinished(const Modpack& modpack); + void privateFileDownloadFailed(QString reason, QString packCode); +}; + +} // namespace LegacyFTB diff --git a/launcher/modplatform/legacy_ftb/PackHelpers.h b/launcher/modplatform/legacy_ftb/PackHelpers.h new file mode 100644 index 0000000..f2d18f8 --- /dev/null +++ b/launcher/modplatform/legacy_ftb/PackHelpers.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include +#include + +namespace LegacyFTB { + +// Header for structs etc... +enum class PackType { Public, ThirdParty, Private }; + +struct Modpack { + QString name; + QString description; + QString author; + QStringList oldVersions; + QString currentVersion; + QString mcVersion; + QString mods; + QString logo; + + // Technical data + QString dir; + QString file; //<- Url in the xml, but doesn't make much sense + + bool bugged = false; + bool broken = false; + + PackType type; + QString packCode; +}; + +using ModpackList = QList; + +} // namespace LegacyFTB + +// We need it for the proxy model +Q_DECLARE_METATYPE(LegacyFTB::Modpack) diff --git a/launcher/modplatform/legacy_ftb/PackInstallTask.cpp b/launcher/modplatform/legacy_ftb/PackInstallTask.cpp new file mode 100644 index 0000000..8220676 --- /dev/null +++ b/launcher/modplatform/legacy_ftb/PackInstallTask.cpp @@ -0,0 +1,222 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "PackInstallTask.h" + +#include + +#include "BaseInstance.h" +#include "FileSystem.h" +#include "MMCZip.h" +#include "minecraft/GradleSpecifier.h" +#include "minecraft/MinecraftInstance.h" +#include "minecraft/PackProfile.h" +#include "settings/INISettingsObject.h" + +#include "Application.h" +#include "BuildConfig.h" + +#include "net/ApiDownload.h" + +namespace LegacyFTB { + +PackInstallTask::PackInstallTask(QNetworkAccessManager* network, const Modpack& pack, QString version) +{ + m_pack = pack; + m_version = version; + m_network = network; +} + +void PackInstallTask::executeTask() +{ + downloadPack(); +} + +void PackInstallTask::downloadPack() +{ + setStatus(tr("Downloading zip for %1").arg(m_pack.name)); + setProgress(1, 4); + setAbortable(false); + + auto path = QString("%1/%2/%3").arg(m_pack.dir, m_version.replace(".", "_"), m_pack.file); + auto entry = APPLICATION->metacache()->resolveEntry("FTBPacks", path); + entry->setStale(true); + archivePath = entry->getFullPath(); + netJobContainer.reset(new NetJob("Download FTB Pack", m_network)); + QString url; + if (m_pack.type == PackType::Private) { + url = QString(BuildConfig.LEGACY_FTB_CDN_BASE_URL + "privatepacks/%1").arg(path); + } else { + url = QString(BuildConfig.LEGACY_FTB_CDN_BASE_URL + "modpacks/%1").arg(path); + } + netJobContainer->addNetAction(Net::ApiDownload::makeCached(url, entry)); + + connect(netJobContainer.get(), &NetJob::succeeded, this, &PackInstallTask::unzip); + connect(netJobContainer.get(), &NetJob::failed, this, &PackInstallTask::emitFailed); + connect(netJobContainer.get(), &NetJob::stepProgress, this, &PackInstallTask::propagateStepProgress); + connect(netJobContainer.get(), &NetJob::aborted, this, &PackInstallTask::emitAborted); + + netJobContainer->start(); + + setAbortable(true); + progress(1, 4); +} + +void PackInstallTask::unzip() +{ + setStatus(tr("Extracting modpack")); + setAbortable(false); + progress(2, 4); + + QDir extractDir(m_stagingPath); + + m_extractFuture = QtConcurrent::run(QThreadPool::globalInstance(), QOverload::of(MMCZip::extractDir), archivePath, + extractDir.absolutePath() + "/unzip"); + connect(&m_extractFutureWatcher, &QFutureWatcher::finished, this, &PackInstallTask::onUnzipFinished); + connect(&m_extractFutureWatcher, &QFutureWatcher::canceled, this, &PackInstallTask::onUnzipCanceled); + m_extractFutureWatcher.setFuture(m_extractFuture); +} + +void PackInstallTask::onUnzipFinished() +{ + install(); +} + +void PackInstallTask::onUnzipCanceled() +{ + emitAborted(); +} + +void PackInstallTask::install() +{ + setStatus(tr("Installing modpack")); + progress(3, 4); + QDir unzipMcDir(m_stagingPath + "/unzip/minecraft"); + if (unzipMcDir.exists()) { + // ok, found minecraft dir, move contents to instance dir + if (!FS::move(m_stagingPath + "/unzip/minecraft", m_stagingPath + "/minecraft")) { + emitFailed(tr("Failed to move unpacked Minecraft!")); + return; + } + } + + QString instanceConfigPath = FS::PathCombine(m_stagingPath, "instance.cfg"); + MinecraftInstance instance(m_globalSettings, std::make_unique(instanceConfigPath), m_stagingPath); + { + SettingsObject::Lock lock(instance.settings()); + + auto components = instance.getPackProfile(); + components->buildingFromScratch(); + components->setComponentVersion("net.minecraft", m_pack.mcVersion, true); + + bool fallback = true; + + // handle different versions + QFile packJson(m_stagingPath + "/minecraft/pack.json"); + QDir jarmodDir = QDir(m_stagingPath + "/unzip/instMods"); + if (packJson.exists()) { + if (packJson.open(QIODevice::ReadOnly | QIODevice::Text)) { + QJsonDocument doc = QJsonDocument::fromJson(packJson.readAll()); + packJson.close(); + + // we only care about the libs + QJsonArray libs = doc.object().value("libraries").toArray(); + + for (const auto& value : libs) { + QString nameValue = value.toObject().value("name").toString(); + if (!nameValue.startsWith("net.minecraftforge")) { + continue; + } + + GradleSpecifier forgeVersion(nameValue); + + components->setComponentVersion("net.minecraftforge", + forgeVersion.version().replace(m_pack.mcVersion, "").replace("-", "")); + packJson.remove(); + fallback = false; + break; + } + } else { + qWarning() << "Failed to open file" << packJson.fileName() << "for reading:" << packJson.errorString(); + } + } + + if (jarmodDir.exists()) { + qDebug() << "Found jarmods, installing..."; + + QStringList jarmods; + for (auto info : jarmodDir.entryInfoList(QDir::NoDotAndDotDot | QDir::Files)) { + qDebug() << "Jarmod:" << info.fileName(); + jarmods.push_back(info.absoluteFilePath()); + } + + components->installJarMods(jarmods); + fallback = false; + } + + // just nuke unzip directory, it s not needed anymore + FS::deletePath(m_stagingPath + "/unzip"); + + if (fallback) { + // TODO: Some fallback mechanism... or just keep failing! + emitFailed(tr("No installation method found!")); + return; + } + + components->saveNow(); + + progress(4, 4); + + instance.setName(name()); + if (m_instIcon == "default") { + m_instIcon = "ftb_logo"; + } + instance.setIconKey(m_instIcon); + } + + emitSucceeded(); +} + +bool PackInstallTask::abort() +{ + if (!canAbort()) { + return false; + } + + netJobContainer->abort(); + return InstanceTask::abort(); +} + +} // namespace LegacyFTB diff --git a/launcher/modplatform/legacy_ftb/PackInstallTask.h b/launcher/modplatform/legacy_ftb/PackInstallTask.h new file mode 100644 index 0000000..9877721 --- /dev/null +++ b/launcher/modplatform/legacy_ftb/PackInstallTask.h @@ -0,0 +1,49 @@ +#pragma once +#include "InstanceTask.h" +#include "PackHelpers.h" +#include "meta/Index.h" +#include "meta/Version.h" +#include "meta/VersionList.h" +#include "net/NetJob.h" + +#include + +namespace LegacyFTB { + +class PackInstallTask : public InstanceTask { + Q_OBJECT + + public: + explicit PackInstallTask(QNetworkAccessManager* network, const Modpack& pack, QString version); + virtual ~PackInstallTask() {} + + bool canAbort() const override { return true; } + bool abort() override; + + protected: + //! Entry point for tasks. + virtual void executeTask() override; + + private: + void downloadPack(); + void unzip(); + void install(); + + private slots: + + void onUnzipFinished(); + void onUnzipCanceled(); + + private: /* data */ + QNetworkAccessManager* m_network; + bool abortable = false; + QFuture> m_extractFuture; + QFutureWatcher> m_extractFutureWatcher; + NetJob::Ptr netJobContainer; + QString archivePath; + + Modpack m_pack; + QString m_version; +}; + +} // namespace LegacyFTB diff --git a/launcher/modplatform/legacy_ftb/PrivatePackManager.cpp b/launcher/modplatform/legacy_ftb/PrivatePackManager.cpp new file mode 100644 index 0000000..17e9f7d --- /dev/null +++ b/launcher/modplatform/legacy_ftb/PrivatePackManager.cpp @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "PrivatePackManager.h" + +#include + +#include "FileSystem.h" + +namespace LegacyFTB { + +void PrivatePackManager::load() +{ + try { + auto foo = QString::fromUtf8(FS::read(m_filename)).split('\n', Qt::SkipEmptyParts); + currentPacks = QSet(foo.begin(), foo.end()); + + dirty = false; + } catch (...) { + currentPacks = {}; + qWarning() << "Failed to read third party FTB pack codes from" << m_filename; + } +} + +void PrivatePackManager::save() const +{ + if (!dirty) { + return; + } + try { + QStringList list = currentPacks.values(); + FS::write(m_filename, list.join('\n').toUtf8()); + dirty = false; + } catch (...) { + qWarning() << "Failed to write third party FTB pack codes to" << m_filename; + } +} + +} // namespace LegacyFTB diff --git a/launcher/modplatform/legacy_ftb/PrivatePackManager.h b/launcher/modplatform/legacy_ftb/PrivatePackManager.h new file mode 100644 index 0000000..be811f8 --- /dev/null +++ b/launcher/modplatform/legacy_ftb/PrivatePackManager.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include + +namespace LegacyFTB { + +class PrivatePackManager { + public: + ~PrivatePackManager() { save(); } + void load(); + void save() const; + bool empty() const { return currentPacks.empty(); } + const QSet& getCurrentPackCodes() const { return currentPacks; } + void add(const QString& code) + { + currentPacks.insert(code); + dirty = true; + } + void remove(const QString& code) + { + currentPacks.remove(code); + dirty = true; + } + + private: + QSet currentPacks; + QString m_filename = "private_packs.txt"; + mutable bool dirty = false; +}; + +} // namespace LegacyFTB diff --git a/launcher/modplatform/modrinth/ModrinthAPI.cpp b/launcher/modplatform/modrinth/ModrinthAPI.cpp new file mode 100644 index 0000000..d5bea52 --- /dev/null +++ b/launcher/modplatform/modrinth/ModrinthAPI.cpp @@ -0,0 +1,170 @@ +// SPDX-FileCopyrightText: 2023 flowln +// +// SPDX-License-Identifier: GPL-3.0-only + +#include "ModrinthAPI.h" + +#include "Application.h" +#include "Json.h" +#include "net/ApiDownload.h" +#include "net/ApiUpload.h" +#include "net/NetJob.h" + +std::pair ModrinthAPI::currentVersion(const QString& hash, const QString& hash_format) const +{ + auto netJob = makeShared(QString("Modrinth::GetCurrentVersion"), APPLICATION->network()); + + auto [action, response] = + Net::ApiDownload::makeByteArray(QString(BuildConfig.MODRINTH_PROD_URL + "/version_file/%1?algorithm=%2").arg(hash, hash_format)); + netJob->addNetAction(action); + + return { netJob, response }; +} + +std::pair ModrinthAPI::currentVersions(const QStringList& hashes, QString hash_format) const +{ + auto netJob = makeShared(QString("Modrinth::GetCurrentVersions"), APPLICATION->network()); + + QJsonObject body_obj; + + Json::writeStringList(body_obj, "hashes", hashes); + Json::writeString(body_obj, "algorithm", hash_format); + + QJsonDocument body(body_obj); + auto body_raw = body.toJson(); + + auto [action, response] = Net::ApiUpload::makeByteArray(QString(BuildConfig.MODRINTH_PROD_URL + "/version_files"), body_raw); + netJob->addNetAction(action); + netJob->setAskRetry(false); + return { netJob, response }; +} + +std::pair ModrinthAPI::latestVersion(const QString& hash, + const QString& hash_format, + std::optional> mcVersions, + std::optional loaders) const +{ + auto netJob = makeShared(QString("Modrinth::GetLatestVersion"), APPLICATION->network()); + + QJsonObject body_obj; + + if (loaders.has_value()) { + Json::writeStringList(body_obj, "loaders", getModLoaderStrings(loaders.value())); + } + + if (mcVersions.has_value()) { + QStringList game_versions; + for (auto& ver : mcVersions.value()) { + game_versions.append(mapMCVersionToModrinth(ver)); + } + Json::writeStringList(body_obj, "game_versions", game_versions); + } + + QJsonDocument body(body_obj); + auto body_raw = body.toJson(); + + auto [action, response] = Net::ApiUpload::makeByteArray( + QString(BuildConfig.MODRINTH_PROD_URL + "/version_file/%1/update?algorithm=%2").arg(hash, hash_format), body_raw); + netJob->addNetAction(action); + + return { netJob, response }; +} + +std::pair ModrinthAPI::latestVersions(const QStringList& hashes, + const QString& hash_format, + std::optional> mcVersions, + std::optional loaders) const +{ + auto netJob = makeShared(QString("Modrinth::GetLatestVersions"), APPLICATION->network()); + + QJsonObject body_obj; + + Json::writeStringList(body_obj, "hashes", hashes); + Json::writeString(body_obj, "algorithm", hash_format); + + if (loaders.has_value()) { + Json::writeStringList(body_obj, "loaders", getModLoaderStrings(loaders.value())); + } + + if (mcVersions.has_value()) { + QStringList game_versions; + for (auto& ver : mcVersions.value()) { + game_versions.append(mapMCVersionToModrinth(ver)); + } + Json::writeStringList(body_obj, "game_versions", game_versions); + } + + QJsonDocument body(body_obj); + auto body_raw = body.toJson(); + auto [action, response] = Net::ApiUpload::makeByteArray(QString(BuildConfig.MODRINTH_PROD_URL + "/version_files/update"), body_raw); + netJob->addNetAction(action); + + return { netJob, response }; +} + +std::pair ModrinthAPI::getProjects(QStringList addonIds) const +{ + auto netJob = makeShared(QString("Modrinth::GetProjects"), APPLICATION->network()); + auto searchUrl = getMultipleModInfoURL(addonIds); + + auto [action, response] = Net::ApiDownload::makeByteArray(QUrl(searchUrl)); + netJob->addNetAction(action); + + return { netJob, response }; +} + +QList ModrinthAPI::getSortingMethods() const +{ + // https://docs.modrinth.com/api-spec/#tag/projects/operation/searchProjects + return { { .index = 1, .name = "relevance", .readable_name = QObject::tr("Sort by Relevance") }, + { .index = 2, .name = "downloads", .readable_name = QObject::tr("Sort by Downloads") }, + { .index = 3, .name = "follows", .readable_name = QObject::tr("Sort by Follows") }, + { .index = 4, .name = "newest", .readable_name = QObject::tr("Sort by Newest") }, + { .index = 5, .name = "updated", .readable_name = QObject::tr("Sort by Last Updated") } }; +} + +std::pair ModrinthAPI::getModCategories() +{ + auto netJob = makeShared(QString("Modrinth::GetCategories"), APPLICATION->network()); + auto [action, response] = Net::ApiDownload::makeByteArray(QUrl(BuildConfig.MODRINTH_PROD_URL + "/tag/category")); + netJob->addNetAction(action); + QObject::connect(netJob.get(), &Task::failed, [](const QString& msg) { qDebug() << "Modrinth failed to get categories:" << msg; }); + + return { netJob, response }; +} + +QList ModrinthAPI::loadCategories(const QByteArray& response, const QString& projectType) +{ + QList categories; + QJsonParseError parse_error{}; + QJsonDocument doc = QJsonDocument::fromJson(response, &parse_error); + if (parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from categories at" << parse_error.offset + << "reason:" << parse_error.errorString(); + qWarning() << *response; + return categories; + } + + try { + auto arr = Json::requireArray(doc); + + for (auto val : arr) { + auto cat = Json::requireObject(val); + auto name = Json::requireString(cat, "name"); + if (cat["project_type"].toString() == projectType) { + categories.push_back({ .name = name, .id = name }); + } + } + + } catch (Json::JsonException& e) { + qCritical() << "Failed to parse response from a version request."; + qCritical() << e.what(); + qDebug() << doc; + } + return categories; +} + +QList ModrinthAPI::loadModCategories(const QByteArray& response) +{ + return loadCategories(response, "mod"); +}; diff --git a/launcher/modplatform/modrinth/ModrinthAPI.h b/launcher/modplatform/modrinth/ModrinthAPI.h new file mode 100644 index 0000000..731ac1b --- /dev/null +++ b/launcher/modplatform/modrinth/ModrinthAPI.h @@ -0,0 +1,239 @@ +// SPDX-FileCopyrightText: 2022-2023 flowln +// +// SPDX-License-Identifier: GPL-3.0-only + +#pragma once + +#include "BuildConfig.h" +#include "modplatform/ModIndex.h" +#include "modplatform/ResourceAPI.h" +#include "modplatform/modrinth/ModrinthPackIndex.h" + +#include +#include + +class ModrinthAPI : public ResourceAPI { + public: + std::pair currentVersion(const QString& hash, const QString& hash_format) const; + + std::pair currentVersions(const QStringList& hashes, QString hash_format) const; + + std::pair latestVersion(const QString& hash, + const QString& hash_format, + std::optional> mcVersions, + std::optional loaders) const; + + std::pair latestVersions(const QStringList& hashes, + const QString& hash_format, + std::optional> mcVersions, + std::optional loaders) const; + + std::pair getProjects(QStringList addonIds) const override; + + static std::pair getModCategories(); + static QList loadCategories(const QByteArray& response, const QString& projectType); + static QList loadModCategories(const QByteArray& response); + + public: + auto getSortingMethods() const -> QList override; + + static auto getAuthorURL(const QString& name) -> QString { return "https://modrinth.com/user/" + name; }; + + static auto getModLoaderStrings(const ModPlatform::ModLoaderTypes types) -> QStringList + { + QStringList l; + for (auto loader : { ModPlatform::NeoForge, ModPlatform::Forge, ModPlatform::Fabric, ModPlatform::Quilt, ModPlatform::LiteLoader, + ModPlatform::DataPack, ModPlatform::Babric, ModPlatform::BTA, ModPlatform::LegacyFabric, ModPlatform::Ornithe, + ModPlatform::Rift }) { + if ((types & loader) != 0U) { + l << getModLoaderAsString(loader); + } + } + return l; + } + + static auto getModLoaderFilters(ModPlatform::ModLoaderTypes types) -> QString + { + QStringList l; + for (const auto& loader : getModLoaderStrings(types)) { + l << QString("\"categories:%1\"").arg(loader); + } + return l.join(','); + } + + static auto getCategoriesFilters(const QStringList& categories) -> QString + { + QStringList l; + for (const auto& cat : categories) { + l << QString("\"categories:%1\"").arg(cat); + } + return l.join(','); + } + + static QString getSideFilters(ModPlatform::Side side) + { + switch (side) { + case ModPlatform::Side::ClientSide: + return { R"("client_side:required","client_side:optional"],["server_side:optional","server_side:unsupported")" }; + case ModPlatform::Side::ServerSide: + return { R"("server_side:required","server_side:optional"],["client_side:optional","client_side:unsupported")" }; + case ModPlatform::Side::UniversalSide: + return { R"("client_side:required"],["server_side:required")" }; + case ModPlatform::Side::NoSide: + // fallthrough + default: + return {}; + } + } + + static QString mapMCVersionFromModrinth(QString v) + { + static const QString s_preString = " Pre-Release "; + bool pre = false; + if (v.contains("-pre")) { + pre = true; + v.replace("-pre", s_preString); + } + v.replace("-", " "); + if (pre) { + v.replace(" Pre Release ", s_preString); + } + return v; + } + + private: + static QString resourceTypeParameter(ModPlatform::ResourceType type) + { + switch (type) { + case ModPlatform::ResourceType::Mod: + return "mod"; + case ModPlatform::ResourceType::ResourcePack: + return "resourcepack"; + case ModPlatform::ResourceType::ShaderPack: + return "shader"; + case ModPlatform::ResourceType::DataPack: + return "datapack"; + case ModPlatform::ResourceType::Modpack: + return "modpack"; + default: + qWarning() << "Invalid resource type for Modrinth API!"; + break; + } + + return ""; + } + + QString createFacets(const SearchArgs& args) const + { + QStringList facets_list; + + if (args.loaders.has_value() && args.loaders.value() != 0) { + facets_list.append(QString("[%1]").arg(getModLoaderFilters(args.loaders.value()))); + } + if (args.versions.has_value() && !args.versions.value().empty()) { + facets_list.append(QString("[%1]").arg(getGameVersionsArray(args.versions.value()))); + } + if (args.side.has_value()) { + auto side = getSideFilters(args.side.value()); + if (!side.isEmpty()) { + facets_list.append(QString("[%1]").arg(side)); + } + } + if (args.categoryIds.has_value() && !args.categoryIds->empty()) { + facets_list.append(QString("[%1]").arg(getCategoriesFilters(args.categoryIds.value()))); + } + if (args.openSource) { + facets_list.append("[\"open_source:true\"]"); + } + + facets_list.append(QString("[\"project_type:%1\"]").arg(resourceTypeParameter(args.type))); + + return QString("[%1]").arg(facets_list.join(',')); + } + + public: + auto getSearchURL(const SearchArgs& args) const -> std::optional override + { + if (args.loaders.has_value() && args.loaders.value() != 0) { + if (!validateModLoaders(args.loaders.value())) { + qWarning() << "Modrinth - or our interface - does not support any the provided mod loaders!"; + return {}; + } + } + + QStringList get_arguments; + get_arguments.append(QString("offset=%1").arg(args.offset)); + get_arguments.append(QString("limit=25")); + if (args.search.has_value()) { + get_arguments.append(QString("query=%1").arg(args.search.value())); + } + if (args.sorting.has_value()) { + get_arguments.append(QString("index=%1").arg(args.sorting.value().name)); + } + get_arguments.append(QString("facets=%1").arg(createFacets(args))); + + return BuildConfig.MODRINTH_PROD_URL + "/search?" + get_arguments.join('&'); + }; + + auto getInfoURL(const QString& id) const -> std::optional override + { + return BuildConfig.MODRINTH_PROD_URL + "/project/" + id; + }; + + auto getMultipleModInfoURL(const QStringList& ids) const -> QString + { + return BuildConfig.MODRINTH_PROD_URL + QString("/projects?ids=[\"%1\"]").arg(ids.join("\",\"")); + }; + + auto getVersionsURL(const VersionSearchArgs& args) const -> std::optional override + { + QStringList get_arguments; + if (args.mcVersions.has_value()) { + get_arguments.append(QString("game_versions=[%1]").arg(getGameVersionsString(args.mcVersions.value()))); + } + if (args.loaders.has_value()) { + get_arguments.append(QString("loaders=[\"%1\"]").arg(getModLoaderStrings(args.loaders.value()).join("\",\""))); + } + get_arguments.append(QString("include_changelog=%1").arg(args.includeChangelog ? "true" : "false")); + + return QString("%1/project/%2/version%3%4") + .arg(BuildConfig.MODRINTH_PROD_URL, args.pack->addonId.toString(), get_arguments.isEmpty() ? "" : "?", get_arguments.join('&')); + }; + + QString getGameVersionsArray(const std::vector& mcVersions) const + { + QString s; + for (const auto& ver : mcVersions) { + s += QString(R"("versions:%1",)").arg(mapMCVersionToModrinth(ver)); + } + s.remove(s.length() - 1, 1); // remove last comma + return s.isEmpty() ? QString() : s; + } + + static auto validateModLoaders(ModPlatform::ModLoaderTypes loaders) -> bool + { + return (loaders & (ModPlatform::NeoForge | ModPlatform::Forge | ModPlatform::Fabric | ModPlatform::Quilt | ModPlatform::LiteLoader | + ModPlatform::DataPack | ModPlatform::Babric | ModPlatform::BTA | ModPlatform::LegacyFabric | + ModPlatform::Ornithe | ModPlatform::Rift)) != 0; + } + + std::optional getDependencyURL(const DependencySearchArgs& args) const override + { + return args.dependency.version.length() != 0 + ? QString("%1/version/%2").arg(BuildConfig.MODRINTH_PROD_URL, args.dependency.version) + : QString(R"(%1/project/%2/version?game_versions=["%3"]&loaders=["%4"]&include_changelog=%5)") + .arg(BuildConfig.MODRINTH_PROD_URL) + .arg(args.dependency.addonId.toString()) + .arg(mapMCVersionToModrinth(args.mcVersion)) + .arg(getModLoaderStrings(args.loader).join("\",\"")) + .arg(args.includeChangelog ? "true" : "false"); + }; + + QJsonArray documentToArray(QJsonDocument& obj) const override { return obj.object().value("hits").toArray(); } + void loadIndexedPack(ModPlatform::IndexedPack& m, QJsonObject& obj) const override { Modrinth::loadIndexedPack(m, obj); } + ModPlatform::IndexedVersion loadIndexedPackVersion(QJsonObject& obj, ModPlatform::ResourceType /*unused*/) const override + { + return Modrinth::loadIndexedPackVersion(obj); + }; + void loadExtraPackInfo(ModPlatform::IndexedPack& m, QJsonObject& obj) const override { Modrinth::loadExtraPackData(m, obj); } +}; diff --git a/launcher/modplatform/modrinth/ModrinthCheckUpdate.cpp b/launcher/modplatform/modrinth/ModrinthCheckUpdate.cpp new file mode 100644 index 0000000..bd5a50d --- /dev/null +++ b/launcher/modplatform/modrinth/ModrinthCheckUpdate.cpp @@ -0,0 +1,233 @@ +#include "ModrinthCheckUpdate.h" +#include "Application.h" +#include "ModrinthAPI.h" +#include "ModrinthPackIndex.h" + +#include "Json.h" + +#include "QObjectPtr.h" +#include "ResourceDownloadTask.h" + +#include "modplatform/ModIndex.h" +#include "modplatform/helpers/HashUtils.h" + +#include "tasks/ConcurrentTask.h" + +static const ModrinthAPI g_api; + +ModrinthCheckUpdate::ModrinthCheckUpdate(QList& resources, + std::vector& mcVersions, + QList loadersList, + ResourceFolderModel* resourceModel) + : CheckUpdateTask(resources, mcVersions, std::move(loadersList), resourceModel) + , m_hashType(ModPlatform::ProviderCapabilities::hashType(ModPlatform::ResourceProvider::MODRINTH).first()) +{ + if (!m_loadersList.isEmpty()) { // this is for mods so append all the other posible loaders to the initial list + m_initialSize = m_loadersList.length(); + ModPlatform::ModLoaderTypes modLoaders; + for (auto* m : resources) { + modLoaders |= m->metadata()->loaders; + } + for (auto l : m_loadersList) { + modLoaders &= ~static_cast(l); + } + m_loadersList.append(ModPlatform::modLoaderTypesToList(modLoaders)); + } +} + +bool ModrinthCheckUpdate::abort() +{ + if (m_job) { + return m_job->abort(); + } + return true; +} + +/* Check for update: + * - Get latest version available + * - Compare hash of the latest version with the current hash + * - If equal, no updates, else, there's updates, so add to the list + * */ +void ModrinthCheckUpdate::executeTask() +{ + setStatus(tr("Preparing resources for Modrinth...")); + setProgress(0, ((m_loadersList.isEmpty() ? 1 : m_loadersList.length()) * 2) + 1); + + auto hashing_task = + makeShared("MakeModrinthHashesTask", APPLICATION->settings()->get("NumberOfConcurrentTasks").toInt()); + bool startHasing = false; + for (auto* resource : m_resources) { + auto hash = resource->metadata()->hash; + + // Sadly the API can only handle one hash type per call, se we + // need to generate a new hash if the current one is innadequate + // (though it will rarely happen, if at all) + if (resource->metadata()->hash_format != m_hashType) { + auto hash_task = Hashing::createHasher(resource->fileinfo().absoluteFilePath(), ModPlatform::ResourceProvider::MODRINTH); + connect(hash_task.get(), &Hashing::Hasher::resultsReady, [this, resource](QString hash) { m_mappings.insert(hash, resource); }); + connect(hash_task.get(), &Task::failed, [this] { failed("Failed to generate hash"); }); + hashing_task->addTask(hash_task); + startHasing = true; + } else { + m_mappings.insert(hash, resource); + } + } + + if (startHasing) { + connect(hashing_task.get(), &Task::finished, this, &ModrinthCheckUpdate::checkNextLoader); + m_job = hashing_task; + hashing_task->start(); + } else { + checkNextLoader(); + } +} + +void ModrinthCheckUpdate::getUpdateModsForLoader(std::optional loader, bool forceModLoaderCheck) +{ + m_loaderIdx++; + + setStatus(tr("Waiting for the API response from Modrinth...")); + setProgress(m_progress + 1, m_progressTotal); + + QStringList hashes; + if (forceModLoaderCheck && loader.has_value()) { + for (const auto& hash : m_mappings.keys()) { + if ((m_mappings.value(hash)->metadata()->loaders & loader.value()) != 0) { + hashes.append(hash); + } + } + } else { + hashes = m_mappings.keys(); + } + + if (hashes.isEmpty()) { + checkNextLoader(); + return; + } + + auto [job, response] = g_api.latestVersions(hashes, m_hashType, m_gameVersions, loader); + + connect(job.get(), &Task::succeeded, this, [this, response, loader] { checkVersionsResponse(response, loader); }); + + connect(job.get(), &Task::failed, this, &ModrinthCheckUpdate::checkNextLoader); + + m_job = job; + job->start(); +} + +void ModrinthCheckUpdate::checkVersionsResponse(QByteArray* response, std::optional loader) +{ + setStatus(tr("Parsing the API response from Modrinth...")); + setProgress(m_progress + 1, m_progressTotal); + + QJsonParseError parse_error{}; + QJsonDocument doc = QJsonDocument::fromJson(*response, &parse_error); + if (parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from ModrinthCheckUpdate at" << parse_error.offset + << "reason:" << parse_error.errorString(); + qWarning() << *response; + + emitFailed(parse_error.errorString()); + return; + } + + try { + auto iter = m_mappings.begin(); + + while (iter != m_mappings.end()) { + const QString hash = iter.key(); + Resource* resource = iter.value(); + + auto project_obj = doc[hash].toObject(); + + // If the returned project is empty, but we have Modrinth metadata, + // it means this specific version is not available + if (project_obj.isEmpty()) { + qDebug() << "Mod" << m_mappings.find(hash).value()->name() << "got an empty response. Hash:" << hash; + ++iter; + continue; + } + + // Sometimes a version may have multiple files, one with "forge" and one with "fabric", + // so we may want to filter it + QString loader_filter; + if (loader.has_value() && loader != 0) { + auto modLoaders = ModPlatform::modLoaderTypesToList(*loader); + if (!modLoaders.isEmpty()) { + loader_filter = ModPlatform::getModLoaderAsString(modLoaders.first()); + } + } + + // Currently, we rely on a couple heuristics to determine whether an update is actually available or not: + // - The file needs to be preferred: It is either the primary file, or the one found via (explicit) usage of the + // loader_filter + // - The version reported by the JAR is different from the version reported by the indexed version (it's usually the case) + // Such is the pain of having arbitrary files for a given version .-. + + auto project_ver = Modrinth::loadIndexedPackVersion(project_obj, m_hashType, loader_filter); + if (project_ver.downloadUrl.isEmpty()) { + qCritical() << "Modrinth mod without download url!" << project_ver.fileName; + ++iter; + continue; + } + + // Fake pack with the necessary info to pass to the download task :) + auto pack = std::make_shared(); + pack->name = resource->name(); + pack->slug = resource->metadata()->slug; + pack->addonId = resource->metadata()->project_id; + pack->provider = ModPlatform::ResourceProvider::MODRINTH; + if ((project_ver.hash != hash && project_ver.is_preferred) || (resource->status() == ResourceStatus::NOT_INSTALLED)) { + auto download_task = makeShared(pack, project_ver, m_resourceModel); + + QString old_version = resource->metadata()->version_number; + if (old_version.isEmpty()) { + if (resource->status() == ResourceStatus::NOT_INSTALLED) + old_version = tr("Not installed"); + else + old_version = tr("Unknown"); + } + + m_updates.emplace_back(pack->name, hash, old_version, project_ver.version_number, project_ver.version_type, + project_ver.changelog, ModPlatform::ResourceProvider::MODRINTH, download_task, resource->enabled()); + } + m_deps.append(std::make_shared(pack, project_ver)); + + iter = m_mappings.erase(iter); + } + } catch (Json::JsonException& e) { + emitFailed(e.cause() + ": " + e.what()); + return; + } + checkNextLoader(); +} + +void ModrinthCheckUpdate::checkNextLoader() +{ + if (m_mappings.isEmpty()) { + emitSucceeded(); + return; + } + if (m_loaderIdx < m_loadersList.size()) { // this are mods so check with loades + getUpdateModsForLoader(m_loadersList.at(m_loaderIdx), m_loaderIdx > m_initialSize); + return; + } else if (m_loadersList.isEmpty() && m_loaderIdx == 0) { // this are other resources no need to check more than once with empty loader + getUpdateModsForLoader(); + return; + } + + for (auto resource : m_mappings) { + QString reason; + + if (dynamic_cast(resource) != nullptr) + reason = + tr("No valid version found for this resource. It's probably unavailable for the current game " + "version / mod loader."); + else + reason = tr("No valid version found for this resource. It's probably unavailable for the current game version."); + + emit checkFailed(resource, reason); + } + + emitSucceeded(); +} diff --git a/launcher/modplatform/modrinth/ModrinthCheckUpdate.h b/launcher/modplatform/modrinth/ModrinthCheckUpdate.h new file mode 100644 index 0000000..c0407be --- /dev/null +++ b/launcher/modplatform/modrinth/ModrinthCheckUpdate.h @@ -0,0 +1,29 @@ +#pragma once + +#include "modplatform/CheckUpdateTask.h" + +class ModrinthCheckUpdate : public CheckUpdateTask { + Q_OBJECT + + public: + ModrinthCheckUpdate(QList& resources, + std::vector& mcVersions, + QList loadersList, + ResourceFolderModel* resourceModel); + + public slots: + bool abort() override; + + protected slots: + void executeTask() override; + void getUpdateModsForLoader(std::optional loader = {}, bool forceModLoaderCheck = false); + void checkVersionsResponse(QByteArray* response, std::optional loader); + void checkNextLoader(); + + private: + Task::Ptr m_job = nullptr; + QHash m_mappings; + QString m_hashType; + int m_loaderIdx = 0; + qsizetype m_initialSize = 0; +}; diff --git a/launcher/modplatform/modrinth/ModrinthInstanceCreationTask.cpp b/launcher/modplatform/modrinth/ModrinthInstanceCreationTask.cpp new file mode 100644 index 0000000..f308c88 --- /dev/null +++ b/launcher/modplatform/modrinth/ModrinthInstanceCreationTask.cpp @@ -0,0 +1,466 @@ +#include "ModrinthInstanceCreationTask.h" + +#include "Application.h" +#include "FileSystem.h" +#include "InstanceList.h" +#include "Json.h" + +#include "QObjectPtr.h" +#include "minecraft/MinecraftInstance.h" +#include "minecraft/PackProfile.h" + +#include "minecraft/mod/Mod.h" +#include "modplatform/EnsureMetadataTask.h" +#include "modplatform/helpers/OverrideUtils.h" + +#include "net/ChecksumValidator.h" + +#include "net/ApiDownload.h" +#include "net/NetJob.h" +#include "settings/INISettingsObject.h" + +#include "ui/dialogs/CustomMessageBox.h" +#include "ui/pages/modplatform/OptionalModDialog.h" + +#include +#include +#include +#include + +bool ModrinthCreationTask::abort() +{ + if (!canAbort()) + return false; + + if (m_task) + m_task->abort(); + return InstanceCreationTask::abort(); +} + +bool ModrinthCreationTask::updateInstance() +{ + auto instance_list = APPLICATION->instances(); + + // FIXME: How to handle situations when there's more than one install already for a given modpack? + BaseInstance* inst; + if (auto original_id = originalInstanceID(); !original_id.isEmpty()) { + inst = instance_list->getInstanceById(original_id); + Q_ASSERT(inst); + } else { + inst = instance_list->getInstanceByManagedName(originalName()); + + if (!inst) { + inst = instance_list->getInstanceById(originalName()); + + if (!inst) + return false; + } + } + + QString index_path = FS::PathCombine(m_stagingPath, "modrinth.index.json"); + if (!parseManifest(index_path, m_files, true, false)) + return false; + + auto version_name = inst->getManagedPackVersionName(); + m_root_path = QFileInfo(inst->gameRoot()).fileName(); + auto version_str = !version_name.isEmpty() ? tr(" (version %1)").arg(version_name) : ""; + + if (shouldConfirmUpdate()) { + auto should_update = askIfShouldUpdate(m_parent, version_str); + if (should_update == ShouldUpdate::SkipUpdating) + return false; + if (should_update == ShouldUpdate::Cancel) { + m_abort = true; + return false; + } + } + + // Remove repeated files, we don't need to download them! + QDir old_inst_dir(inst->instanceRoot()); + + QString old_index_folder(FS::PathCombine(old_inst_dir.absolutePath(), "mrpack")); + + QString old_index_path(FS::PathCombine(old_index_folder, "modrinth.index.json")); + QFileInfo old_index_file(old_index_path); + if (old_index_file.exists()) { + std::vector old_files; + parseManifest(old_index_path, old_files, false, false); + + // Let's remove all duplicated, identical resources! + auto files_iterator = m_files.begin(); + begin: + while (files_iterator != m_files.end()) { + auto const& file = *files_iterator; + + auto old_files_iterator = old_files.begin(); + while (old_files_iterator != old_files.end()) { + auto const& old_file = *old_files_iterator; + + if (old_file.hash == file.hash) { + qDebug() << "Removed file at" << file.path << "from list of downloads"; + files_iterator = m_files.erase(files_iterator); + old_files_iterator = old_files.erase(old_files_iterator); + goto begin; // Sorry :c + } + + old_files_iterator++; + } + + files_iterator++; + } + + QDir old_minecraft_dir(inst->gameRoot()); + + // Some files were removed from the old version, and some will be downloaded in an updated version, + // so we're fine removing them! + if (!old_files.empty()) { + for (auto const& file : old_files) { + scheduleToDelete(m_parent, old_minecraft_dir, file.path, true); + } + } + + // We will remove all the previous overrides, to prevent duplicate files! + // TODO: Currently 'overrides' will always override the stuff on update. How do we preserve unchanged overrides? + // FIXME: We may want to do something about disabled mods. + auto old_overrides = Override::readOverrides("overrides", old_index_folder); + for (const auto& entry : old_overrides) { + scheduleToDelete(m_parent, old_minecraft_dir, entry); + } + + auto old_client_overrides = Override::readOverrides("client-overrides", old_index_folder); + for (const auto& entry : old_client_overrides) { + scheduleToDelete(m_parent, old_minecraft_dir, entry); + } + } else { + // We don't have an old index file, so we may duplicate stuff! + auto dialog = CustomMessageBox::selectable(m_parent, tr("No index file."), + tr("We couldn't find a suitable index file for the older version. This may cause some " + "of the files to be duplicated. Do you want to continue?"), + QMessageBox::Warning, QMessageBox::Ok | QMessageBox::Cancel); + + if (dialog->exec() == QDialog::DialogCode::Rejected) { + m_abort = true; + return false; + } + } + + setOverride(true, inst->id()); + qDebug() << "Will override instance!"; + + m_instance = inst; + + // We let it go through the createInstance() stage, just with a couple modifications for updating + return false; +} + +// https://docs.modrinth.com/docs/modpacks/format_definition/ +std::unique_ptr ModrinthCreationTask::createInstance() +{ + QEventLoop loop; + + QString parent_folder(FS::PathCombine(m_stagingPath, "mrpack")); + + QString index_path = FS::PathCombine(m_stagingPath, "modrinth.index.json"); + if (m_files.empty() && !parseManifest(index_path, m_files, true, true)) + return nullptr; + + // Keep index file in case we need it some other time (like when changing versions) + QString new_index_place(FS::PathCombine(parent_folder, "modrinth.index.json")); + FS::ensureFilePathExists(new_index_place); + FS::move(index_path, new_index_place); + + auto mcPath = FS::PathCombine(m_stagingPath, m_root_path); + + auto override_path = FS::PathCombine(m_stagingPath, "overrides"); + if (QFile::exists(override_path)) { + // Create a list of overrides in "overrides.txt" inside mrpack/ + Override::createOverrides("overrides", parent_folder, override_path); + + // Apply the overrides + if (!FS::move(override_path, mcPath)) { + setError(tr("Could not rename the overrides folder:\n") + "overrides"); + return nullptr; + } + } + + // Do client overrides + auto client_override_path = FS::PathCombine(m_stagingPath, "client-overrides"); + if (QFile::exists(client_override_path)) { + // Create a list of overrides in "client-overrides.txt" inside mrpack/ + Override::createOverrides("client-overrides", parent_folder, client_override_path); + + // Apply the overrides + if (!FS::overrideFolder(mcPath, client_override_path)) { + setError(tr("Could not rename the client overrides folder:\n") + "client overrides"); + return nullptr; + } + } + + QString configPath = FS::PathCombine(m_stagingPath, "instance.cfg"); + auto instanceSettings = std::make_unique(configPath); + auto instance = std::make_unique(m_globalSettings, std::move(instanceSettings), m_stagingPath); + + auto components = instance->getPackProfile(); + components->buildingFromScratch(); + components->setComponentVersion("net.minecraft", m_minecraft_version, true); + + if (!m_fabric_version.isEmpty()) + components->setComponentVersion("net.fabricmc.fabric-loader", m_fabric_version); + if (!m_quilt_version.isEmpty()) + components->setComponentVersion("org.quiltmc.quilt-loader", m_quilt_version); + if (!m_forge_version.isEmpty()) + components->setComponentVersion("net.minecraftforge", m_forge_version); + if (!m_neoForge_version.isEmpty()) + components->setComponentVersion("net.neoforged", m_neoForge_version); + + if (m_instIcon != "default") { + instance->setIconKey(m_instIcon); + } else if (!m_managed_id.isEmpty()) { + instance->setIconKey("modrinth"); + } + + // Don't add managed info to packs without an ID (most likely imported from ZIP) + if (!m_managed_id.isEmpty()) + instance->setManagedPack("modrinth", m_managed_id, m_managed_name, m_managed_version_id, version()); + else + instance->setManagedPack("modrinth", "", name(), "", ""); + + instance->setName(name()); + instance->saveNow(); + + auto downloadMods = makeShared(tr("Mod Download Modrinth"), APPLICATION->network()); + + auto root_modpack_path = FS::PathCombine(m_stagingPath, m_root_path); + auto root_modpack_url = QUrl::fromLocalFile(root_modpack_path); + // TODO make this work with other sorts of resource + QHash resources; + for (auto& file : m_files) { + auto fileName = file.path; + fileName = FS::RemoveInvalidPathChars(fileName); + auto file_path = FS::PathCombine(root_modpack_path, fileName); + if (!root_modpack_url.isParentOf(QUrl::fromLocalFile(file_path))) { + // This means we somehow got out of the root folder, so abort here to prevent exploits + setError(tr("One of the files has a path that leads to an arbitrary location (%1). This is a security risk and isn't allowed.") + .arg(fileName)); + return nullptr; + } + if (fileName.startsWith("mods/")) { + auto mod = new Mod(file_path); + ModDetails d; + d.mod_id = file_path; + mod->setDetails(d); + resources[file.hash.toHex()] = mod; + } + if (file.downloads.empty()) { + setError(tr("The file '%1' is missing a download link. This is invalid in the pack format.").arg(fileName)); + return nullptr; + } + qDebug() << "Will try to download" << file.downloads.front() << "to" << file_path; + auto dl = Net::ApiDownload::makeFile(file.downloads.dequeue(), file_path); + dl->addValidator(new Net::ChecksumValidator(file.hashAlgorithm, file.hash)); + downloadMods->addNetAction(dl); + if (!file.downloads.empty()) { + // FIXME: This really needs to be put into a ConcurrentTask of + // MultipleOptionsTask's , once those exist :) + auto param = dl.toWeakRef(); + connect(dl.get(), &Task::failed, [&file, file_path, param, downloadMods] { + auto ndl = Net::ApiDownload::makeFile(file.downloads.dequeue(), file_path); + ndl->addValidator(new Net::ChecksumValidator(file.hashAlgorithm, file.hash)); + downloadMods->addNetAction(ndl); + if (auto shared = param.lock()) + shared->succeeded(); + }); + } + } + + bool ended_well = false; + + connect(downloadMods.get(), &NetJob::succeeded, this, [&ended_well]() { ended_well = true; }); + connect(downloadMods.get(), &NetJob::failed, [this, &ended_well](const QString& reason) { + ended_well = false; + setError(reason); + }); + connect(downloadMods.get(), &NetJob::finished, &loop, &QEventLoop::quit); + connect(downloadMods.get(), &NetJob::progress, [this](qint64 current, qint64 total) { + setDetails(tr("%1 out of %2 complete").arg(current).arg(total)); + setProgress(current, total); + }); + connect(downloadMods.get(), &NetJob::stepProgress, this, &ModrinthCreationTask::propagateStepProgress); + + setStatus(tr("Downloading mods...")); + downloadMods->start(); + m_task = downloadMods; + + loop.exec(); + + if (!ended_well) { + for (auto resource : resources) { + delete resource; + } + return nullptr; + } + + QEventLoop ensureMetaLoop; + QDir folder = FS::PathCombine(instance->modsRoot(), ".index"); + auto ensureMetadataTask = makeShared(resources, folder, ModPlatform::ResourceProvider::MODRINTH); + connect(ensureMetadataTask.get(), &Task::succeeded, this, [&ended_well]() { ended_well = true; }); + connect(ensureMetadataTask.get(), &Task::finished, &ensureMetaLoop, &QEventLoop::quit); + connect(ensureMetadataTask.get(), &Task::progress, [this](qint64 current, qint64 total) { + setDetails(tr("%1 out of %2 complete").arg(current).arg(total)); + setProgress(current, total); + }); + connect(ensureMetadataTask.get(), &Task::stepProgress, this, &ModrinthCreationTask::propagateStepProgress); + + ensureMetadataTask->start(); + m_task = ensureMetadataTask; + + ensureMetaLoop.exec(); + for (auto resource : resources) { + delete resource; + } + resources.clear(); + + // Update information of the already installed instance, if any. + if (m_instance && ended_well) { + setAbortable(false); + auto inst = m_instance.value(); + + // Only change the name if it didn't use a custom name, so that the previous custom name + // is preserved, but if we're using the original one, we update the version string. + // NOTE: This needs to come before the copyManagedPack call! + if (inst->name().contains(inst->getManagedPackVersionName()) && inst->name() != instance->name()) { + if (askForChangingInstanceName(m_parent, inst->name(), instance->name()) == InstanceNameChange::ShouldChange) + inst->setName(instance->name()); + } + + inst->copyManagedPack(*instance); + } + + if (ended_well) { + return instance; + } + return nullptr; +} + +bool ModrinthCreationTask::parseManifest(const QString& index_path, + std::vector& files, + bool set_internal_data, + bool show_optional_dialog) +{ + try { + auto doc = Json::requireDocument(index_path); + auto obj = Json::requireObject(doc, "modrinth.index.json"); + int formatVersion = Json::requireInteger(obj, "formatVersion", "modrinth.index.json"); + if (formatVersion == 1) { + auto game = Json::requireString(obj, "game", "modrinth.index.json"); + if (game != "minecraft") { + throw JSONValidationError("Unknown game: " + game); + } + + if (set_internal_data) { + if (m_managed_version_id.isEmpty()) + m_managed_version_id = obj["versionId"].toString(); + m_managed_name = obj["name"].toString(); + } + + auto jsonFiles = Json::requireIsArrayOf(obj, "files", "modrinth.index.json"); + std::vector optionalFiles; + for (const auto& modInfo : jsonFiles) { + File file; + file.path = Json::requireString(modInfo, "path").replace("\\", "/"); + + auto env = modInfo["env"].toObject(); + // 'env' field is optional + if (!env.isEmpty()) { + QString support = env["client"].toString("unsupported"); + if (support == "unsupported") { + continue; + } else if (support == "optional") { + file.required = false; + } + } + + QJsonObject hashes = Json::requireObject(modInfo, "hashes"); + file.hash = QByteArray::fromHex(Json::requireString(hashes, "sha512").toLatin1()); + file.hashAlgorithm = QCryptographicHash::Sha512; + + // Do not use requireUrl, which uses StrictMode, instead use QUrl's default TolerantMode + // (as Modrinth seems to incorrectly handle spaces) + + auto download_arr = modInfo["downloads"].toArray(); + for (auto download : download_arr) { + qWarning() << download.toString(); + bool is_last = download.toString() == download_arr.last().toString(); + + auto download_url = QUrl(download.toString()); + + if (!download_url.isValid()) { + qDebug() + << QString("Download URL (%1) for %2 is not a correctly formatted URL").arg(download_url.toString(), file.path); + if (is_last && file.downloads.isEmpty()) + throw JSONValidationError(tr("Download URL for %1 is not a correctly formatted URL").arg(file.path)); + } else { + file.downloads.push_back(download_url); + } + } + + (file.required ? files : optionalFiles).push_back(file); + } + + if (!optionalFiles.empty()) { + if (show_optional_dialog) { + QStringList oFiles; + for (auto file : optionalFiles) + oFiles.push_back(file.path); + OptionalModDialog optionalModDialog(m_parent, oFiles); + if (optionalModDialog.exec() == QDialog::Rejected) { + emitAborted(); + return false; + } + + auto selectedMods = optionalModDialog.getResult(); + for (auto file : optionalFiles) { + if (selectedMods.contains(file.path)) { + file.required = true; + } else { + file.path += ".disabled"; + } + files.push_back(file); + } + } else { + for (auto file : optionalFiles) { + file.path += ".disabled"; + files.push_back(file); + } + } + } + if (set_internal_data) { + auto dependencies = Json::requireObject(obj, "dependencies", "modrinth.index.json"); + for (auto it = dependencies.begin(), end = dependencies.end(); it != end; ++it) { + QString name = it.key(); + if (name == "minecraft") { + m_minecraft_version = Json::requireString(*it, "Minecraft version"); + } else if (name == "fabric-loader") { + m_fabric_version = Json::requireString(*it, "Fabric Loader version"); + } else if (name == "quilt-loader") { + m_quilt_version = Json::requireString(*it, "Quilt Loader version"); + } else if (name == "forge") { + m_forge_version = Json::requireString(*it, "Forge version"); + } else if (name == "neoforge") { + m_neoForge_version = Json::requireString(*it, "NeoForge version"); + } else { + throw JSONValidationError("Unknown dependency type: " + name); + } + } + } + } else { + throw JSONValidationError(QStringLiteral("Unknown format version: %s").arg(formatVersion)); + } + + } catch (const JSONValidationError& e) { + setError(tr("Could not understand pack index:\n") + e.cause()); + return false; + } + + return true; +} diff --git a/launcher/modplatform/modrinth/ModrinthInstanceCreationTask.h b/launcher/modplatform/modrinth/ModrinthInstanceCreationTask.h new file mode 100644 index 0000000..01cc875 --- /dev/null +++ b/launcher/modplatform/modrinth/ModrinthInstanceCreationTask.h @@ -0,0 +1,61 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include + +#include "BaseInstance.h" +#include "InstanceCreationTask.h" + +class ModrinthCreationTask final : public InstanceCreationTask { + Q_OBJECT + struct File { + QString path; + + QCryptographicHash::Algorithm hashAlgorithm; + QByteArray hash; + QQueue downloads; + bool required = true; + }; + + public: + ModrinthCreationTask(QString staging_path, + SettingsObject* global_settings, + QWidget* parent, + QString id, + QString version_id = {}, + QString original_instance_id = {}) + : InstanceCreationTask(), m_parent(parent), m_managed_id(std::move(id)), m_managed_version_id(std::move(version_id)) + { + setStagingPath(staging_path); + setParentSettings(global_settings); + + m_original_instance_id = std::move(original_instance_id); + } + + bool abort() override; + + bool updateInstance() override; + std::unique_ptr createInstance() override; + + private: + bool parseManifest(const QString&, std::vector&, bool set_internal_data = true, bool show_optional_dialog = true); + + private: + QWidget* m_parent = nullptr; + + QString m_minecraft_version, m_fabric_version, m_quilt_version, m_forge_version, m_neoForge_version; + QString m_managed_id, m_managed_version_id, m_managed_name; + + std::vector m_files; + Task::Ptr m_task; + + std::optional m_instance; + + QString m_root_path = "minecraft"; +}; diff --git a/launcher/modplatform/modrinth/ModrinthPackExportTask.cpp b/launcher/modplatform/modrinth/ModrinthPackExportTask.cpp new file mode 100644 index 0000000..9a972bc --- /dev/null +++ b/launcher/modplatform/modrinth/ModrinthPackExportTask.cpp @@ -0,0 +1,313 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "ModrinthPackExportTask.h" + +#include +#include +#include +#include +#include +#include "Json.h" +#include "MMCZip.h" +#include "archive/ExportToZipTask.h" +#include "minecraft/PackProfile.h" +#include "minecraft/mod/MetadataHandler.h" +#include "minecraft/mod/ModFolderModel.h" +#include "modplatform/ModIndex.h" +#include "modplatform/helpers/HashUtils.h" +#include "tasks/Task.h" + +const QStringList ModrinthPackExportTask::PREFIXES({ "mods/", "coremods/", "resourcepacks/", "texturepacks/", "shaderpacks/" }); +const QStringList ModrinthPackExportTask::FILE_EXTENSIONS({ "jar", "litemod", "zip" }); + +ModrinthPackExportTask::ModrinthPackExportTask(const QString& name, + const QString& version, + const QString& summary, + bool optionalFiles, + BaseInstance* instance, + const QString& output, + MMCZip::FilterFileFunction filter) + : name(name) + , version(version) + , summary(summary) + , optionalFiles(optionalFiles) + , instance(instance) + , mcInstance(dynamic_cast(instance)) + , gameRoot(instance->gameRoot()) + , output(output) + , filter(filter) +{} + +void ModrinthPackExportTask::executeTask() +{ + setStatus(tr("Searching for files...")); + setProgress(0, 0); + collectFiles(); +} + +bool ModrinthPackExportTask::abort() +{ + if (task) { + task->abort(); + return true; + } + return false; +} + +void ModrinthPackExportTask::collectFiles() +{ + setAbortable(false); + QCoreApplication::processEvents(); + + files.clear(); + if (!MMCZip::collectFileListRecursively(instance->gameRoot(), nullptr, &files, filter)) { + emitFailed(tr("Could not search for files")); + return; + } + + pendingHashes.clear(); + resolvedFiles.clear(); + + if (mcInstance) { + mcInstance->loaderModList()->update(); + connect(mcInstance->loaderModList(), &ModFolderModel::updateFinished, this, &ModrinthPackExportTask::collectHashes); + } else + collectHashes(); +} + +void ModrinthPackExportTask::collectHashes() +{ + setStatus(tr("Finding file hashes...")); + for (const QFileInfo& file : files) { + QCoreApplication::processEvents(); + + const QString relative = gameRoot.relativeFilePath(file.absoluteFilePath()); + // require sensible file types + if (!std::any_of(PREFIXES.begin(), PREFIXES.end(), [&relative](const QString& prefix) { return relative.startsWith(prefix); })) + continue; + if (!std::any_of(FILE_EXTENSIONS.begin(), FILE_EXTENSIONS.end(), [&relative](const QString& extension) { + return relative.endsWith('.' + extension) || relative.endsWith('.' + extension + ".disabled"); + })) + continue; + + QFile openFile(file.absoluteFilePath()); + if (!openFile.open(QFile::ReadOnly)) { + qWarning() << "Could not open" << file << "for hashing:" << openFile.errorString(); + continue; + } + + const QByteArray data = openFile.readAll(); + if (openFile.error() != QFileDevice::NoError) { + qWarning() << "Could not read" << file << "error:" << openFile.errorString(); + continue; + } + auto sha512 = Hashing::hash(data, Hashing::Algorithm::Sha512); + + auto allMods = mcInstance->loaderModList()->allMods(); + if (auto modIter = std::find_if(allMods.begin(), allMods.end(), [&file](Mod* mod) { return mod->fileinfo() == file; }); + modIter != allMods.end()) { + const Mod* mod = *modIter; + if (mod->metadata() != nullptr) { + const QUrl& url = mod->metadata()->url; + // ensure the url is permitted on modrinth.com + if (!url.isEmpty() && BuildConfig.MODRINTH_MRPACK_HOSTS.contains(url.host())) { + qDebug() << "Resolving" << relative << "from index"; + + auto sha1 = Hashing::hash(data, Hashing::Algorithm::Sha1); + + ResolvedFile resolvedFile{ sha1, sha512, url.toEncoded(), openFile.size(), mod->metadata()->side }; + resolvedFiles[relative] = resolvedFile; + + // nice! we've managed to resolve based on local metadata! + // no need to enqueue it + continue; + } + } + } + + qDebug() << "Enqueueing" << relative << "for Modrinth query"; + pendingHashes[relative] = sha512; + } + + setAbortable(true); + makeApiRequest(); +} + +void ModrinthPackExportTask::makeApiRequest() +{ + if (pendingHashes.isEmpty()) + buildZip(); + else { + setStatus(tr("Finding versions for hashes...")); + auto [versionsTask, response] = api.currentVersions(pendingHashes.values(), "sha512"); + task = versionsTask; + connect(task.get(), &Task::succeeded, [this, response]() { parseApiResponse(response); }); + connect(task.get(), &Task::failed, this, &ModrinthPackExportTask::emitFailed); + connect(task.get(), &Task::aborted, this, &ModrinthPackExportTask::emitAborted); + task->start(); + } +} + +void ModrinthPackExportTask::parseApiResponse(QByteArray* response) +{ + task = nullptr; + + try { + const QJsonDocument doc = Json::requireDocument(*response); + + QMapIterator iterator(pendingHashes); + while (iterator.hasNext()) { + iterator.next(); + + const QJsonObject obj = doc[iterator.value()].toObject(); + if (obj.isEmpty()) + continue; + + const QJsonArray files_array = obj["files"].toArray(); + if (auto fileIter = std::find_if(files_array.begin(), files_array.end(), + [&iterator](const QJsonValue& file) { return file["hashes"]["sha512"] == iterator.value(); }); + fileIter != files_array.end()) { + // map the file to the url + resolvedFiles[iterator.key()] = + ResolvedFile{ fileIter->toObject()["hashes"].toObject()["sha1"].toString(), iterator.value(), + fileIter->toObject()["url"].toString(), fileIter->toObject()["size"].toInt() }; + } + } + } catch (const Json::JsonException& e) { + emitFailed(tr("Failed to parse versions response: %1").arg(e.what())); + return; + } + pendingHashes.clear(); + buildZip(); +} + +void ModrinthPackExportTask::buildZip() +{ + setStatus(tr("Adding files...")); + + auto zipTask = makeShared(output, gameRoot, files, "overrides/", true); + zipTask->addExtraFile("modrinth.index.json", generateIndex()); + + zipTask->setExcludeFiles(resolvedFiles.keys()); + + auto progressStep = std::make_shared(); + connect(zipTask.get(), &Task::finished, this, [this, progressStep] { + progressStep->state = TaskStepState::Succeeded; + stepProgress(*progressStep); + }); + + connect(zipTask.get(), &Task::succeeded, this, &ModrinthPackExportTask::emitSucceeded); + connect(zipTask.get(), &Task::aborted, this, &ModrinthPackExportTask::emitAborted); + connect(zipTask.get(), &Task::failed, this, [this, progressStep](QString reason) { + progressStep->state = TaskStepState::Failed; + stepProgress(*progressStep); + emitFailed(reason); + }); + connect(zipTask.get(), &Task::stepProgress, this, &ModrinthPackExportTask::propagateStepProgress); + + connect(zipTask.get(), &Task::progress, this, [this, progressStep](qint64 current, qint64 total) { + progressStep->update(current, total); + stepProgress(*progressStep); + }); + connect(zipTask.get(), &Task::status, this, [this, progressStep](QString status) { + progressStep->status = status; + stepProgress(*progressStep); + }); + task.reset(zipTask); + zipTask->start(); +} + +QByteArray ModrinthPackExportTask::generateIndex() +{ + QJsonObject out; + out["formatVersion"] = 1; + out["game"] = "minecraft"; + out["name"] = name; + out["versionId"] = version; + if (!summary.isEmpty()) + out["summary"] = summary; + + if (mcInstance) { + auto profile = mcInstance->getPackProfile(); + // collect all supported components + const ComponentPtr minecraft = profile->getComponent("net.minecraft"); + const ComponentPtr quilt = profile->getComponent("org.quiltmc.quilt-loader"); + const ComponentPtr fabric = profile->getComponent("net.fabricmc.fabric-loader"); + const ComponentPtr forge = profile->getComponent("net.minecraftforge"); + const ComponentPtr neoForge = profile->getComponent("net.neoforged"); + + // convert all available components to mrpack dependencies + QJsonObject dependencies; + if (minecraft != nullptr) + dependencies["minecraft"] = minecraft->m_version; + if (quilt != nullptr) + dependencies["quilt-loader"] = quilt->m_version; + if (fabric != nullptr) + dependencies["fabric-loader"] = fabric->m_version; + if (forge != nullptr) + dependencies["forge"] = forge->m_version; + if (neoForge != nullptr) + dependencies["neoforge"] = neoForge->m_version; + + out["dependencies"] = dependencies; + } + + QJsonArray filesOut; + for (auto iterator = resolvedFiles.constBegin(); iterator != resolvedFiles.constEnd(); iterator++) { + QJsonObject fileOut; + + QString path = iterator.key(); + const ResolvedFile& value = iterator.value(); + + QJsonObject env; + + // detect disabled mod + const QFileInfo pathInfo(path); + if (optionalFiles && pathInfo.suffix() == "disabled") { + // rename it + path = pathInfo.dir().filePath(pathInfo.completeBaseName()); + env["client"] = "optional"; + env["server"] = "optional"; + } else { + env["client"] = "required"; + env["server"] = "required"; + } + + // a server side mod does not imply that the mod does not work on the client + // however, if a mrpack mod is marked as server-only it will not install on the client + if (iterator->side == ModPlatform::Side::ClientSide) + env["server"] = "unsupported"; + + fileOut["env"] = env; + + fileOut["path"] = path; + fileOut["downloads"] = QJsonArray{ iterator->url }; + + QJsonObject hashes; + hashes["sha1"] = value.sha1; + hashes["sha512"] = value.sha512; + fileOut["hashes"] = hashes; + + fileOut["fileSize"] = value.size; + filesOut << fileOut; + } + out["files"] = filesOut; + + return QJsonDocument(out).toJson(QJsonDocument::Compact); +} diff --git a/launcher/modplatform/modrinth/ModrinthPackExportTask.h b/launcher/modplatform/modrinth/ModrinthPackExportTask.h new file mode 100644 index 0000000..5aca657 --- /dev/null +++ b/launcher/modplatform/modrinth/ModrinthPackExportTask.h @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include +#include "BaseInstance.h" +#include "MMCZip.h" +#include "minecraft/MinecraftInstance.h" +#include "modplatform/ModIndex.h" +#include "modplatform/modrinth/ModrinthAPI.h" +#include "tasks/Task.h" + +class ModrinthPackExportTask : public Task { + Q_OBJECT + public: + ModrinthPackExportTask(const QString& name, + const QString& version, + const QString& summary, + bool optionalFiles, + BaseInstance* instance, + const QString& output, + MMCZip::FilterFileFunction filter); + + protected: + void executeTask() override; + bool abort() override; + + private: + struct ResolvedFile { + QString sha1, sha512, url; + qint64 size; + ModPlatform::Side side; + }; + + static const QStringList PREFIXES; + static const QStringList FILE_EXTENSIONS; + + // inputs + const QString name, version, summary; + const bool optionalFiles; + const BaseInstance* instance; + MinecraftInstance* mcInstance; + const QDir gameRoot; + const QString output; + const MMCZip::FilterFileFunction filter; + + ModrinthAPI api; + QFileInfoList files; + QMap pendingHashes; + QMap resolvedFiles; + Task::Ptr task; + + void collectFiles(); + void collectHashes(); + void makeApiRequest(); + void parseApiResponse(QByteArray* response); + void buildZip(); + + QByteArray generateIndex(); +}; diff --git a/launcher/modplatform/modrinth/ModrinthPackIndex.cpp b/launcher/modplatform/modrinth/ModrinthPackIndex.cpp new file mode 100644 index 0000000..48d28fe --- /dev/null +++ b/launcher/modplatform/modrinth/ModrinthPackIndex.cpp @@ -0,0 +1,238 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "ModrinthPackIndex.h" +#include "FileSystem.h" +#include "ModrinthAPI.h" + +#include "Json.h" +#include "minecraft/MinecraftInstance.h" +#include "minecraft/PackProfile.h" +#include "modplatform/ModIndex.h" + +bool shouldDownloadOnSide(const QString& side) +{ + return side == "required" || side == "optional"; +} + +// https://docs.modrinth.com/api/operations/getproject/ +void Modrinth::loadIndexedPack(ModPlatform::IndexedPack& pack, QJsonObject& obj) +{ + pack.addonId = obj["project_id"].toString(); + if (pack.addonId.toString().isEmpty()) { + pack.addonId = Json::requireString(obj, "id"); + } + + pack.provider = ModPlatform::ResourceProvider::MODRINTH; + pack.name = Json::requireString(obj, "title"); + + pack.slug = obj["slug"].toString(""); + if (!pack.slug.isEmpty()) { + pack.websiteUrl = "https://modrinth.com/mod/" + pack.slug; + } else { + pack.websiteUrl = ""; + } + + pack.description = obj["description"].toString(""); + + pack.logoUrl = obj["icon_url"].toString(""); + pack.logoName = QString("%1.%2").arg(obj["slug"].toString(), QFileInfo(QUrl(pack.logoUrl).fileName()).suffix()); + + if (obj.contains("author")) { + ModPlatform::ModpackAuthor modAuthor; + modAuthor.name = obj["author"].toString(); + modAuthor.url = ModrinthAPI::getAuthorURL(modAuthor.name); + pack.authors = { modAuthor }; + } + + auto client = shouldDownloadOnSide(obj["client_side"].toString()); + auto server = shouldDownloadOnSide(obj["server_side"].toString()); + + if (server && client) { + pack.side = ModPlatform::Side::UniversalSide; + } else if (server) { + pack.side = ModPlatform::Side::ServerSide; + } else if (client) { + pack.side = ModPlatform::Side::ClientSide; + } + + // Modrinth can have more data than what's provided by the basic search :) + pack.extraDataLoaded = false; +} + +void Modrinth::loadExtraPackData(ModPlatform::IndexedPack& pack, QJsonObject& obj) +{ + pack.extraData.issuesUrl = obj["issues_url"].toString(); + if (pack.extraData.issuesUrl.endsWith('/')) + pack.extraData.issuesUrl.chop(1); + + pack.extraData.sourceUrl = obj["source_url"].toString(); + if (pack.extraData.sourceUrl.endsWith('/')) + pack.extraData.sourceUrl.chop(1); + + pack.extraData.wikiUrl = obj["wiki_url"].toString(); + if (pack.extraData.wikiUrl.endsWith('/')) + pack.extraData.wikiUrl.chop(1); + + pack.extraData.discordUrl = obj["discord_url"].toString(); + if (pack.extraData.discordUrl.endsWith('/')) { + pack.extraData.discordUrl.chop(1); + } + + auto donate_arr = obj["donation_urls"].toArray(); + for (auto d : donate_arr) { + auto d_obj = Json::requireObject(d); + + ModPlatform::DonationData donate; + + donate.id = d_obj["id"].toString(); + donate.platform = d_obj["platform"].toString(); + donate.url = d_obj["url"].toString(); + + pack.extraData.donate.append(donate); + } + + pack.extraData.status = obj["status"].toString(); + + pack.extraData.body = obj["body"].toString().remove("
    "); + + pack.extraDataLoaded = true; +} + +ModPlatform::IndexedVersion Modrinth::loadIndexedPackVersion(QJsonObject& obj, + const QString& preferred_hash_type, + const QString& preferred_file_name) +{ + ModPlatform::IndexedVersion file; + + file.addonId = Json::requireString(obj, "project_id"); + file.fileId = Json::requireString(obj, "id"); + file.date = Json::requireString(obj, "date_published"); + auto versionArray = Json::requireArray(obj, "game_versions"); + if (versionArray.empty()) { + return {}; + } + for (auto mcVer : versionArray) { + file.mcVersion.append({ ModrinthAPI::mapMCVersionFromModrinth(mcVer.toString()), + mcVer.toString() }); // double this so we can check both strings when filtering + } + auto loaders = Json::requireArray(obj, "loaders"); + for (auto loader : loaders) { + if (loader == "neoforge") { + file.loaders |= ModPlatform::NeoForge; + } else if (loader == "forge") { + file.loaders |= ModPlatform::Forge; + } else if (loader == "cauldron") { + file.loaders |= ModPlatform::Cauldron; + } else if (loader == "liteloader") { + file.loaders |= ModPlatform::LiteLoader; + } else if (loader == "fabric") { + file.loaders |= ModPlatform::Fabric; + } else if (loader == "quilt") { + file.loaders |= ModPlatform::Quilt; + } + } + file.version = Json::requireString(obj, "name"); + file.version_number = Json::requireString(obj, "version_number"); + file.version_type = ModPlatform::IndexedVersionType::fromString(Json::requireString(obj, "version_type")); + + if (obj.contains("changelog")) { + file.changelog = Json::requireString(obj, "changelog"); + } + + auto dependencies = obj["dependencies"].toArray(); + for (auto d : dependencies) { + auto dep = d.toObject(); + ModPlatform::Dependency dependency; + dependency.addonId = dep["project_id"].toString(); + dependency.version = dep["version_id"].toString(); + auto depType = Json::requireString(dep, "dependency_type"); + + if (depType == "required") { + dependency.type = ModPlatform::DependencyType::REQUIRED; + } else if (depType == "optional") { + dependency.type = ModPlatform::DependencyType::OPTIONAL; + } else if (depType == "incompatible") { + dependency.type = ModPlatform::DependencyType::INCOMPATIBLE; + } else if (depType == "embedded") { + dependency.type = ModPlatform::DependencyType::EMBEDDED; + } else { + dependency.type = ModPlatform::DependencyType::UNKNOWN; + } + + file.dependencies.append(dependency); + } + + auto files = Json::requireArray(obj, "files"); + int i = 0; + + if (files.empty()) { + // This should not happen normally, but check just in case + qWarning() << "Modrinth returned an unexpected empty list of files:" << obj; + return {}; + } + + // Find correct file (needed in cases where one version may have multiple files) + // Will default to the last one if there's no primary (though I think Modrinth requires that + // at least one file is primary, idk) + // NOTE: files.count() is 1-indexed, so we need to subtract 1 to become 0-indexed + while (i < files.count() - 1) { + auto parent = files[i].toObject(); + auto fileName = Json::requireString(parent, "filename"); + + if (!preferred_file_name.isEmpty() && fileName.contains(preferred_file_name)) { + file.is_preferred = true; + break; + } + + // Grab the primary file, if available + if (Json::requireBoolean(parent, "primary")) { + break; + } + + i++; + } + + auto parent = files[i].toObject(); + if (parent.contains("url")) { + file.downloadUrl = Json::requireString(parent, "url"); + file.fileName = Json::requireString(parent, "filename"); + file.fileName = FS::RemoveInvalidPathChars(file.fileName); + file.is_preferred = Json::requireBoolean(parent, "primary") || (files.count() == 1); + auto hash_list = Json::requireObject(parent, "hashes"); + + if (hash_list.contains(preferred_hash_type)) { + file.hash = Json::requireString(hash_list, preferred_hash_type); + file.hash_type = preferred_hash_type; + } else { + auto hash_types = ModPlatform::ProviderCapabilities::hashType(ModPlatform::ResourceProvider::MODRINTH); + for (auto& hash_type : hash_types) { + if (hash_list.contains(hash_type)) { + file.hash = Json::requireString(hash_list, hash_type); + file.hash_type = hash_type; + break; + } + } + } + + return file; + } + + return {}; +} diff --git a/launcher/modplatform/modrinth/ModrinthPackIndex.h b/launcher/modplatform/modrinth/ModrinthPackIndex.h new file mode 100644 index 0000000..b38cd9c --- /dev/null +++ b/launcher/modplatform/modrinth/ModrinthPackIndex.h @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include "modplatform/ModIndex.h" + +#include "BaseInstance.h" + +namespace Modrinth { + +void loadIndexedPack(ModPlatform::IndexedPack& pack, QJsonObject& obj); +void loadExtraPackData(ModPlatform::IndexedPack& pack, QJsonObject& obj); +auto loadIndexedPackVersion(QJsonObject& obj, const QString& preferred_hash_type = "sha512", const QString& preferred_file_name = "") + -> ModPlatform::IndexedVersion; + +} // namespace Modrinth diff --git a/launcher/modplatform/packwiz/Packwiz.cpp b/launcher/modplatform/packwiz/Packwiz.cpp new file mode 100644 index 0000000..4d162a9 --- /dev/null +++ b/launcher/modplatform/packwiz/Packwiz.cpp @@ -0,0 +1,396 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "Packwiz.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "FileSystem.h" +#include "StringUtils.h" + +#include "Version.h" +#include "modplatform/ModIndex.h" + +#include + +namespace Packwiz { + +namespace { +auto getRealIndexName(const QDir& indexDir, const QString& normalizedFname, bool shouldFindMatch = false) -> QString +{ + const QFile indexFile(indexDir.absoluteFilePath(normalizedFname)); + + QString realFname = normalizedFname; + if (!indexFile.exists()) { + // Tries to get similar entries + for (auto& fileName : indexDir.entryList(QDir::Filter::Files)) { + if (QString::compare(normalizedFname, fileName, Qt::CaseInsensitive) == 0) { + realFname = fileName; + break; + } + } + + if (shouldFindMatch && (QString::compare(normalizedFname, realFname, Qt::CaseSensitive) == 0)) { + qCritical() << "Could not find a match for a valid metadata file!"; + qCritical() << "File:" << normalizedFname; + return {}; + } + } + + return realFname; +} + +// Helpers +auto indexFileName(const QString& modSlug) -> QString +{ + if (modSlug.endsWith(".pw.toml")) { + return modSlug; + } + return QString("%1.pw.toml").arg(modSlug); +} + +// Helper functions for extracting data from the TOML file +auto stringEntry(toml::table table, const QString& entryName) -> QString +{ + auto* node = table.get(StringUtils::toStdString(entryName)); + if (!node) { + qDebug() << "Failed to read str property '" + entryName + "' in mod metadata."; + return {}; + } + + return node->value_or(""); +} + +auto intEntry(toml::table table, const QString& entryName) -> int +{ + auto* node = table.get(StringUtils::toStdString(entryName)); + if (!node) { + qDebug() << "Failed to read int property '" + entryName + "' in mod metadata."; + return {}; + } + + return node->value_or(0); +} + +bool sortMCVersions(const QString& a, const QString& b) +{ + auto cmp = Version(a) <=> Version(b); + if (cmp == std::strong_ordering::equal) { + return a < b; + } + return cmp == std::strong_ordering::less; +} + +} // namespace +auto V1::createModFormat([[maybe_unused]] const QDir& index_dir, + ModPlatform::IndexedPack& mod_pack, + ModPlatform::IndexedVersion& mod_version) -> Mod +{ + Mod mod; + + mod.slug = mod_pack.slug; + mod.name = mod_pack.name; + mod.filename = mod_version.fileName; + + if (mod_pack.provider == ModPlatform::ResourceProvider::FLAME) { + mod.mode = "metadata:curseforge"; + } else { + mod.mode = "url"; + mod.url = mod_version.downloadUrl; + } + + mod.hash_format = mod_version.hash_type; + mod.hash = mod_version.hash; + + mod.provider = mod_pack.provider; + mod.file_id = mod_version.fileId; + mod.project_id = mod_pack.addonId; + mod.side = mod_version.side == ModPlatform::Side::NoSide ? mod_pack.side : mod_version.side; + mod.loaders = mod_version.loaders; + mod.mcVersions = mod_version.mcVersion; + mod.mcVersions.removeDuplicates(); + std::ranges::sort(mod.mcVersions, sortMCVersions); + mod.releaseType = mod_version.version_type; + + mod.version_number = mod_version.version_number; + if (mod.version_number.isNull()) // on CurseForge, there is only a version name - not a version number + mod.version_number = mod_version.version; + + mod.dependencies = mod_version.dependencies; + return mod; +} + +void V1::updateModIndex(const QDir& index_dir, Mod& mod) +{ + if (!mod.isValid()) { + qCritical() << QString("Tried to update metadata of an invalid mod!"); + return; + } + + // Ensure the corresponding mod's info exists, and create it if not + + auto normalized_fname = indexFileName(mod.slug); + auto real_fname = getRealIndexName(index_dir, normalized_fname); + + QFile index_file(index_dir.absoluteFilePath(real_fname)); + + if (real_fname != normalized_fname) + index_file.rename(normalized_fname); + + // There's already data on there! + // TODO: We should do more stuff here, as the user is likely trying to + // override a file. In this case, check versions and ask the user what + // they want to do! + if (index_file.exists()) { + index_file.remove(); + } else { + FS::ensureFilePathExists(index_file.fileName()); + } + + toml::table update; + switch (mod.provider) { + case (ModPlatform::ResourceProvider::FLAME): + if (mod.file_id.toInt() == 0 || mod.project_id.toInt() == 0) { + qCritical() << QString("Did not write file %1 because missing information!").arg(normalized_fname); + return; + } + update = toml::table{ + { "file-id", mod.file_id.toInt() }, + { "project-id", mod.project_id.toInt() }, + }; + break; + case (ModPlatform::ResourceProvider::MODRINTH): + if (mod.mod_id().toString().isEmpty() || mod.version().toString().isEmpty()) { + qCritical() << QString("Did not write file %1 because missing information!").arg(normalized_fname); + return; + } + update = toml::table{ + { "mod-id", mod.mod_id().toString().toStdString() }, + { "version", mod.version().toString().toStdString() }, + }; + break; + } + + toml::array loaders; + for (auto loader : ModPlatform::modLoaderTypesToList(mod.loaders)) { + loaders.push_back(getModLoaderAsString(loader).toStdString()); + } + toml::array mcVersions; + for (auto version : mod.mcVersions) { + mcVersions.push_back(version.toStdString()); + } + + if (!index_file.open(QIODevice::ReadWrite)) { + qCritical() << "Could not open file" << normalized_fname << "error:" << index_file.errorString(); + return; + } + + toml::array deps; + for (auto dep : mod.dependencies) { + auto tbl = toml::table{ { "addonId", dep.addonId.toString().toStdString() }, + { "type", ModPlatform::DependencyTypeUtils::toString(dep.type).toStdString() } }; + if (!dep.version.isEmpty()) { + tbl.emplace("version", dep.version.toStdString()); + } + deps.push_back(tbl); + } + + // Put TOML data into the file + QTextStream in_stream(&index_file); + { + auto tbl = toml::table{ { "name", mod.name.toStdString() }, + { "filename", mod.filename.toStdString() }, + { "side", ModPlatform::SideUtils::toString(mod.side).toStdString() }, + { "x-prismlauncher-loaders", loaders }, + { "x-prismlauncher-mc-versions", mcVersions }, + { "x-prismlauncher-release-type", mod.releaseType.toString().toStdString() }, + { "x-prismlauncher-version-number", mod.version_number.toStdString() }, + { "x-prismlauncher-dependencies", deps }, + { "download", + toml::table{ + { "mode", mod.mode.toStdString() }, + { "url", mod.url.toString().toStdString() }, + { "hash-format", mod.hash_format.toStdString() }, + { "hash", mod.hash.toStdString() }, + } }, + { "update", toml::table{ { ModPlatform::ProviderCapabilities::name(mod.provider), update } } } }; + std::stringstream ss; + ss << tbl; + in_stream << QString::fromStdString(ss.str()); + } + + index_file.flush(); + index_file.close(); +} + +void V1::deleteModIndex(const QDir& index_dir, QString& mod_slug) +{ + auto normalized_fname = indexFileName(mod_slug); + auto real_fname = getRealIndexName(index_dir, normalized_fname); + if (real_fname.isEmpty()) + return; + + QFile index_file(index_dir.absoluteFilePath(real_fname)); + + if (!index_file.exists()) { + qWarning() << QString("Tried to delete non-existent mod metadata for %1!").arg(mod_slug); + return; + } + + if (!index_file.remove()) { + qWarning() << QString("Failed to remove metadata for mod %1!").arg(mod_slug); + } +} + +auto V1::getIndexForMod(const QDir& index_dir, QString slug) -> Mod +{ + Mod mod; + + auto normalized_fname = indexFileName(slug); + auto real_fname = getRealIndexName(index_dir, normalized_fname, true); + if (real_fname.isEmpty()) + return {}; + + toml::table table; +#if TOML_EXCEPTIONS + try { + table = toml::parse_file(StringUtils::toStdString(index_dir.absoluteFilePath(real_fname))); + } catch (const toml::parse_error& err) { + qWarning() << QString("Could not open file %1!").arg(normalized_fname); + qWarning() << "Reason:" << QString(err.what()); + return {}; + } +#else + toml::parse_result result = toml::parse_file(StringUtils::toStdString(index_dir.absoluteFilePath(real_fname))); + if (!result) { + qWarning() << QString("Could not open file %1!").arg(normalized_fname); + qWarning() << "Reason:" << result.error().description(); + return {}; + } + table = result.table(); +#endif + + // index_file.close(); + + mod.slug = slug; + + { // Basic info + mod.name = stringEntry(table, "name"); + mod.filename = stringEntry(table, "filename"); + mod.side = ModPlatform::SideUtils::fromString(stringEntry(table, "side")); + mod.releaseType = ModPlatform::IndexedVersionType::fromString(table["x-prismlauncher-release-type"].value_or("")); + if (auto loaders = table["x-prismlauncher-loaders"]; loaders && loaders.is_array()) { + for (auto&& loader : *loaders.as_array()) { + if (loader.is_string()) { + mod.loaders |= ModPlatform::getModLoaderFromString(QString::fromStdString(loader.as_string()->value_or(""))); + } + } + } + if (auto versions = table["x-prismlauncher-mc-versions"]; versions && versions.is_array()) { + for (auto&& version : *versions.as_array()) { + if (version.is_string()) { + auto ver = QString::fromStdString(version.as_string()->value_or("")); + if (!ver.isEmpty()) { + mod.mcVersions << ver; + } + } + } + mod.mcVersions.removeDuplicates(); + std::ranges::sort(mod.mcVersions, sortMCVersions); + } + } + mod.version_number = table["x-prismlauncher-version-number"].value_or(""); + + { // [download] info + auto download_table = table["download"].as_table(); + if (!download_table) { + qCritical() << QString("No [download] section found on mod metadata!"); + return {}; + } + + mod.mode = stringEntry(*download_table, "mode"); + mod.url = stringEntry(*download_table, "url"); + mod.hash_format = stringEntry(*download_table, "hash-format"); + mod.hash = stringEntry(*download_table, "hash"); + } + + { // [update] info + using Provider = ModPlatform::ResourceProvider; + + auto update_table = table["update"]; + if (!update_table || !update_table.is_table()) { + qCritical() << QString("No [update] section found on mod metadata!"); + return {}; + } + + toml::table* mod_provider_table = nullptr; + if ((mod_provider_table = update_table[ModPlatform::ProviderCapabilities::name(Provider::FLAME)].as_table())) { + mod.provider = Provider::FLAME; + mod.file_id = intEntry(*mod_provider_table, "file-id"); + mod.project_id = intEntry(*mod_provider_table, "project-id"); + } else if ((mod_provider_table = update_table[ModPlatform::ProviderCapabilities::name(Provider::MODRINTH)].as_table())) { + mod.provider = Provider::MODRINTH; + mod.mod_id() = stringEntry(*mod_provider_table, "mod-id"); + mod.version() = stringEntry(*mod_provider_table, "version"); + } else { + qCritical() << QString("No mod provider on mod metadata!"); + return {}; + } + } + { // dependencies + auto deps = table["x-prismlauncher-dependencies"].as_array(); + if (deps) { + for (auto&& depNode : *deps) { + auto dep = depNode.as_table(); + if (dep) { + ModPlatform::Dependency d; + d.addonId = stringEntry(*dep, "addonId"); + if (dep->contains("version")) { + d.version = stringEntry(*dep, "version"); + } + d.type = ModPlatform::DependencyTypeUtils::fromString(stringEntry(*dep, "type")); + mod.dependencies << d; + } + } + } + } + + return mod; +} + +auto V1::getIndexForMod(const QDir& index_dir, QVariant& mod_id) -> Mod +{ + for (auto& file_name : index_dir.entryList(QDir::Filter::Files)) { + auto mod = getIndexForMod(index_dir, file_name); + + if (mod.mod_id() == mod_id) + return mod; + } + + return {}; +} + +} // namespace Packwiz diff --git a/launcher/modplatform/packwiz/Packwiz.h b/launcher/modplatform/packwiz/Packwiz.h new file mode 100644 index 0000000..b5b8177 --- /dev/null +++ b/launcher/modplatform/packwiz/Packwiz.h @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include "modplatform/ModIndex.h" + +#include +#include +#include + +class QDir; + +namespace Packwiz { + +class V1 { + public: + // can also represent other resources beside loader mods - but this is what packwiz calls it + struct Mod { + QString slug{}; + QString name{}; + QString filename{}; + ModPlatform::Side side{ ModPlatform::Side::UniversalSide }; + ModPlatform::ModLoaderTypes loaders; + QStringList mcVersions; + ModPlatform::IndexedVersionType releaseType; + + // [download] + QString mode{}; + QUrl url{}; + QString hash_format{}; + QString hash{}; + + // [update] + ModPlatform::ResourceProvider provider{}; + QVariant file_id{}; + QVariant project_id{}; + QString version_number{}; + + QList dependencies; + + public: + // This is a totally heuristic, but should work for now. + auto isValid() const -> bool { return !slug.isEmpty() && !project_id.isNull(); } + + // Different providers can use different names for the same thing + // Modrinth-specific + auto mod_id() -> QVariant& { return project_id; } + auto version() -> QVariant& { return file_id; } + }; + + /* Generates the object representing the information in a mod.pw.toml file via + * its common representation in the launcher, when downloading mods. + * */ + static auto createModFormat(const QDir& index_dir, ModPlatform::IndexedPack& mod_pack, ModPlatform::IndexedVersion& mod_version) -> Mod; + + /* Updates the mod index for the provided mod. + * This creates a new index if one does not exist already + * TODO: Ask the user if they want to override, and delete the old mod's files, or keep the old one. + * */ + static void updateModIndex(const QDir& index_dir, Mod& mod); + + /* Deletes the metadata for the mod with the given slug. If the metadata doesn't exist, it does nothing. */ + static void deleteModIndex(const QDir& index_dir, QString& mod_slug); + + /* Gets the metadata for a mod with a particular file name. + * If the mod doesn't have a metadata, it simply returns an empty Mod object. + * */ + static auto getIndexForMod(const QDir& index_dir, QString slug) -> Mod; + + /* Gets the metadata for a mod with a particular id. + * If the mod doesn't have a metadata, it simply returns an empty Mod object. + * */ + static auto getIndexForMod(const QDir& index_dir, QVariant& mod_id) -> Mod; +}; + +} // namespace Packwiz diff --git a/launcher/modplatform/technic/SingleZipPackInstallTask.cpp b/launcher/modplatform/technic/SingleZipPackInstallTask.cpp new file mode 100644 index 0000000..c40213b --- /dev/null +++ b/launcher/modplatform/technic/SingleZipPackInstallTask.cpp @@ -0,0 +1,132 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "SingleZipPackInstallTask.h" + +#include + +#include "FileSystem.h" +#include "MMCZip.h" +#include "TechnicPackProcessor.h" + +#include "Application.h" + +#include "net/ApiDownload.h" + +Technic::SingleZipPackInstallTask::SingleZipPackInstallTask(const QUrl& sourceUrl, const QString& minecraftVersion) +{ + m_sourceUrl = sourceUrl; + m_minecraftVersion = minecraftVersion; +} + +bool Technic::SingleZipPackInstallTask::abort() +{ + if (m_abortable) { + return m_filesNetJob->abort(); + } + return false; +} + +void Technic::SingleZipPackInstallTask::executeTask() +{ + setStatus(tr("Downloading modpack:\n%1").arg(m_sourceUrl.toString())); + + const QString path = m_sourceUrl.host() + '/' + m_sourceUrl.path(); + auto entry = APPLICATION->metacache()->resolveEntry("general", path); + entry->setStale(true); + m_filesNetJob.reset(new NetJob(tr("Modpack download"), APPLICATION->network())); + m_filesNetJob->addNetAction(Net::ApiDownload::makeCached(m_sourceUrl, entry)); + m_archivePath = entry->getFullPath(); + auto job = m_filesNetJob.get(); + connect(job, &NetJob::succeeded, this, &Technic::SingleZipPackInstallTask::downloadSucceeded); + connect(job, &NetJob::progress, this, &Technic::SingleZipPackInstallTask::downloadProgressChanged); + connect(job, &NetJob::stepProgress, this, &Technic::SingleZipPackInstallTask::propagateStepProgress); + connect(job, &NetJob::failed, this, &Technic::SingleZipPackInstallTask::downloadFailed); + m_filesNetJob->start(); +} + +void Technic::SingleZipPackInstallTask::downloadSucceeded() +{ + m_abortable = false; + + setStatus(tr("Extracting modpack")); + QDir extractDir(FS::PathCombine(m_stagingPath, "minecraft")); + qDebug() << "Attempting to create instance from" << m_archivePath; + + // open the zip and find relevant files in it + m_packZip.reset(new MMCZip::ArchiveReader(m_archivePath)); + m_extractFuture = + QtConcurrent::run(QThreadPool::globalInstance(), MMCZip::extractSubDir, m_packZip.get(), QString(""), extractDir.absolutePath()); + connect(&m_extractFutureWatcher, &QFutureWatcher::finished, this, &Technic::SingleZipPackInstallTask::extractFinished); + connect(&m_extractFutureWatcher, &QFutureWatcher::canceled, this, &Technic::SingleZipPackInstallTask::extractAborted); + m_extractFutureWatcher.setFuture(m_extractFuture); + m_filesNetJob.reset(); +} + +void Technic::SingleZipPackInstallTask::downloadFailed(QString reason) +{ + m_abortable = false; + m_filesNetJob.reset(); + emitFailed(reason); +} + +void Technic::SingleZipPackInstallTask::downloadProgressChanged(qint64 current, qint64 total) +{ + m_abortable = true; + setProgress(current / 2, total); +} + +void Technic::SingleZipPackInstallTask::extractFinished() +{ + m_packZip.reset(); + if (!m_extractFuture.result()) { + emitFailed(tr("Failed to extract modpack")); + return; + } + QDir extractDir(m_stagingPath); + + qDebug() << "Fixing permissions for extracted pack files..."; + QDirIterator it(extractDir, QDirIterator::Subdirectories); + while (it.hasNext()) { + auto filepath = it.next(); + QFileInfo file(filepath); + auto permissions = QFile::permissions(filepath); + auto origPermissions = permissions; + if (file.isDir()) { + // Folder +rwx for current user + permissions |= QFileDevice::Permission::ReadUser | QFileDevice::Permission::WriteUser | QFileDevice::Permission::ExeUser; + } else { + // File +rw for current user + permissions |= QFileDevice::Permission::ReadUser | QFileDevice::Permission::WriteUser; + } + if (origPermissions != permissions) { + if (!QFile::setPermissions(filepath, permissions)) { + logWarning(tr("Could not fix permissions for %1").arg(filepath)); + } else { + qDebug() << "Fixed" << filepath; + } + } + } + + auto packProcessor = makeShared(); + connect(packProcessor.get(), &Technic::TechnicPackProcessor::succeeded, this, &Technic::SingleZipPackInstallTask::emitSucceeded); + connect(packProcessor.get(), &Technic::TechnicPackProcessor::failed, this, &Technic::SingleZipPackInstallTask::emitFailed); + packProcessor->run(m_globalSettings, name(), m_instIcon, m_stagingPath, m_minecraftVersion); +} + +void Technic::SingleZipPackInstallTask::extractAborted() +{ + emitFailed(tr("Instance import has been aborted.")); +} diff --git a/launcher/modplatform/technic/SingleZipPackInstallTask.h b/launcher/modplatform/technic/SingleZipPackInstallTask.h new file mode 100644 index 0000000..9dd5445 --- /dev/null +++ b/launcher/modplatform/technic/SingleZipPackInstallTask.h @@ -0,0 +1,61 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "InstanceTask.h" +#include "archive/ArchiveReader.h" +#include "net/NetJob.h" + +#include +#include +#include + +#include + +namespace Technic { + +class SingleZipPackInstallTask : public InstanceTask { + Q_OBJECT + + public: + SingleZipPackInstallTask(const QUrl& sourceUrl, const QString& minecraftVersion); + + bool canAbort() const override { return true; } + bool abort() override; + + protected: + void executeTask() override; + + private slots: + void downloadSucceeded(); + void downloadFailed(QString reason); + void downloadProgressChanged(qint64 current, qint64 total); + void extractFinished(); + void extractAborted(); + + private: + bool m_abortable = false; + + QUrl m_sourceUrl; + QString m_minecraftVersion; + QString m_archivePath; + NetJob::Ptr m_filesNetJob; + std::unique_ptr m_packZip; + QFuture> m_extractFuture; + QFutureWatcher> m_extractFutureWatcher; +}; + +} // namespace Technic diff --git a/launcher/modplatform/technic/SolderPackInstallTask.cpp b/launcher/modplatform/technic/SolderPackInstallTask.cpp new file mode 100644 index 0000000..e7f4b7a --- /dev/null +++ b/launcher/modplatform/technic/SolderPackInstallTask.cpp @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2021-2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "SolderPackInstallTask.h" + +#include +#include +#include +#include + +#include "SolderPackManifest.h" +#include "TechnicPackProcessor.h" +#include "net/ApiDownload.h" +#include "net/ChecksumValidator.h" + +Technic::SolderPackInstallTask::SolderPackInstallTask(QNetworkAccessManager* network, + const QUrl& solderUrl, + const QString& pack, + const QString& version, + const QString& minecraftVersion) +{ + m_solderUrl = solderUrl; + m_pack = pack; + m_version = version; + m_network = network; + m_minecraftVersion = minecraftVersion; +} + +bool Technic::SolderPackInstallTask::abort() +{ + if (m_abortable) { + return m_filesNetJob->abort(); + } + return false; +} + +void Technic::SolderPackInstallTask::executeTask() +{ + setStatus(tr("Resolving modpack files")); + + m_filesNetJob.reset(new NetJob(tr("Resolving modpack files"), m_network)); + auto sourceUrl = QString("%1/modpack/%2/%3").arg(m_solderUrl.toString(), m_pack, m_version); + auto [action, response] = Net::ApiDownload::makeByteArray(sourceUrl); + m_filesNetJob->addNetAction(action); + + auto job = m_filesNetJob.get(); + connect(job, &NetJob::succeeded, this, [this, response] { fileListSucceeded(response); }); + connect(job, &NetJob::failed, this, &Technic::SolderPackInstallTask::downloadFailed); + connect(job, &NetJob::aborted, this, &Technic::SolderPackInstallTask::downloadAborted); + m_filesNetJob->start(); +} + +void Technic::SolderPackInstallTask::fileListSucceeded(QByteArray* response) +{ + setStatus(tr("Downloading modpack")); + + QJsonParseError parse_error{}; + QJsonDocument doc = QJsonDocument::fromJson(*response, &parse_error); + if (parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from Solder at" << parse_error.offset << "reason:" << parse_error.errorString(); + qWarning() << *response; + return; + } + auto obj = doc.object(); + + TechnicSolder::PackBuild build; + try { + TechnicSolder::loadPackBuild(build, obj); + } catch (const JSONValidationError& e) { + m_filesNetJob.reset(); + emitFailed(tr("Could not understand pack manifest:\n") + e.cause()); + return; + } + + if (!build.minecraft.isEmpty()) + m_minecraftVersion = build.minecraft; + + m_filesNetJob.reset(new NetJob(tr("Downloading modpack"), m_network)); + + int i = 0; + for (const auto& mod : build.mods) { + auto path = FS::PathCombine(m_outputDir.path(), QString("%1").arg(i)); + + auto dl = Net::ApiDownload::makeFile(mod.url, path); + if (!mod.md5.isEmpty()) { + dl->addValidator(new Net::ChecksumValidator(QCryptographicHash::Md5, mod.md5)); + } + m_filesNetJob->addNetAction(dl); + + i++; + } + + m_modCount = build.mods.size(); + + connect(m_filesNetJob.get(), &NetJob::succeeded, this, &Technic::SolderPackInstallTask::downloadSucceeded); + connect(m_filesNetJob.get(), &NetJob::progress, this, &Technic::SolderPackInstallTask::downloadProgressChanged); + connect(m_filesNetJob.get(), &NetJob::stepProgress, this, &Technic::SolderPackInstallTask::propagateStepProgress); + connect(m_filesNetJob.get(), &NetJob::failed, this, &Technic::SolderPackInstallTask::downloadFailed); + connect(m_filesNetJob.get(), &NetJob::aborted, this, &Technic::SolderPackInstallTask::downloadAborted); + m_filesNetJob->start(); +} + +void Technic::SolderPackInstallTask::downloadSucceeded() +{ + m_abortable = false; + + setStatus(tr("Extracting modpack")); + m_filesNetJob.reset(); + m_extractFuture = QtConcurrent::run([this]() { + int i = 0; + QString extractDir = FS::PathCombine(m_stagingPath, "minecraft"); + FS::ensureFolderPathExists(extractDir); + + while (m_modCount > i) { + auto path = FS::PathCombine(m_outputDir.path(), QString("%1").arg(i)); + if (!MMCZip::extractDir(path, extractDir)) { + return false; + } + i++; + } + return true; + }); + connect(&m_extractFutureWatcher, &QFutureWatcher::finished, this, &Technic::SolderPackInstallTask::extractFinished); + connect(&m_extractFutureWatcher, &QFutureWatcher::canceled, this, &Technic::SolderPackInstallTask::extractAborted); + m_extractFutureWatcher.setFuture(m_extractFuture); +} + +void Technic::SolderPackInstallTask::downloadFailed(QString reason) +{ + m_abortable = false; + m_filesNetJob.reset(); + emitFailed(reason); +} + +void Technic::SolderPackInstallTask::downloadProgressChanged(qint64 current, qint64 total) +{ + m_abortable = true; + setProgress(current / 2, total); +} + +void Technic::SolderPackInstallTask::downloadAborted() +{ + m_filesNetJob.reset(); + emitAborted(); +} + +void Technic::SolderPackInstallTask::extractFinished() +{ + if (!m_extractFuture.result()) { + emitFailed(tr("Failed to extract modpack")); + return; + } + QDir extractDir(m_stagingPath); + + qDebug() << "Fixing permissions for extracted pack files..."; + QDirIterator it(extractDir, QDirIterator::Subdirectories); + while (it.hasNext()) { + auto filepath = it.next(); + QFileInfo file(filepath); + auto permissions = QFile::permissions(filepath); + auto origPermissions = permissions; + if (file.isDir()) { + // Folder +rwx for current user + permissions |= QFileDevice::Permission::ReadUser | QFileDevice::Permission::WriteUser | QFileDevice::Permission::ExeUser; + } else { + // File +rw for current user + permissions |= QFileDevice::Permission::ReadUser | QFileDevice::Permission::WriteUser; + } + if (origPermissions != permissions) { + if (!QFile::setPermissions(filepath, permissions)) { + logWarning(tr("Could not fix permissions for %1").arg(filepath)); + } else { + qDebug() << "Fixed" << filepath; + } + } + } + + auto packProcessor = makeShared(); + connect(packProcessor.get(), &Technic::TechnicPackProcessor::succeeded, this, &Technic::SolderPackInstallTask::emitSucceeded); + connect(packProcessor.get(), &Technic::TechnicPackProcessor::failed, this, &Technic::SolderPackInstallTask::emitFailed); + packProcessor->run(m_globalSettings, name(), m_instIcon, m_stagingPath, m_minecraftVersion, true); +} + +void Technic::SolderPackInstallTask::extractAborted() +{ + emitFailed(tr("Instance import has been aborted.")); +} diff --git a/launcher/modplatform/technic/SolderPackInstallTask.h b/launcher/modplatform/technic/SolderPackInstallTask.h new file mode 100644 index 0000000..07cce46 --- /dev/null +++ b/launcher/modplatform/technic/SolderPackInstallTask.h @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2021-2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +#include + +namespace Technic { +class SolderPackInstallTask : public InstanceTask { + Q_OBJECT + public: + explicit SolderPackInstallTask(QNetworkAccessManager* network, + const QUrl& solderUrl, + const QString& pack, + const QString& version, + const QString& minecraftVersion); + + bool canAbort() const override { return true; } + bool abort() override; + + protected: + //! Entry point for tasks. + virtual void executeTask() override; + + private slots: + void fileListSucceeded(QByteArray* response); + void downloadSucceeded(); + void downloadFailed(QString reason); + void downloadProgressChanged(qint64 current, qint64 total); + void downloadAborted(); + void extractFinished(); + void extractAborted(); + + private: + bool m_abortable = false; + + QNetworkAccessManager* m_network; + + NetJob::Ptr m_filesNetJob; + QUrl m_solderUrl; + QString m_pack; + QString m_version; + QString m_minecraftVersion; + QTemporaryDir m_outputDir; + int m_modCount; + QFuture m_extractFuture; + QFutureWatcher m_extractFutureWatcher; +}; +} // namespace Technic diff --git a/launcher/modplatform/technic/SolderPackManifest.cpp b/launcher/modplatform/technic/SolderPackManifest.cpp new file mode 100644 index 0000000..4b9701e --- /dev/null +++ b/launcher/modplatform/technic/SolderPackManifest.cpp @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "SolderPackManifest.h" + +#include "Json.h" + +namespace TechnicSolder { + +void loadPack(Pack& v, QJsonObject& obj) +{ + v.recommended = Json::requireString(obj, "recommended"); + v.latest = Json::requireString(obj, "latest"); + + auto builds = Json::requireArray(obj, "builds"); + for (const auto buildRaw : builds) { + auto build = Json::requireString(buildRaw); + v.builds.append(build); + } +} + +static void loadPackBuildMod(PackBuildMod& b, QJsonObject& obj) +{ + b.name = Json::requireString(obj, "name"); + b.version = obj["version"].toString(""); + b.md5 = Json::requireString(obj, "md5"); + b.url = Json::requireString(obj, "url"); +} + +void loadPackBuild(PackBuild& v, QJsonObject& obj) +{ + v.minecraft = Json::requireString(obj, "minecraft"); + + auto mods = Json::requireArray(obj, "mods"); + for (const auto modRaw : mods) { + auto modObj = Json::requireObject(modRaw); + PackBuildMod mod; + loadPackBuildMod(mod, modObj); + v.mods.append(mod); + } +} + +} // namespace TechnicSolder diff --git a/launcher/modplatform/technic/SolderPackManifest.h b/launcher/modplatform/technic/SolderPackManifest.h new file mode 100644 index 0000000..3a59475 --- /dev/null +++ b/launcher/modplatform/technic/SolderPackManifest.h @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include +#include + +namespace TechnicSolder { + +struct Pack { + QString recommended; + QString latest; + QList builds; +}; + +void loadPack(Pack& v, QJsonObject& obj); + +struct PackBuildMod { + QString name; + QString version; + QString md5; + QString url; +}; + +struct PackBuild { + QString minecraft; + QList mods; +}; + +void loadPackBuild(PackBuild& v, QJsonObject& obj); + +} // namespace TechnicSolder diff --git a/launcher/modplatform/technic/TechnicPackProcessor.cpp b/launcher/modplatform/technic/TechnicPackProcessor.cpp new file mode 100644 index 0000000..858f4ae --- /dev/null +++ b/launcher/modplatform/technic/TechnicPackProcessor.cpp @@ -0,0 +1,201 @@ +/* Copyright 2020-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "TechnicPackProcessor.h" + +#include +#include +#include +#include +#include + +#include +#include "archive/ArchiveReader.h" + +void Technic::TechnicPackProcessor::run(SettingsObject* globalSettings, + const QString& instName, + const QString& instIcon, + const QString& stagingPath, + const QString& minecraftVersion, + [[maybe_unused]] const bool isSolder) +{ + QString minecraftPath = FS::PathCombine(stagingPath, "minecraft"); + QString configPath = FS::PathCombine(stagingPath, "instance.cfg"); + auto instanceSettings = std::make_unique(configPath); + MinecraftInstance instance(globalSettings, std::move(instanceSettings), stagingPath); + + instance.setName(instName); + + if (instIcon != "default") { + instance.setIconKey(instIcon); + } + + auto components = instance.getPackProfile(); + components->buildingFromScratch(); + + QByteArray data; + + QString modpackJar = FS::PathCombine(minecraftPath, "bin", "modpack.jar"); + QString versionJson = FS::PathCombine(minecraftPath, "bin", "version.json"); + QString fmlMinecraftVersion; + if (QFile::exists(modpackJar)) { + MMCZip::ArchiveReader zipFile(modpackJar); + if (!zipFile.collectFiles()) { + emit failed(tr("Unable to open \"bin/modpack.jar\" file!")); + return; + } + if (zipFile.exists("/version.json")) { + if (zipFile.exists("/fmlversion.properties")) { + auto file = zipFile.goToFile("fmlversion.properties"); + if (!file) { + emit failed(tr("Unable to open \"fmlversion.properties\"!")); + return; + } + QByteArray fmlVersionData = file->readAll(); + INIFile iniFile; + iniFile.loadFile(fmlVersionData); + // If not present, this evaluates to a null string + fmlMinecraftVersion = iniFile["fmlbuild.mcversion"].toString(); + } + auto file = zipFile.goToFile("version.json"); + if (!file) { + emit failed(tr("Unable to open \"version.json\"!")); + return; + } + data = file->readAll(); + } else { + if (minecraftVersion.isEmpty()) { + emit failed(tr("Could not find \"version.json\" inside \"bin/modpack.jar\", but Minecraft version is unknown")); + return; + } + components->setComponentVersion("net.minecraft", minecraftVersion, true); + components->installJarMods({ modpackJar }); + + // Forge for 1.4.7 and for 1.5.2 require extra libraries. + // Figure out the forge version and add it as a component + // (the code still comes from the jar mod installed above) + if (zipFile.exists("/forgeversion.properties")) { + auto file = zipFile.goToFile("forgeversion.properties"); + if (!file) { + // Really shouldn't happen, but error handling shall not be forgotten + emit failed(tr("Unable to open \"forgeversion.properties\"")); + return; + } + auto forgeVersionData = file->readAll(); + INIFile iniFile; + iniFile.loadFile(forgeVersionData); + QString major, minor, revision, build; + major = iniFile["forge.major.number"].toString(); + minor = iniFile["forge.minor.number"].toString(); + revision = iniFile["forge.revision.number"].toString(); + build = iniFile["forge.build.number"].toString(); + + if (major.isEmpty() || minor.isEmpty() || revision.isEmpty() || build.isEmpty()) { + emit failed(tr("Invalid \"forgeversion.properties\"!")); + return; + } + + components->setComponentVersion("net.minecraftforge", major + '.' + minor + '.' + revision + '.' + build); + } + + components->saveNow(); + emit succeeded(); + return; + } + } else if (QFile::exists(versionJson)) { + QFile file(versionJson); + if (!file.open(QIODevice::ReadOnly)) { + emit failed(tr("Unable to open \"version.json\": %1").arg(file.errorString())); + return; + } + data = file.readAll(); + file.close(); + } else { + // This is the "Vanilla" modpack, excluded by the search code + components->setComponentVersion("net.minecraft", minecraftVersion, true); + components->saveNow(); + emit succeeded(); + return; + } + + try { + QJsonDocument doc = Json::requireDocument(data); + QJsonObject root = Json::requireObject(doc, "version.json"); + QString packMinecraftVersion = root["inheritsFrom"].toString(); + if (packMinecraftVersion.isEmpty()) { + if (fmlMinecraftVersion.isEmpty()) { + emit failed(tr("Could not understand \"version.json\":\ninheritsFrom is missing")); + return; + } + packMinecraftVersion = fmlMinecraftVersion; + } + components->setComponentVersion("net.minecraft", packMinecraftVersion, true); + for (auto library : root["libraries"].toArray()) { + if (!library.isObject()) { + continue; + } + + auto libraryObject = library.toObject(); + auto libraryName = libraryObject["name"].toString(); + + if (libraryName.startsWith("net.neoforged.fancymodloader:")) { // it is neoforge + // no easy way to get the version from the libs so use the arguments + auto arguments = root["arguments"].toObject(); + bool isVersionArg = false; + QString neoforgeVersion; + for (auto arg : arguments["game"].toArray()) { + auto argument = arg.toString(""); + if (isVersionArg) { + neoforgeVersion = argument; + break; + } else { + isVersionArg = "--fml.neoForgeVersion" == argument || "--fml.forgeVersion" == argument; + } + } + if (!neoforgeVersion.isEmpty()) { + components->setComponentVersion("net.neoforged", neoforgeVersion); + } + break; + } else if ((libraryName.startsWith("net.minecraftforge:forge:") || libraryName.startsWith("net.minecraftforge:fmlloader:")) && + libraryName.contains('-')) { + QString libraryVersion = libraryName.section(':', 2); + if (!libraryVersion.startsWith("1.7.10-")) { + components->setComponentVersion("net.minecraftforge", libraryName.section('-', 1)); + } else { + // 1.7.10 versions sometimes look like 1.7.10-10.13.4.1614-1.7.10, this filters out the 10.13.4.1614 part + components->setComponentVersion("net.minecraftforge", libraryName.section('-', 1, 1)); + } + break; + } else { + // -> + static QMap loaderMap{ { "net.minecraftforge:minecraftforge:", "net.minecraftforge" }, + { "net.fabricmc:fabric-loader:", "net.fabricmc.fabric-loader" }, + { "org.quiltmc:quilt-loader:", "org.quiltmc.quilt-loader" } }; + for (const auto& loader : loaderMap.keys()) { + if (libraryName.startsWith(loader)) { + components->setComponentVersion(loaderMap.value(loader), libraryName.section(':', 2)); + break; + } + } + } + } + } catch (const JSONValidationError& e) { + emit failed(tr("Could not understand \"version.json\":\n") + e.cause()); + return; + } + + components->saveNow(); + emit succeeded(); +} diff --git a/launcher/modplatform/technic/TechnicPackProcessor.h b/launcher/modplatform/technic/TechnicPackProcessor.h new file mode 100644 index 0000000..0d2dabc --- /dev/null +++ b/launcher/modplatform/technic/TechnicPackProcessor.h @@ -0,0 +1,38 @@ +/* Copyright 2020-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include "settings/SettingsObject.h" + +namespace Technic { +// not exporting it, only used in SingleZipPackInstallTask, InstanceImportTask and SolderPackInstallTask +class TechnicPackProcessor : public QObject { + Q_OBJECT + + signals: + void succeeded(); + void failed(QString reason); + + public: + void run(SettingsObject* globalSettings, + const QString& instName, + const QString& instIcon, + const QString& stagingPath, + const QString& minecraftVersion = QString(), + bool isSolder = false); +}; +} // namespace Technic diff --git a/launcher/net/ApiDownload.cpp b/launcher/net/ApiDownload.cpp new file mode 100644 index 0000000..9a5a441 --- /dev/null +++ b/launcher/net/ApiDownload.cpp @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "net/ApiDownload.h" +#include "net/ApiHeaderProxy.h" + +namespace Net { + +Download::Ptr ApiDownload::makeCached(QUrl url, MetaEntryPtr entry, Download::Options options) +{ + auto dl = Download::makeCached(url, entry, options); + dl->addHeaderProxy(std::make_unique()); + return dl; +} + +std::pair ApiDownload::makeByteArray(QUrl url, Download::Options options) +{ + auto [dl, response] = Download::makeByteArray(url, options); + dl->addHeaderProxy(std::make_unique()); + return { dl, response }; +} + +Download::Ptr ApiDownload::makeFile(QUrl url, QString path, Download::Options options) +{ + auto dl = Download::makeFile(url, path, options); + dl->addHeaderProxy(std::make_unique()); + return dl; +} + +} // namespace Net diff --git a/launcher/net/ApiDownload.h b/launcher/net/ApiDownload.h new file mode 100644 index 0000000..01a31eb --- /dev/null +++ b/launcher/net/ApiDownload.h @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#pragma once + +#include "Download.h" + +namespace Net { + +namespace ApiDownload { +Download::Ptr makeCached(QUrl url, MetaEntryPtr entry, Download::Options options = Download::Option::NoOptions); +std::pair makeByteArray(QUrl url, Download::Options options = Download::Option::NoOptions); +Download::Ptr makeFile(QUrl url, QString path, Download::Options options = Download::Option::NoOptions); +}; // namespace ApiDownload + +} // namespace Net diff --git a/launcher/net/ApiHeaderProxy.h b/launcher/net/ApiHeaderProxy.h new file mode 100644 index 0000000..789a6fa --- /dev/null +++ b/launcher/net/ApiHeaderProxy.h @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#pragma once + +#include "Application.h" +#include "BuildConfig.h" +#include "net/HeaderProxy.h" + +namespace Net { + +class ApiHeaderProxy : public HeaderProxy { + public: + ApiHeaderProxy() : HeaderProxy() {} + virtual ~ApiHeaderProxy() = default; + + public: + virtual QList headers(const QNetworkRequest& request) const override + { + QList hdrs; + if (APPLICATION->capabilities() & Application::SupportsFlame && request.url().host() == QUrl(BuildConfig.FLAME_BASE_URL).host()) { + hdrs.append({ "x-api-key", APPLICATION->getFlameAPIKey().toUtf8() }); + } else if (request.url().host() == QUrl(BuildConfig.MODRINTH_PROD_URL).host() || + request.url().host() == QUrl(BuildConfig.MODRINTH_STAGING_URL).host()) { + QString token = APPLICATION->getModrinthAPIToken(); + if (!token.isNull()) + hdrs.append({ "Authorization", token.toUtf8() }); + } + return hdrs; + }; +}; + +} // namespace Net diff --git a/launcher/net/ApiUpload.cpp b/launcher/net/ApiUpload.cpp new file mode 100644 index 0000000..2615581 --- /dev/null +++ b/launcher/net/ApiUpload.cpp @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "net/ApiUpload.h" +#include "net/ApiHeaderProxy.h" + +namespace Net { + +std::pair ApiUpload::makeByteArray(QUrl url, QByteArray m_post_data) +{ + auto [up, response] = Upload::makeByteArray(url, m_post_data); + up->addHeaderProxy(std::make_unique()); + return { up, response }; +} + +} // namespace Net diff --git a/launcher/net/ApiUpload.h b/launcher/net/ApiUpload.h new file mode 100644 index 0000000..3aa4ade --- /dev/null +++ b/launcher/net/ApiUpload.h @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#pragma once + +#include "Upload.h" + +namespace Net { + +namespace ApiUpload { +std::pair makeByteArray(QUrl url, QByteArray m_post_data); +}; + +} // namespace Net diff --git a/launcher/net/ByteArraySink.h b/launcher/net/ByteArraySink.h new file mode 100644 index 0000000..b03d719 --- /dev/null +++ b/launcher/net/ByteArraySink.h @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "Sink.h" + +namespace Net { + +/* + * Sink object for downloads that uses an owned QByteArray as a target. + */ +class ByteArraySink : public Sink { + public: + virtual ~ByteArraySink() = default; + + public: + auto init(QNetworkRequest& request) -> Task::State override + { + m_output.clear(); + if (initAllValidators(request)) + return Task::State::Running; + m_fail_reason = "Failed to initialize validators"; + return Task::State::Failed; + }; + + auto write(QByteArray& data) -> Task::State override + { + m_output.append(data); + if (writeAllValidators(data)) + return Task::State::Running; + m_fail_reason = "Failed to write validators"; + return Task::State::Failed; + } + + auto abort() -> Task::State override + { + failAllValidators(); + m_fail_reason = "Aborted"; + return Task::State::Failed; + } + + auto finalize(QNetworkReply& reply) -> Task::State override + { + if (finalizeAllValidators(reply)) + return Task::State::Succeeded; + m_fail_reason = "Failed to finalize validators"; + return Task::State::Failed; + } + + auto hasLocalData() -> bool override { return false; } + + QByteArray* output() { return &m_output; } + + protected: + QByteArray m_output; +}; +} // namespace Net diff --git a/launcher/net/ChecksumValidator.h b/launcher/net/ChecksumValidator.h new file mode 100644 index 0000000..7663d5d --- /dev/null +++ b/launcher/net/ChecksumValidator.h @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "Validator.h" + +#include +#include + +namespace Net { +class ChecksumValidator : public Validator { + public: + ChecksumValidator(QCryptographicHash::Algorithm algorithm, QString expectedHex) + : Net::ChecksumValidator(algorithm, QByteArray::fromHex(expectedHex.toLatin1())) + {} + ChecksumValidator(QCryptographicHash::Algorithm algorithm, QByteArray expected = QByteArray()) + : m_checksum(algorithm), m_expected(expected) {}; + virtual ~ChecksumValidator() = default; + + public: + auto init(QNetworkRequest&) -> bool override + { + m_checksum.reset(); + return true; + } + + auto write(QByteArray& data) -> bool override + { + m_checksum.addData(data); + return true; + } + + auto abort() -> bool override + { + m_checksum.reset(); + return true; + } + + auto validate(QNetworkReply&) -> bool override + { + if (m_expected.size() && m_expected != hash()) { + qWarning() << "Checksum mismatch, download is bad."; + return false; + } + return true; + } + + auto hash() -> QByteArray { return m_checksum.result(); } + + void setExpected(QByteArray expected) { m_expected = expected; } + + private: + QCryptographicHash m_checksum; + QByteArray m_expected; +}; +} // namespace Net diff --git a/launcher/net/Download.cpp b/launcher/net/Download.cpp new file mode 100644 index 0000000..9a22c87 --- /dev/null +++ b/launcher/net/Download.cpp @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2023 TheKodeToad + * Copyright (C) 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "Download.h" +#include + +#include +#include +#include + +#include "ByteArraySink.h" +#include "ChecksumValidator.h" +#include "MetaCacheSink.h" + +namespace Net { + +#if defined(LAUNCHER_APPLICATION) +auto Download::makeCached(QUrl url, MetaEntryPtr entry, Options options) -> Download::Ptr +{ + auto dl = makeShared(); + dl->m_url = url; + dl->setObjectName(QString("CACHE:") + url.toString()); + dl->m_options = options; + auto md5Node = new ChecksumValidator(QCryptographicHash::Md5); + auto cachedNode = new MetaCacheSink(entry, md5Node, options.testFlag(Option::MakeEternal)); + dl->m_sink.reset(cachedNode); + return dl; +} +#endif + +auto Download::makeByteArray(QUrl url, Options options) -> std::pair +{ + auto dl = makeShared(); + dl->m_url = url; + dl->setObjectName(QString("BYTES:") + url.toString()); + dl->m_options = options; + + auto sink = std::make_unique(); + QByteArray* response = sink->output(); + dl->m_sink = std::move(sink); + + return { dl, response }; +} + +auto Download::makeFile(QUrl url, QString path, Options options) -> Download::Ptr +{ + auto dl = makeShared(); + dl->m_url = url; + dl->setObjectName(QString("FILE:") + url.toString()); + dl->m_options = options; + dl->m_sink.reset(new FileSink(path)); + return dl; +} + +QNetworkReply* Download::getReply(QNetworkRequest& request) +{ + return m_network->get(request); +} +} // namespace Net diff --git a/launcher/net/Download.h b/launcher/net/Download.h new file mode 100644 index 0000000..60a5b5b --- /dev/null +++ b/launcher/net/Download.h @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +#include "HttpMetaCache.h" + +#include "QObjectPtr.h" +#include "net/NetRequest.h" + +namespace Net { +class ByteArraySink; + +class Download : public NetRequest { + Q_OBJECT + public: + using Ptr = shared_qobject_ptr; + explicit Download() : NetRequest() { logCat = taskDownloadLogC; } + +#if defined(LAUNCHER_APPLICATION) + static auto makeCached(QUrl url, MetaEntryPtr entry, Options options = Option::NoOptions) -> Download::Ptr; +#endif + + /** + * Creates a request downloading to the returned QByteArray,. + * The QByteArray will live as long as the Download object. + */ + static auto makeByteArray(QUrl url, Options options = Option::NoOptions) -> std::pair; + static auto makeFile(QUrl url, QString path, Options options = Option::NoOptions) -> Download::Ptr; + + protected: + virtual QNetworkReply* getReply(QNetworkRequest&) override; +}; +} // namespace Net diff --git a/launcher/net/DummySink.h b/launcher/net/DummySink.h new file mode 100644 index 0000000..fa540fd --- /dev/null +++ b/launcher/net/DummySink.h @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2025 Octol1ttle + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +namespace Net { + +class DummySink : public Sink { + public: + explicit DummySink() {} + ~DummySink() override {} + auto init(QNetworkRequest& request) -> Task::State override { return Task::State::Running; } + auto write(QByteArray& data) -> Task::State override { return Task::State::Succeeded; } + auto abort() -> Task::State override { return Task::State::AbortedByUser; } + auto finalize(QNetworkReply& reply) -> Task::State override { return Task::State::Succeeded; } + auto hasLocalData() -> bool override { return false; } +}; + +} // namespace Net diff --git a/launcher/net/FileSink.cpp b/launcher/net/FileSink.cpp new file mode 100644 index 0000000..47838f6 --- /dev/null +++ b/launcher/net/FileSink.cpp @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "FileSink.h" + +#include "FileSystem.h" + +#include "net/Logging.h" + +namespace Net { + +Task::State FileSink::init(QNetworkRequest& request) +{ + auto result = initCache(request); + if (result != Task::State::Running) { + return result; + } + + // create a new save file and open it for writing + if (!FS::ensureFilePathExists(m_filename)) { + qCCritical(taskNetLogC) << "Could not create folder for " + m_filename; + m_fail_reason = "Could not create folder"; + return Task::State::Failed; + } + + m_wroteAnyData = false; + m_output_file.reset(new PSaveFile(m_filename)); + if (!m_output_file->open(QIODevice::WriteOnly)) { + const auto error = QString("Could not open %1 for writing: %2").arg(m_filename).arg(m_output_file->errorString()); + qCCritical(taskNetLogC) << error; + m_fail_reason = error; + return Task::State::Failed; + } + + if (initAllValidators(request)) + return Task::State::Running; + m_fail_reason = "Failed to initialize validators"; + return Task::State::Failed; +} + +Task::State FileSink::write(QByteArray& data) +{ + if (!writeAllValidators(data) || m_output_file->write(data) != data.size()) { + QString error = QString("Failed writing into %1: %2").arg(m_filename); + if (m_output_file->error() == QFileDevice::NoError) { + error = error.arg("Validators failed"); + } else { + error = error.arg(m_output_file->errorString()); + } + qCCritical(taskNetLogC) << error; + m_fail_reason = error; + m_output_file->cancelWriting(); + m_output_file.reset(); + m_wroteAnyData = false; + return Task::State::Failed; + } + + m_wroteAnyData = true; + return Task::State::Running; +} + +Task::State FileSink::abort() +{ + if (m_output_file) { + m_output_file->cancelWriting(); + } + failAllValidators(); + return Task::State::Failed; +} + +Task::State FileSink::finalize(QNetworkReply& reply) +{ + bool gotFile = false; + QVariant statusCodeV = reply.attribute(QNetworkRequest::HttpStatusCodeAttribute); + bool validStatus = false; + int statusCode = statusCodeV.toInt(&validStatus); + if (validStatus) { + // this leaves out 304 Not Modified + gotFile = statusCode == 200 || statusCode == 203; + } + + // if we wrote any data to the save file, we try to commit the data to the real file. + // if it actually got a proper file, we write it even if it was empty + if (gotFile || m_wroteAnyData) { + // ask validators for data consistency + // we only do this for actual downloads, not 'your data is still the same' cache hits + if (!finalizeAllValidators(reply)) { + m_fail_reason = "Failed to finalize validators"; + return Task::State::Failed; + } + + // nothing went wrong... + if (!m_output_file->commit()) { + const auto error = QString("Failed to commit changes to %1: %2").arg(m_filename).arg(m_output_file->errorString()); + qCCritical(taskNetLogC) << error; + m_fail_reason = error; + m_output_file->cancelWriting(); + return Task::State::Failed; + } + } + + // then get rid of the save file + m_output_file.reset(); + + return finalizeCache(reply); +} + +Task::State FileSink::initCache(QNetworkRequest&) +{ + return Task::State::Running; +} + +Task::State FileSink::finalizeCache(QNetworkReply&) +{ + return Task::State::Succeeded; +} + +bool FileSink::hasLocalData() +{ + QFileInfo info(m_filename); + return info.exists() && info.size() != 0; +} +} // namespace Net diff --git a/launcher/net/FileSink.h b/launcher/net/FileSink.h new file mode 100644 index 0000000..67c2536 --- /dev/null +++ b/launcher/net/FileSink.h @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "PSaveFile.h" +#include "Sink.h" + +namespace Net { +class FileSink : public Sink { + public: + FileSink(QString filename) : m_filename(filename) {}; + virtual ~FileSink() = default; + + public: + auto init(QNetworkRequest& request) -> Task::State override; + auto write(QByteArray& data) -> Task::State override; + auto abort() -> Task::State override; + auto finalize(QNetworkReply& reply) -> Task::State override; + + auto hasLocalData() -> bool override; + + protected: + virtual auto initCache(QNetworkRequest&) -> Task::State; + virtual auto finalizeCache(QNetworkReply& reply) -> Task::State; + + protected: + QString m_filename; + bool m_wroteAnyData = false; + std::unique_ptr m_output_file; +}; +} // namespace Net diff --git a/launcher/net/HeaderProxy.h b/launcher/net/HeaderProxy.h new file mode 100644 index 0000000..2036292 --- /dev/null +++ b/launcher/net/HeaderProxy.h @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#pragma once + +#include +#include + +namespace Net { + +struct HeaderPair { + QByteArray headerName; + QByteArray headerValue; +}; + +class HeaderProxy { + public: + HeaderProxy() {} + virtual ~HeaderProxy() {} + + public: + virtual QList headers(const QNetworkRequest& request) const = 0; + + public: + void writeHeaders(QNetworkRequest& request) + { + for (auto header : headers(request)) { + request.setRawHeader(header.headerName, header.headerValue); + } + } +}; + +} // namespace Net diff --git a/launcher/net/HttpMetaCache.cpp b/launcher/net/HttpMetaCache.cpp new file mode 100644 index 0000000..6b81bfe --- /dev/null +++ b/launcher/net/HttpMetaCache.cpp @@ -0,0 +1,335 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "HttpMetaCache.h" +#include "FileSystem.h" +#include "Json.h" + +#include +#include +#include +#include + +#include + +#include "net/Logging.h" + +auto MetaEntry::getFullPath() -> QString +{ + // FIXME: make local? + return FS::PathCombine(m_basePath, m_relativePath); +} + +HttpMetaCache::HttpMetaCache(QString path) : QObject(), m_index_file(path) +{ + saveBatchingTimer.setSingleShot(true); + saveBatchingTimer.setTimerType(Qt::VeryCoarseTimer); + + connect(&saveBatchingTimer, &QTimer::timeout, this, &HttpMetaCache::SaveNow); +} + +HttpMetaCache::~HttpMetaCache() +{ + saveBatchingTimer.stop(); + SaveNow(); +} + +auto HttpMetaCache::getEntry(QString base, QString resource_path) -> MetaEntryPtr +{ + // no base. no base path. can't store + if (!m_entries.contains(base)) { + // TODO: log problem + return {}; + } + + EntryMap& map = m_entries[base]; + if (map.entry_list.contains(resource_path)) { + return map.entry_list[resource_path]; + } + + return {}; +} + +auto HttpMetaCache::resolveEntry(QString base, QString resource_path, QString expected_etag) -> MetaEntryPtr +{ + resource_path = FS::RemoveInvalidPathChars(resource_path); + auto entry = getEntry(base, resource_path); + // it's not present? generate a default stale entry + if (!entry) { + return staleEntry(base, resource_path); + } + + auto& selected_base = m_entries[base]; + QString real_path = FS::PathCombine(selected_base.base_path, resource_path); + QFileInfo finfo(real_path); + + // is the file really there? if not -> stale + if (!finfo.isFile() || !finfo.isReadable()) { + // if the file doesn't exist, we disown the entry + selected_base.entry_list.remove(resource_path); + return staleEntry(base, resource_path); + } + + if (!expected_etag.isEmpty() && expected_etag != entry->m_etag) { + // if the etag doesn't match expected, we disown the entry + selected_base.entry_list.remove(resource_path); + return staleEntry(base, resource_path); + } + + // if the file changed, check md5sum + qint64 file_last_changed = finfo.lastModified().toUTC().toMSecsSinceEpoch(); + if (file_last_changed != entry->m_local_changed_timestamp) { + QFile input(real_path); + if (!input.open(QIODevice::ReadOnly)) { + qWarning() << "Failed to open file" << input.fileName() << "for reading:" << input.errorString(); + return staleEntry(base, resource_path); + } + QString md5sum = QCryptographicHash::hash(input.readAll(), QCryptographicHash::Md5).toHex().constData(); + if (entry->m_md5sum != md5sum) { + selected_base.entry_list.remove(resource_path); + return staleEntry(base, resource_path); + } + + // md5sums matched... keep entry and save the new state to file + entry->m_local_changed_timestamp = file_last_changed; + SaveEventually(); + } + + // Get rid of old entries, to prevent cache problems + auto current_time = QDateTime::currentSecsSinceEpoch(); + if (entry->isExpired(current_time - (file_last_changed / 1000))) { + qCWarning(taskNetLogC) << "[HttpMetaCache]" + << "Removing cache entry because of old age!"; + selected_base.entry_list.remove(resource_path); + return staleEntry(base, resource_path); + } + + // entry passed all the checks we cared about. + entry->m_basePath = getBasePath(base); + return entry; +} + +auto HttpMetaCache::updateEntry(MetaEntryPtr stale_entry) -> bool +{ + if (!m_entries.contains(stale_entry->m_baseId)) { + qCCritical(taskHttpMetaCacheLogC) << "Cannot add entry with unknown base:" << stale_entry->m_baseId.toLocal8Bit(); + return false; + } + + if (stale_entry->m_stale) { + qCCritical(taskHttpMetaCacheLogC) << "Cannot add stale entry:" << stale_entry->getFullPath().toLocal8Bit(); + return false; + } + + m_entries[stale_entry->m_baseId].entry_list[stale_entry->m_relativePath] = stale_entry; + SaveEventually(); + + return true; +} + +auto HttpMetaCache::evictEntry(MetaEntryPtr entry) -> bool +{ + if (!entry) + return false; + + entry->m_stale = true; + SaveEventually(); + return true; +} + +// returns true on success, false otherwise +auto HttpMetaCache::evictAll() -> bool +{ + bool ret = true; + for (QString& base : m_entries.keys()) { + EntryMap& map = m_entries[base]; + qCDebug(taskHttpMetaCacheLogC) << "Evicting base" << base; + for (MetaEntryPtr entry : map.entry_list) { + if (!evictEntry(entry)) + qCWarning(taskHttpMetaCacheLogC) << "Unexpected missing cache entry" << entry->m_basePath; + } + map.entry_list.clear(); + // AND all return codes together so the result is true iff all runs of deletePath() are true + ret &= FS::deletePath(map.base_path); + } + return ret; +} + +auto HttpMetaCache::staleEntry(QString base, QString resource_path) -> MetaEntryPtr +{ + auto foo = new MetaEntry(); + foo->m_baseId = base; + foo->m_basePath = getBasePath(base); + foo->m_relativePath = resource_path; + foo->m_stale = true; + + return MetaEntryPtr(foo); +} + +void HttpMetaCache::addBase(QString base, QString base_root) +{ + // TODO: report error + if (m_entries.contains(base)) + return; + + // TODO: check if the base path is valid + EntryMap foo; + foo.base_path = base_root; + m_entries[base] = foo; +} + +auto HttpMetaCache::getBasePath(QString base) -> QString +{ + if (m_entries.contains(base)) { + return m_entries[base].base_path; + } + + return {}; +} + +void HttpMetaCache::Load() +{ + if (m_index_file.isNull()) + return; + + QFile index(m_index_file); + if (!index.open(QIODevice::ReadOnly)) + return; + + QJsonParseError parseError; + QJsonDocument json = QJsonDocument::fromJson(index.readAll(), &parseError); + + // Fail if the JSON is invalid. + if (parseError.error != QJsonParseError::NoError) { + qCritical() << QString("Failed to parse HttpMetaCache file: %1 at offset %2") + .arg(parseError.errorString(), QString::number(parseError.offset)) + .toUtf8(); + return; + } + + // Make sure the root is an object. + if (!json.isObject()) { + qCritical() << "HttpMetaCache root should be an object."; + return; + } + + auto root = json.object(); + + // check file version first + auto version_val = root["version"].toString(); + if (version_val != "1") + return; + + // read the entry array + auto array = root["entries"].toArray(); + for (auto element : array) { + auto element_obj = element.toObject(); + auto base = element_obj["base"].toString(); + if (!m_entries.contains(base)) + continue; + + auto& entrymap = m_entries[base]; + + auto foo = new MetaEntry(); + foo->m_baseId = base; + foo->m_relativePath = element_obj["path"].toString(); + foo->m_md5sum = element_obj["md5sum"].toString(); + foo->m_etag = element_obj["etag"].toString(); + foo->m_local_changed_timestamp = element_obj["last_changed_timestamp"].toDouble(); + foo->m_remote_changed_timestamp = element_obj["remote_changed_timestamp"].toString(); + + foo->makeEternal(element_obj[QStringLiteral("eternal")].toBool()); + if (!foo->isEternal()) { + foo->m_current_age = element_obj["current_age"].toDouble(); + foo->m_max_age = element_obj["max_age"].toDouble(); + } + + // presumed innocent until closer examination + foo->m_stale = false; + + entrymap.entry_list[foo->m_relativePath] = MetaEntryPtr(foo); + } +} + +void HttpMetaCache::SaveEventually() +{ + // reset the save timer + saveBatchingTimer.stop(); + saveBatchingTimer.start(30000); +} + +void HttpMetaCache::SaveNow() +{ + if (m_index_file.isNull()) + return; + + qCDebug(taskHttpMetaCacheLogC) << "Saving metacache with" << m_entries.size() << "entries"; + + QJsonObject toplevel; + Json::writeString(toplevel, "version", "1"); + + QJsonArray entriesArr; + for (auto group : m_entries) { + for (auto entry : group.entry_list) { + // do not save stale entries. they are dead. + if (entry->m_stale) { + continue; + } + + QJsonObject entryObj; + Json::writeString(entryObj, "base", entry->m_baseId); + Json::writeString(entryObj, "path", entry->m_relativePath); + Json::writeString(entryObj, "md5sum", entry->m_md5sum); + Json::writeString(entryObj, "etag", entry->m_etag); + entryObj.insert("last_changed_timestamp", QJsonValue(double(entry->m_local_changed_timestamp))); + if (!entry->m_remote_changed_timestamp.isEmpty()) + entryObj.insert("remote_changed_timestamp", QJsonValue(entry->m_remote_changed_timestamp)); + if (entry->isEternal()) { + entryObj.insert("eternal", true); + } else { + entryObj.insert("current_age", QJsonValue(double(entry->m_current_age))); + entryObj.insert("max_age", QJsonValue(double(entry->m_max_age))); + } + entriesArr.append(entryObj); + } + } + toplevel.insert("entries", entriesArr); + + try { + Json::write(toplevel, m_index_file); + } catch (const Exception& e) { + qCWarning(taskHttpMetaCacheLogC) << "Error writing cache:" << e.what(); + } +} diff --git a/launcher/net/HttpMetaCache.h b/launcher/net/HttpMetaCache.h new file mode 100644 index 0000000..c8b02da --- /dev/null +++ b/launcher/net/HttpMetaCache.h @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include + +class HttpMetaCache; + +class MetaEntry { + friend class HttpMetaCache; + + protected: + MetaEntry() = default; + + public: + auto isStale() -> bool { return m_stale; } + void setStale(bool stale) { m_stale = stale; } + + auto getFullPath() -> QString; + + auto getRemoteChangedTimestamp() -> QString { return m_remote_changed_timestamp; } + void setRemoteChangedTimestamp(QString remote_changed_timestamp) { m_remote_changed_timestamp = remote_changed_timestamp; } + void setLocalChangedTimestamp(qint64 timestamp) { m_local_changed_timestamp = timestamp; } + + auto getETag() -> QString { return m_etag; } + void setETag(QString etag) { m_etag = etag; } + + auto getMD5Sum() -> QString { return m_md5sum; } + void setMD5Sum(QString md5sum) { m_md5sum = md5sum; } + + /* Whether the entry expires after some time (false) or not (true). */ + void makeEternal(bool eternal) { m_is_eternal = eternal; } + bool isEternal() const { return m_is_eternal; } + + auto getCurrentAge() -> qint64 { return m_current_age; } + void setCurrentAge(qint64 age) { m_current_age = age; } + + auto getMaximumAge() -> qint64 { return m_max_age; } + void setMaximumAge(qint64 age) { m_max_age = age; } + + bool isExpired(qint64 offset) { return !m_is_eternal && (m_current_age >= m_max_age - offset); } + + protected: + QString m_baseId; + QString m_basePath; + QString m_relativePath; + QString m_md5sum; + QString m_etag; + + qint64 m_local_changed_timestamp = 0; + QString m_remote_changed_timestamp; // QString for now, RFC 2822 encoded time + qint64 m_current_age = 0; + qint64 m_max_age = 0; + bool m_is_eternal = false; + + bool m_stale = true; +}; + +using MetaEntryPtr = std::shared_ptr; + +class HttpMetaCache : public QObject { + Q_OBJECT + public: + // supply path to the cache index file + HttpMetaCache(QString path = QString()); + ~HttpMetaCache() override; + + // get the entry solely from the cache + // you probably don't want this, unless you have some specific caching needs. + auto getEntry(QString base, QString resource_path) -> MetaEntryPtr; + + // get the entry from cache and verify that it isn't stale (within reason) + auto resolveEntry(QString base, QString resource_path, QString expected_etag = QString()) -> MetaEntryPtr; + + // add a previously resolved stale entry + auto updateEntry(MetaEntryPtr stale_entry) -> bool; + + // evict selected entry from cache + auto evictEntry(MetaEntryPtr entry) -> bool; + bool evictAll(); + + void addBase(QString base, QString base_root); + + // (re)start a timer that calls SaveNow later. + void SaveEventually(); + void Load(); + + auto getBasePath(QString base) -> QString; + + public slots: + void SaveNow(); + + private: + // create a new stale entry, given the parameters + auto staleEntry(QString base, QString resource_path) -> MetaEntryPtr; + + struct EntryMap { + QString base_path; + QMap entry_list; + }; + + QMap m_entries; + QString m_index_file; + QTimer saveBatchingTimer; +}; diff --git a/launcher/net/Logging.cpp b/launcher/net/Logging.cpp new file mode 100644 index 0000000..cd0c88d --- /dev/null +++ b/launcher/net/Logging.cpp @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "net/Logging.h" + +Q_LOGGING_CATEGORY(taskNetLogC, "launcher.task.net") +Q_LOGGING_CATEGORY(taskDownloadLogC, "launcher.task.net.download") +Q_LOGGING_CATEGORY(taskUploadLogC, "launcher.task.net.upload") +Q_LOGGING_CATEGORY(taskMCSkinsLogC, "launcher.task.minecraft.skins") +Q_LOGGING_CATEGORY(taskMetaCacheLogC, "launcher.task.net.metacache") +Q_LOGGING_CATEGORY(taskHttpMetaCacheLogC, "launcher.task.net.metacache.http") diff --git a/launcher/net/Logging.h b/launcher/net/Logging.h new file mode 100644 index 0000000..2536f31 --- /dev/null +++ b/launcher/net/Logging.h @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#pragma once + +#include + +Q_DECLARE_LOGGING_CATEGORY(taskNetLogC) +Q_DECLARE_LOGGING_CATEGORY(taskDownloadLogC) +Q_DECLARE_LOGGING_CATEGORY(taskUploadLogC) +Q_DECLARE_LOGGING_CATEGORY(taskMCSkinsLogC) +Q_DECLARE_LOGGING_CATEGORY(taskMetaCacheLogC) +Q_DECLARE_LOGGING_CATEGORY(taskHttpMetaCacheLogC) diff --git a/launcher/net/MetaCacheSink.cpp b/launcher/net/MetaCacheSink.cpp new file mode 100644 index 0000000..8896f10 --- /dev/null +++ b/launcher/net/MetaCacheSink.cpp @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "MetaCacheSink.h" +#include +#include +#include +#include "Application.h" + +#include "net/Logging.h" + +namespace Net { + +/** Maximum time to hold a cache entry + * = 1 week in seconds + */ +#define MAX_TIME_TO_EXPIRE 1 * 7 * 24 * 60 * 60 + +MetaCacheSink::MetaCacheSink(MetaEntryPtr entry, ChecksumValidator* md5sum, bool is_eternal) + : Net::FileSink(entry->getFullPath()), m_entry(entry), m_md5Node(md5sum), m_is_eternal(is_eternal) +{ + addValidator(md5sum); +} + +Task::State MetaCacheSink::initCache(QNetworkRequest& request) +{ + if (!m_entry->isStale()) { + return Task::State::Succeeded; + } + + // check if file exists, if it does, use its information for the request + QFile current(m_filename); + if (current.exists() && current.size() != 0) { + if (m_entry->getRemoteChangedTimestamp().size()) { + request.setRawHeader(QString("If-Modified-Since").toLatin1(), m_entry->getRemoteChangedTimestamp().toLatin1()); + } + if (m_entry->getETag().size()) { + request.setRawHeader(QString("If-None-Match").toLatin1(), m_entry->getETag().toLatin1()); + } + } + + return Task::State::Running; +} + +Task::State MetaCacheSink::finalizeCache(QNetworkReply& reply) +{ + QFileInfo output_file_info(m_filename); + + if (m_wroteAnyData) { + m_entry->setMD5Sum(m_md5Node->hash().toHex().constData()); + } + + m_entry->setETag(reply.rawHeader("ETag").constData()); + + if (reply.hasRawHeader("Last-Modified")) { + m_entry->setRemoteChangedTimestamp(reply.rawHeader("Last-Modified").constData()); + } + + m_entry->setLocalChangedTimestamp(output_file_info.lastModified().toUTC().toMSecsSinceEpoch()); + + { // Cache lifetime + if (m_is_eternal) { + qCDebug(taskMetaCacheLogC) << "Adding eternal cache entry:" << m_entry->getFullPath(); + m_entry->makeEternal(true); + } else if (reply.hasRawHeader("Cache-Control")) { + auto cache_control_header = reply.rawHeader("Cache-Control"); + qCDebug(taskMetaCacheLogC) << "Parsing 'Cache-Control' header with" << cache_control_header; + + static const QRegularExpression s_maxAgeExpr("max-age=([0-9]+)"); + qint64 max_age = s_maxAgeExpr.match(cache_control_header).captured(1).toLongLong(); + m_entry->setMaximumAge(max_age); + + } else if (reply.hasRawHeader("Expires")) { + auto expires_header = reply.rawHeader("Expires"); + qCDebug(taskMetaCacheLogC) << "Parsing 'Expires' header with" << expires_header; + + qint64 max_age = QDateTime::fromString(expires_header).toSecsSinceEpoch() - QDateTime::currentSecsSinceEpoch(); + m_entry->setMaximumAge(max_age); + } else { + m_entry->setMaximumAge(MAX_TIME_TO_EXPIRE); + } + + if (reply.hasRawHeader("Age")) { + auto age_header = reply.rawHeader("Age"); + qCDebug(taskMetaCacheLogC) << "Parsing 'Age' header with" << age_header; + + qint64 current_age = age_header.toLongLong(); + m_entry->setCurrentAge(current_age); + } else { + m_entry->setCurrentAge(0); + } + } + + m_entry->setStale(false); + APPLICATION->metacache()->updateEntry(m_entry); + + return Task::State::Succeeded; +} + +bool MetaCacheSink::hasLocalData() +{ + QFileInfo info(m_filename); + return info.exists() && info.size() != 0; +} +} // namespace Net diff --git a/launcher/net/MetaCacheSink.h b/launcher/net/MetaCacheSink.h new file mode 100644 index 0000000..f9f7d23 --- /dev/null +++ b/launcher/net/MetaCacheSink.h @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "ChecksumValidator.h" +#include "FileSink.h" +#include "net/HttpMetaCache.h" + +namespace Net { +class MetaCacheSink : public FileSink { + public: + MetaCacheSink(MetaEntryPtr entry, ChecksumValidator* md5sum, bool is_eternal = false); + virtual ~MetaCacheSink() = default; + + auto hasLocalData() -> bool override; + + protected: + auto initCache(QNetworkRequest& request) -> Task::State override; + auto finalizeCache(QNetworkReply& reply) -> Task::State override; + + private: + MetaEntryPtr m_entry; + ChecksumValidator* m_md5Node; + bool m_is_eternal; +}; +} // namespace Net diff --git a/launcher/net/Mode.h b/launcher/net/Mode.h new file mode 100644 index 0000000..3d75981 --- /dev/null +++ b/launcher/net/Mode.h @@ -0,0 +1,5 @@ +#pragma once + +namespace Net { +enum class Mode { Offline, Online }; +} diff --git a/launcher/net/NetJob.cpp b/launcher/net/NetJob.cpp new file mode 100644 index 0000000..dae149a --- /dev/null +++ b/launcher/net/NetJob.cpp @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "NetJob.h" +#include +#include "net/NetRequest.h" +#include "tasks/ConcurrentTask.h" +#if defined(LAUNCHER_APPLICATION) +#include "Application.h" +#include "settings/SettingsObject.h" +#include "ui/dialogs/NetworkJobFailedDialog.h" +#endif + +NetJob::NetJob(QString job_name, QNetworkAccessManager* network, int max_concurrent) : ConcurrentTask(job_name), m_network(network) +{ +#if defined(LAUNCHER_APPLICATION) + if (APPLICATION_DYN && max_concurrent < 0) + max_concurrent = APPLICATION->settings()->get("NumberOfConcurrentDownloads").toInt(); +#endif + if (max_concurrent > 0) + setMaxConcurrent(max_concurrent); +} + +auto NetJob::addNetAction(Net::NetRequest::Ptr action) -> bool +{ + action->setNetwork(m_network); + + addTask(action); + + return true; +} + +void NetJob::executeNextSubTask() +{ + // We're finished, check for failures and retry if we can (up to 3 times) + if (isRunning() && m_queue.isEmpty() && m_doing.isEmpty() && !m_failed.isEmpty() && m_try < 3) { + m_try += 1; + while (!m_failed.isEmpty()) { + auto task = m_failed.take(*m_failed.keyBegin()); + m_done.remove(task.get()); + m_queue.enqueue(task); + } + } + ConcurrentTask::executeNextSubTask(); +} + +auto NetJob::size() const -> int +{ + return m_queue.size() + m_doing.size() + m_done.size(); +} + +auto NetJob::canAbort() const -> bool +{ + bool canFullyAbort = true; + + // can abort the downloads on the queue? + for (auto part : m_queue) + canFullyAbort &= part->canAbort(); + + // can abort the active downloads? + for (auto part : m_doing) + canFullyAbort &= part->canAbort(); + + return canFullyAbort; +} + +auto NetJob::abort() -> bool +{ + bool fullyAborted = true; + + // fail all downloads on the queue + for (auto task : m_queue) + m_failed.insert(task.get(), task); + m_queue.clear(); + + // abort active downloads + auto toKill = m_doing.values(); + for (auto part : toKill) { + fullyAborted &= part->abort(); + } + + if (fullyAborted) + emitAborted(); + else + emitFailed(tr("Failed to abort all tasks in the NetJob!")); + + return fullyAborted; +} + +auto NetJob::getFailedActions() -> QList +{ + QList failed; + for (auto index : m_failed) { + failed.push_back(dynamic_cast(index.get())); + } + return failed; +} + +auto NetJob::getFailedFiles() -> QList +{ + QList failed; + for (auto index : m_failed) { + failed.append(static_cast(index.get())->url().toString()); + } + return failed; +} + +void NetJob::updateState() +{ + emit progress(m_done.count(), totalSize()); + setStatus(tr("Executing %1 task(s) (%2 out of %3 are done)") + .arg(QString::number(m_doing.count()), QString::number(m_done.count()), QString::number(totalSize()))); +} + +bool NetJob::isOnline() +{ + // check some errors that are ussually associated with the lack of internet + for (auto job : getFailedActions()) { + auto err = job->error(); + if (err != QNetworkReply::HostNotFoundError && err != QNetworkReply::NetworkSessionFailedError) { + return true; + } + } + return false; +}; + +void NetJob::emitFailed(QString reason) +{ +#if defined(LAUNCHER_APPLICATION) + + if (APPLICATION_DYN && m_ask_retry && m_manual_try < APPLICATION->settings()->get("NumberOfManualRetries").toInt() && isOnline()) { + m_manual_try++; + auto failed = getFailedActions(); + auto dialog = new NetworkJobFailedDialog(objectName(), m_try, m_done.size(), failed.size(), nullptr); + dialog->setAttribute(Qt::WA_DeleteOnClose); + + for (const auto& request : failed) { + dialog->addFailedRequest(request->url(), request->errorString()); + } + + dialog->open(); + + connect(dialog, &QDialog::finished, this, [this, reason = std::move(reason)](int result) { + if (result == QDialog::Accepted) { + m_try = 0; + executeNextSubTask(); + } else { + ConcurrentTask::emitFailed(reason); + } + }); + + return; + } +#endif + + ConcurrentTask::emitFailed(reason); +} + +void NetJob::setAskRetry(bool askRetry) +{ + m_ask_retry = askRetry; +} diff --git a/launcher/net/NetJob.h b/launcher/net/NetJob.h new file mode 100644 index 0000000..e8351f6 --- /dev/null +++ b/launcher/net/NetJob.h @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * Copyright (C) 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +#include +#include "net/NetRequest.h" +#include "tasks/ConcurrentTask.h" + +// Those are included so that they are also included by anyone using NetJob +#include "net/Download.h" +#include "net/HttpMetaCache.h" + +class NetJob : public ConcurrentTask { + Q_OBJECT + + public: + // TODO: delete + using Ptr = shared_qobject_ptr; + + explicit NetJob(QString job_name, QNetworkAccessManager* network, int max_concurrent = -1); + ~NetJob() override = default; + + auto size() const -> int; + + auto canAbort() const -> bool override; + auto addNetAction(Net::NetRequest::Ptr action) -> bool; + + auto getFailedActions() -> QList; + auto getFailedFiles() -> QList; + void setAskRetry(bool askRetry); + + public slots: + // Qt can't handle auto at the start for some reason? + bool abort() override; + void emitFailed(QString reason) override; + + protected slots: + void executeNextSubTask() override; + + protected: + void updateState() override; + bool isOnline(); + + private: + QNetworkAccessManager* m_network; + + int m_try = 1; + bool m_ask_retry = true; + int m_manual_try = 0; +}; diff --git a/launcher/net/NetRequest.cpp b/launcher/net/NetRequest.cpp new file mode 100644 index 0000000..7faeffb --- /dev/null +++ b/launcher/net/NetRequest.cpp @@ -0,0 +1,412 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2023 TheKodeToad + * Copyright (C) 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "NetRequest.h" + +#include +#include +#include +#include +#include +#include +#include + +#if defined(LAUNCHER_APPLICATION) +#include "Application.h" +#include "settings/SettingsObject.h" +#endif +#include "BuildConfig.h" + +#include "MMCTime.h" +#include "StringUtils.h" + +namespace Net { + +NetRequest::NetRequest() : Task() +{ + connect(&m_retryTimer, &QTimer::timeout, this, &NetRequest::executeTask); +} + +void NetRequest::addValidator(Validator* v) +{ + m_sink->addValidator(v); +} + +void NetRequest::executeTask() +{ + setStatus(tr("Requesting %1").arg(StringUtils::truncateUrlHumanFriendly(m_url, 80))); + + if (getState() == Task::State::AbortedByUser) { + qCWarning(logCat) << getUid().toString() << "Attempt to start an aborted Request:" << m_url.toString(); + emit aborted(); + emit finished(); + return; + } + + QNetworkRequest request(m_url); + m_state = m_sink->init(request); + switch (m_state) { + case State::Succeeded: + qCDebug(logCat) << getUid().toString() << "Request cache hit" << m_url.toString(); + emit succeeded(); + emit finished(); + return; + case State::Running: + qCDebug(logCat) << getUid().toString() << "Running" << m_url.toString(); + break; + case State::Inactive: + case State::Failed: + m_failReason = m_sink->failReason(); + emit failed(m_sink->failReason()); + emit finished(); + return; + case State::AbortedByUser: + emit aborted(); + emit finished(); + return; + } + +#if defined(LAUNCHER_APPLICATION) + auto user_agent = APPLICATION->getUserAgent(); +#else + auto user_agent = BuildConfig.USER_AGENT; +#endif + + request.setHeader(QNetworkRequest::UserAgentHeader, user_agent.toUtf8()); + for (auto& header_proxy : m_headerProxies) { + header_proxy->writeHeaders(request); + } + +#if defined(LAUNCHER_APPLICATION) + request.setTransferTimeout(APPLICATION->settings()->get("RequestTimeout").toInt() * 1000); +#else + request.setTransferTimeout(); +#endif + + m_last_progress_time = m_clock.now(); + m_last_progress_bytes = 0; + + auto rep = getReply(request); + if (rep == nullptr) // it failed + return; + m_reply.reset(rep); + connect(rep, &QNetworkReply::uploadProgress, this, &NetRequest::onProgress); + connect(rep, &QNetworkReply::downloadProgress, this, &NetRequest::onProgress); + connect(rep, &QNetworkReply::finished, this, &NetRequest::downloadFinished); + connect(rep, &QNetworkReply::errorOccurred, this, &NetRequest::downloadError); + connect(rep, &QNetworkReply::sslErrors, this, &NetRequest::sslErrors); + connect(rep, &QNetworkReply::readyRead, this, &NetRequest::downloadReadyRead); +} + +void NetRequest::onProgress(qint64 bytesReceived, qint64 bytesTotal) +{ + auto now = m_clock.now(); + auto elapsed = now - m_last_progress_time; + + // use milliseconds for speed precision + auto elapsed_ms = std::chrono::duration_cast(elapsed); + auto bytes_received_since = bytesReceived - m_last_progress_bytes; + auto dl_speed_bps = (double)bytes_received_since / elapsed_ms.count() * 1000; + auto remaining_time_s = (bytesTotal - bytesReceived) / dl_speed_bps; + + //: Current amount of bytes downloaded, out of the total amount of bytes in the download + QString dl_progress = + tr("%1 / %2").arg(StringUtils::humanReadableFileSize(bytesReceived)).arg(StringUtils::humanReadableFileSize(bytesTotal)); + + QString dl_speed_str; + if (elapsed_ms.count() > 0) { + auto str_eta = bytesTotal > 0 ? Time::humanReadableDuration(remaining_time_s) : tr("unknown"); + //: Download speed, in bytes per second (remaining download time in parenthesis) + dl_speed_str = tr("%1 /s (%2)").arg(StringUtils::humanReadableFileSize(dl_speed_bps)).arg(str_eta); + } else { + //: Download speed at 0 bytes per second + dl_speed_str = tr("0 B/s"); + } + + setDetails(dl_progress + "\n" + dl_speed_str); + + setProgress(bytesReceived, bytesTotal); +} + +void NetRequest::downloadError(QNetworkReply::NetworkError error) +{ + if (error == QNetworkReply::OperationCanceledError) { + qCCritical(logCat) << getUid().toString() << "Aborted" << m_url.toString(); + m_state = State::Failed; + } else if (replyStatusCode() == 429 /* HTTP Too Many Requests*/ && m_options & Option::AutoRetry) { + qCDebug(logCat) << getUid().toString() << "Rate Limited!"; + int64_t delay = 10 * std::pow(2, m_retryCount); + if (m_reply->hasRawHeader("Retry-After")) { + auto retryAfter = m_reply->rawHeader("Retry-After"); + if (retryAfter.trimmed().endsWith("GMT")) /* HTTP Date format */ { + auto afterTimestamp = QDateTime::fromString(QString::fromUtf8(retryAfter.trimmed()), "ddd, dd MMM yyyy HH:mm:ss 'GMT'"); + auto now = QDateTime::currentDateTime(); + delay = now.secsTo(afterTimestamp); + } else { + delay = retryAfter.toLong(); + } + } + handleAutoRetry(delay); + } else { + if (m_options & Option::AcceptLocalFiles) { + if (m_sink->hasLocalData()) { + m_state = State::Succeeded; + return; + } + } + // error happened during download. + qCCritical(logCat) << getUid().toString() << "Failed" << m_url.toString() << "with error" << error; + if (m_reply) + qCCritical(logCat) << getUid().toString() << "HTTP status:" << replyStatusCode() << errorString(); + if (m_errorResponse.size() > 0) + qCCritical(logCat) << getUid().toString() << "Response from server:" << m_errorResponse; + m_state = State::Failed; + } +} + +void NetRequest::sslErrors(const QList& errors) +{ + int i = 1; + for (auto error : errors) { + qCCritical(logCat).nospace() << getUid().toString() << " Request " << m_url.toString() << " SSL Error #" << i << ": " + << error.errorString(); + auto cert = error.certificate(); + qCCritical(logCat) << getUid().toString() << "Certificate in question:\n" << cert.toText(); + i++; + } +} + +auto NetRequest::handleRedirect() -> bool +{ + QUrl redirect = m_reply->header(QNetworkRequest::LocationHeader).toUrl(); + if (!redirect.isValid()) { + if (!m_reply->hasRawHeader("Location")) { + // no redirect -> it's fine to continue + return false; + } + // there is a Location header, but it's not correct. we need to apply some workarounds... + QByteArray redirectBA = m_reply->rawHeader("Location"); + if (redirectBA.size() == 0) { + // empty, yet present redirect header? WTF? + return false; + } + QString redirectStr = QString::fromUtf8(redirectBA); + + if (redirectStr.startsWith("//")) { + /* + * IF the URL begins with //, we need to insert the URL scheme. + * See: https://bugreports.qt.io/browse/QTBUG-41061 + * See: http://tools.ietf.org/html/rfc3986#section-4.2 + */ + redirectStr = m_reply->url().scheme() + ":" + redirectStr; + } else if (redirectStr.startsWith("/")) { + /* + * IF the URL begins with /, we need to process it as a relative URL + */ + auto url = m_reply->url(); + url.setPath(redirectStr, QUrl::TolerantMode); + redirectStr = url.toString(); + } + + /* + * Next, make sure the URL is parsed in tolerant mode. Qt doesn't parse the location header in tolerant mode, which causes issues. + * FIXME: report Qt bug for this + */ + redirect = QUrl(redirectStr, QUrl::TolerantMode); + if (!redirect.isValid()) { + qCWarning(logCat) << getUid().toString() << "Failed to parse redirect URL:" << redirectStr; + downloadError(QNetworkReply::ProtocolFailure); + return false; + } + qCDebug(logCat) << getUid().toString() << "Fixed location header:" << redirect; + } else { + qCDebug(logCat) << getUid().toString() << "Location header:" << redirect; + } + + m_url = QUrl(redirect.toString()); + qCDebug(logCat) << getUid().toString() << "Following redirect to" << m_url.toString(); + executeTask(); + + return true; +} + +void NetRequest::handleAutoRetry(int64_t delay) +{ + m_retryCount++; + if (delay > 60 || m_retryCount > 4) { + /* 1 minute is too long to wait for retry, fail for now */ + m_state = State::Failed; + auto retryAfter = QDateTime::currentDateTime().addSecs(delay); + emitFailed(tr("Request Rate Limited for %n second(s): Retry After %1", "seconds", delay) + .arg(retryAfter.toLocalTime().toString(QLocale::system().dateTimeFormat(QLocale::ShortFormat)))); + return; + } else { + qCDebug(logCat) << getUid().toString() << "Retyring Request in" << delay << "seconds"; + setStatus(tr("Rate Limited: Waiting %n second(s)", "seconds", delay)); + m_retryTimer.setTimerType(Qt::VeryCoarseTimer); + m_retryTimer.setSingleShot(true); + m_retryTimer.setInterval(delay * 1000); + m_retryTimer.start(); + } +} + +void NetRequest::downloadFinished() +{ + // currently waiting for retry + if (m_retryTimer.isActive()) { + return; + } + + // handle HTTP redirection first + if (handleRedirect()) { + qCDebug(logCat) << getUid().toString() << "Request redirected:" << m_url.toString(); + return; + } + + // if the download failed before this point ... + if (m_state == State::Succeeded) // pretend to succeed so we continue processing :) + { + qCDebug(logCat) << getUid().toString() << "Request failed but we are allowed to proceed:" << m_url.toString(); + m_sink->abort(); + emit succeeded(); + emit finished(); + return; + } else if (m_state == State::Failed) { + qCDebug(logCat) << getUid().toString() << "Request failed in previous step:" << m_url.toString(); + m_sink->abort(); + m_failReason = m_reply->errorString(); + emit failed(m_reply->errorString()); + emit finished(); + return; + } else if (m_state == State::AbortedByUser) { + qCDebug(logCat) << getUid().toString() << "Request aborted in previous step:" << m_url.toString(); + m_sink->abort(); + emit aborted(); + emit finished(); + return; + } + + // make sure we got all the remaining data, if any + auto data = m_reply->readAll(); + if (data.size()) { + qCDebug(logCat) << getUid().toString() << "Writing extra" << data.size() << "bytes"; + m_state = m_sink->write(data); + if (m_state != State::Succeeded) { + qCDebug(logCat) << getUid().toString() << "Request failed to write:" << m_url.toString(); + m_sink->abort(); + m_failReason = m_sink->failReason(); + emit failed(m_sink->failReason()); + emit finished(); + return; + } + } + + // otherwise, finalize the whole graph + m_state = m_sink->finalize(*m_reply.get()); + if (m_state != State::Succeeded) { + qCDebug(logCat) << getUid().toString() << "Request failed to finalize:" << m_url.toString(); + m_sink->abort(); + m_failReason = m_sink->failReason(); + emit failed(m_sink->failReason()); + emit finished(); + return; + } + + qCDebug(logCat) << getUid().toString() << "Request succeeded:" << m_url.toString(); + emit succeeded(); + emit finished(); +} + +void NetRequest::downloadReadyRead() +{ + if (m_state == State::Running) { + auto data = m_reply->readAll(); + m_state = m_sink->write(data); + if (replyStatusCode() >= 400) { + m_errorResponse.append(data); + } + if (m_state == State::Failed) { + qCCritical(logCat) << getUid().toString() << "Failed to process response chunk:" << m_sink->failReason(); + } + // qDebug() << "Request" << m_url.toString() << "gained" << data.size() << "bytes"; + } else { + qCCritical(logCat) << getUid().toString() << "Cannot write download data! illegal status" << m_status; + } +} + +auto NetRequest::abort() -> bool +{ + m_state = State::AbortedByUser; + if (m_reply) { + disconnect(m_reply.get(), &QNetworkReply::errorOccurred, nullptr, nullptr); + m_reply->abort(); + } + return true; +} + +int NetRequest::replyStatusCode() const +{ + return m_reply ? m_reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() : -1; +} + +QNetworkReply::NetworkError NetRequest::error() const +{ + return m_reply ? m_reply->error() : QNetworkReply::NoError; +} + +QUrl NetRequest::url() const +{ + return m_url; +} + +QString NetRequest::errorString() const +{ + return m_reply ? m_reply->errorString() : ""; +} + +void NetRequest::enableAutoRetry(bool enable) +{ + if (enable) { + m_options |= Option::AutoRetry; + } else { + m_options &= ~static_cast(Option::AutoRetry); + } +} + +} // namespace Net diff --git a/launcher/net/NetRequest.h b/launcher/net/NetRequest.h new file mode 100644 index 0000000..e38152b --- /dev/null +++ b/launcher/net/NetRequest.h @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include + +#include "HeaderProxy.h" +#include "Sink.h" +#include "Validator.h" + +#include "QObjectPtr.h" +#include "net/Logging.h" +#include "tasks/Task.h" + +namespace Net { +class NetRequest : public Task { + Q_OBJECT + protected: + explicit NetRequest(); + + public: + using Ptr = shared_qobject_ptr; + enum class Option { NoOptions = 0, AcceptLocalFiles = 1, MakeEternal = 2, AutoRetry = 4 }; + Q_DECLARE_FLAGS(Options, Option) + + public: + ~NetRequest() override = default; + void addValidator(Validator* v); + auto abort() -> bool override; + auto canAbort() const -> bool override { return true; } + + void setNetwork(QNetworkAccessManager* network) { m_network = network; } + void addHeaderProxy(std::unique_ptr proxy) { m_headerProxies.push_back(std::move(proxy)); } + + // automatically handle HTTP 429 Too Many Requests errors and retry + void enableAutoRetry(bool enable); + + QUrl url() const; + void setUrl(QUrl url) { m_url = url; } + int replyStatusCode() const; + QNetworkReply::NetworkError error() const; + QString errorString() const; + + private: + auto handleRedirect() -> bool; + void handleAutoRetry(int64_t delay); + virtual QNetworkReply* getReply(QNetworkRequest&) = 0; + + protected slots: + void onProgress(qint64 bytesReceived, qint64 bytesTotal); + void downloadError(QNetworkReply::NetworkError error); + void sslErrors(const QList& errors); + void downloadFinished(); + void downloadReadyRead(); + void executeTask() override; + + protected: + std::unique_ptr m_sink; + Options m_options; + + using logCatFunc = const QLoggingCategory& (*)(); + logCatFunc logCat = taskUploadLogC; + + std::chrono::steady_clock m_clock; + std::chrono::time_point m_last_progress_time; + qint64 m_last_progress_bytes; + + QNetworkAccessManager* m_network; + + /// the network reply + std::unique_ptr m_reply; + QByteArray m_errorResponse; + + /// source URL + QUrl m_url; + std::vector> m_headerProxies; + + int m_retryCount = 0; + QTimer m_retryTimer; +}; +} // namespace Net + +Q_DECLARE_OPERATORS_FOR_FLAGS(Net::NetRequest::Options) diff --git a/launcher/net/NetUtils.h b/launcher/net/NetUtils.h new file mode 100644 index 0000000..cd517bc --- /dev/null +++ b/launcher/net/NetUtils.h @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include + +namespace Net { +inline bool isApplicationError(QNetworkReply::NetworkError x) +{ + // Mainly taken from https://github.com/qt/qtbase/blob/dev/src/network/access/qhttpthreaddelegate.cpp + static QSet errors = { QNetworkReply::ProtocolInvalidOperationError, + QNetworkReply::AuthenticationRequiredError, + QNetworkReply::ContentAccessDenied, + QNetworkReply::ContentNotFoundError, + QNetworkReply::ContentOperationNotPermittedError, + QNetworkReply::ProxyAuthenticationRequiredError, + QNetworkReply::ContentConflictError, + QNetworkReply::ContentGoneError, + QNetworkReply::InternalServerError, + QNetworkReply::OperationNotImplementedError, + QNetworkReply::ServiceUnavailableError, + QNetworkReply::UnknownServerError, + QNetworkReply::UnknownContentError }; + return errors.contains(x); +} +} // namespace Net diff --git a/launcher/net/PasteUpload.cpp b/launcher/net/PasteUpload.cpp new file mode 100644 index 0000000..ecbf4e2 --- /dev/null +++ b/launcher/net/PasteUpload.cpp @@ -0,0 +1,218 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Lenny McLennington + * Copyright (C) 2022 Swirl + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "PasteUpload.h" + +#include +#include +#include +#include +#include +#include +#include "logs/AnonymizeLog.h" + +const std::array PasteUpload::PasteTypes = { { { "0x0.st", "https://0x0.st", "" }, + { "hastebin", "https://hst.sh", "/documents" }, + { "paste.gg", "https://paste.gg", "/api/v1/pastes" }, + { "mclo.gs", "https://api.mclo.gs", "/1/log" } } }; + +QNetworkReply* PasteUpload::getReply(QNetworkRequest& request) +{ + switch (m_paste_type) { + case PasteUpload::NullPointer: { + QHttpMultiPart* multiPart = new QHttpMultiPart{ QHttpMultiPart::FormDataType, this }; + + QHttpPart filePart; + filePart.setBody(m_log.toUtf8()); + filePart.setHeader(QNetworkRequest::ContentTypeHeader, "text/plain"); + filePart.setHeader(QNetworkRequest::ContentDispositionHeader, "form-data; name=\"file\"; filename=\"log.txt\""); + multiPart->append(filePart); + + return m_network->post(request, multiPart); + } + case PasteUpload::Hastebin: { + return m_network->post(request, m_log.toUtf8()); + } + case PasteUpload::Mclogs: { + QUrlQuery postData; + postData.addQueryItem("content", m_log); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); + return m_network->post(request, postData.toString().toUtf8()); + } + case PasteUpload::PasteGG: { + QJsonObject obj; + QJsonDocument doc; + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + + obj.insert("expires", QDateTime::currentDateTimeUtc().addDays(100).toString(Qt::DateFormat::ISODate)); + + QJsonArray files; + QJsonObject logFileInfo; + QJsonObject logFileContentInfo; + logFileContentInfo.insert("format", "text"); + logFileContentInfo.insert("value", m_log); + logFileInfo.insert("name", "log.txt"); + logFileInfo.insert("content", logFileContentInfo); + files.append(logFileInfo); + + obj.insert("files", files); + + doc.setObject(obj); + return m_network->post(request, doc.toJson()); + } + } + + return nullptr; +}; + +auto PasteUpload::Sink::finalize(QNetworkReply& reply) -> Task::State +{ + if (!finalizeAllValidators(reply)) { + m_fail_reason = "Failed to finalize validators"; + return Task::State::Failed; + } + int statusCode = reply.attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + if (reply.error() != QNetworkReply::NetworkError::NoError) { + m_fail_reason = QObject::tr("Network error: %1").arg(reply.errorString()); + return Task::State::Failed; + } else if (statusCode != 200 && statusCode != 201) { + QString reasonPhrase = reply.attribute(QNetworkRequest::HttpReasonPhraseAttribute).toString(); + m_fail_reason = + QObject::tr("Error: %1 returned unexpected status code %2 %3").arg(m_d->url().toString()).arg(statusCode).arg(reasonPhrase); + return Task::State::Failed; + } + + switch (m_d->m_paste_type) { + case PasteUpload::NullPointer: + m_d->m_pasteLink = QString::fromUtf8(*output()).trimmed(); + break; + case PasteUpload::Hastebin: { + QJsonParseError jsonError; + auto doc = QJsonDocument::fromJson(*output(), &jsonError); + if (jsonError.error != QJsonParseError::NoError) { + qDebug() << "hastebin server did not reply with JSON" << jsonError.errorString(); + m_fail_reason = + QObject::tr("Failed to parse response from hastebin server: expected JSON but got an invalid response. Error: %1") + .arg(jsonError.errorString()); + return Task::State::Failed; + } + auto obj = doc.object(); + if (obj.contains("key") && obj["key"].isString()) { + QString key = doc.object()["key"].toString(); + m_d->m_pasteLink = m_d->m_baseUrl + "/" + key; + } else { + qDebug() << "Log upload failed:" << doc.toJson(); + m_fail_reason = QObject::tr("Error: %1 returned a malformed response body").arg(m_d->url().toString()); + return Task::State::Failed; + } + break; + } + case PasteUpload::Mclogs: { + QJsonParseError jsonError; + auto doc = QJsonDocument::fromJson(*output(), &jsonError); + if (jsonError.error != QJsonParseError::NoError) { + qDebug() << "mclogs server did not reply with JSON" << jsonError.errorString(); + m_fail_reason = + QObject::tr("Failed to parse response from mclogs server: expected JSON but got an invalid response. Error: %1") + .arg(jsonError.errorString()); + return Task::State::Failed; + } + auto obj = doc.object(); + if (obj.contains("success") && obj["success"].isBool()) { + bool success = obj["success"].toBool(); + if (success) { + m_d->m_pasteLink = obj["url"].toString(); + } else { + QString error = obj["error"].toString(); + m_fail_reason = QObject::tr("Error: %1 returned an error: %2").arg(m_d->url().toString(), error); + return Task::State::Failed; + } + } else { + qDebug() << "Log upload failed:" << doc.toJson(); + m_fail_reason = QObject::tr("Error: %1 returned a malformed response body").arg(m_d->url().toString()); + return Task::State::Failed; + } + break; + } + case PasteUpload::PasteGG: + QJsonParseError jsonError; + auto doc = QJsonDocument::fromJson(*output(), &jsonError); + if (jsonError.error != QJsonParseError::NoError) { + qDebug() << "pastegg server did not reply with JSON" << jsonError.errorString(); + m_fail_reason = + QObject::tr("Failed to parse response from pasteGG server: expected JSON but got an invalid response. Error: %1") + .arg(jsonError.errorString()); + return Task::State::Failed; + } + auto obj = doc.object(); + if (obj.contains("status") && obj["status"].isString()) { + QString status = obj["status"].toString(); + if (status == "success") { + m_d->m_pasteLink = m_d->m_baseUrl + "/p/anonymous/" + obj["result"].toObject()["id"].toString(); + } else { + QString error = obj["error"].toString(); + QString message = (obj.contains("message") && obj["message"].isString()) ? obj["message"].toString() : "none"; + m_fail_reason = + QObject::tr("Error: %1 returned an error code: %2\nError message: %3").arg(m_d->url().toString(), error, message); + return Task::State::Failed; + } + } else { + qDebug() << "Log upload failed:" << doc.toJson(); + m_fail_reason = QObject::tr("Error: %1 returned a malformed response body").arg(m_d->url().toString()); + return Task::State::Failed; + } + break; + } + return Task::State::Succeeded; +} + +PasteUpload::PasteUpload(const QString& log, QString url, PasteType pasteType) : m_log(log), m_baseUrl(url), m_paste_type(pasteType) +{ + anonymizeLog(m_log); + auto base = PasteUpload::PasteTypes.at(pasteType); + if (m_baseUrl.isEmpty()) + m_baseUrl = base.defaultBase; + + // HACK: Paste's docs say the standard API path is at /api/ but the official instance paste.gg doesn't follow that?? + if (pasteType == PasteUpload::PasteGG && m_baseUrl == base.defaultBase) + m_url = "https://api.paste.gg/v1/pastes"; + else + m_url = m_baseUrl + base.endpointPath; + + m_sink.reset(new Sink(this)); +} diff --git a/launcher/net/PasteUpload.h b/launcher/net/PasteUpload.h new file mode 100644 index 0000000..d22a9ba --- /dev/null +++ b/launcher/net/PasteUpload.h @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Lenny McLennington + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "net/ByteArraySink.h" +#include "net/NetRequest.h" +#include "tasks/Task.h" + +#include +#include +#include + +#include +#include +#include + +class PasteUpload : public Net::NetRequest { + public: + enum PasteType : int { + // 0x0.st + NullPointer, + // hastebin.com + Hastebin, + // paste.gg + PasteGG, + // mclo.gs + Mclogs, + // Helpful to get the range of valid values on the enum for input sanitisation: + First = NullPointer, + Last = Mclogs + }; + struct PasteTypeInfo { + const QString name; + const QString defaultBase; + const QString endpointPath; + }; + + static const std::array PasteTypes; + + class Sink : public Net::ByteArraySink { + public: + Sink(PasteUpload* p) : m_d(p) {}; + virtual ~Sink() = default; + + public: + auto finalize(QNetworkReply& reply) -> Task::State override; + + private: + PasteUpload* m_d; + }; + friend Sink; + + PasteUpload(const QString& log, QString url, PasteType pasteType); + virtual ~PasteUpload() = default; + + QString pasteLink() { return m_pasteLink; } + + private: + virtual QNetworkReply* getReply(QNetworkRequest&) override; + QString m_log; + QString m_pasteLink; + QString m_baseUrl; + const PasteType m_paste_type; +}; diff --git a/launcher/net/RawHeaderProxy.h b/launcher/net/RawHeaderProxy.h new file mode 100644 index 0000000..9de18ef --- /dev/null +++ b/launcher/net/RawHeaderProxy.h @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#pragma once + +#include "net/HeaderProxy.h" + +namespace Net { + +class RawHeaderProxy : public HeaderProxy { + public: + RawHeaderProxy(QList headers = {}) : HeaderProxy(), m_headers(std::move(headers)) {}; + virtual ~RawHeaderProxy() = default; + + public: + virtual QList headers(const QNetworkRequest&) const override { return m_headers; }; + + void addHeader(const HeaderPair& header) { m_headers.append(header); } + void addHeader(const QByteArray& headerName, const QByteArray& headerValue) { m_headers.append({ headerName, headerValue }); } + void addHeaders(const QList& headers) { m_headers.append(headers); } + void setHeaders(QList headers) { m_headers = headers; }; + + private: + QList m_headers; +}; + +} // namespace Net diff --git a/launcher/net/Sink.h b/launcher/net/Sink.h new file mode 100644 index 0000000..3f04cbd --- /dev/null +++ b/launcher/net/Sink.h @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "Validator.h" +#include "tasks/Task.h" + +namespace Net { +class Sink { + public: + Sink() = default; + virtual ~Sink() = default; + + public: + virtual auto init(QNetworkRequest& request) -> Task::State = 0; + virtual auto write(QByteArray& data) -> Task::State = 0; + virtual auto abort() -> Task::State = 0; + virtual auto finalize(QNetworkReply& reply) -> Task::State = 0; + + virtual auto hasLocalData() -> bool = 0; + + QString failReason() const { return m_fail_reason; } + + void addValidator(Validator* validator) + { + if (validator) { + validators.push_back(std::shared_ptr(validator)); + } + } + + protected: + bool initAllValidators(QNetworkRequest& request) + { + for (auto& validator : validators) { + if (!validator->init(request)) + return false; + } + return true; + } + bool finalizeAllValidators(QNetworkReply& reply) + { + for (auto& validator : validators) { + if (!validator->validate(reply)) + return false; + } + return true; + } + bool failAllValidators() + { + bool success = true; + for (auto& validator : validators) { + success &= validator->abort(); + } + return success; + } + bool writeAllValidators(QByteArray& data) + { + for (auto& validator : validators) { + if (!validator->write(data)) + return false; + } + return true; + } + + protected: + std::vector> validators; + QString m_fail_reason; +}; +} // namespace Net diff --git a/launcher/net/Upload.cpp b/launcher/net/Upload.cpp new file mode 100644 index 0000000..60cf6d3 --- /dev/null +++ b/launcher/net/Upload.cpp @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2023 TheKodeToad + * Copyright (C) 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "Upload.h" + +#include +#include +#include "ByteArraySink.h" + +namespace Net { + +QNetworkReply* Upload::getReply(QNetworkRequest& request) +{ + if (!request.hasRawHeader("Content-Type")) + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + return m_network->post(request, m_post_data); +} + +std::pair Upload::makeByteArray(QUrl url, QByteArray m_post_data) +{ + auto up = makeShared(); + up->m_url = std::move(url); + + auto sink = std::make_unique(); + QByteArray* response = sink->output(); + up->m_sink = std::move(sink); + + up->m_post_data = std::move(m_post_data); + return { up, response }; +} +} // namespace Net diff --git a/launcher/net/Upload.h b/launcher/net/Upload.h new file mode 100644 index 0000000..0610426 --- /dev/null +++ b/launcher/net/Upload.h @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +#include "net/NetRequest.h" + +namespace Net { + +class Upload : public NetRequest { + Q_OBJECT + public: + using Ptr = shared_qobject_ptr; + explicit Upload() : NetRequest() { logCat = taskUploadLogC; }; + + /** + * Creates a request downloading to the returned QByteArray,. + * The QByteArray will live as long as the Upload object. + */ + static std::pair makeByteArray(QUrl url, QByteArray m_post_data); + + protected: + virtual QNetworkReply* getReply(QNetworkRequest&) override; + QByteArray m_post_data; +}; + +} // namespace Net diff --git a/launcher/net/Validator.h b/launcher/net/Validator.h new file mode 100644 index 0000000..6d1945e --- /dev/null +++ b/launcher/net/Validator.h @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +namespace Net { +class Validator { + public: /* con/des */ + Validator() {} + virtual ~Validator() {} + + public: /* methods */ + virtual bool init(QNetworkRequest& request) = 0; + virtual bool write(QByteArray& data) = 0; + virtual bool abort() = 0; + virtual bool validate(QNetworkReply& reply) = 0; +}; +} // namespace Net diff --git a/launcher/news/NewsChecker.cpp b/launcher/news/NewsChecker.cpp new file mode 100644 index 0000000..35173dd --- /dev/null +++ b/launcher/news/NewsChecker.cpp @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "NewsChecker.h" + +#include +#include + +#include +#include "Application.h" + +NewsChecker::NewsChecker(QNetworkAccessManager* network, const QString& feedUrl) +{ + m_network = network; + m_feedUrl = feedUrl; +} + +void NewsChecker::reloadNews() +{ + // Start a netjob to download the RSS feed and call rssDownloadFinished() when it's done. + if (isLoadingNews()) { + qDebug() << "Ignored request to reload news. Currently reloading already."; + return; + } + + m_entry = APPLICATION->metacache()->resolveEntry("feed", "feed.xml"); + + qDebug() << "Reloading news."; + + NetJob::Ptr job{ new NetJob("News RSS Feed", m_network) }; + job->addNetAction(Net::Download::makeCached(m_feedUrl, m_entry)); + job->setAskRetry(false); + connect(job.get(), &NetJob::succeeded, this, &NewsChecker::rssDownloadFinished); + connect(job.get(), &NetJob::failed, this, &NewsChecker::rssDownloadFailed); + m_newsNetJob.reset(job); + job->start(); +} + +void NewsChecker::rssDownloadFinished() +{ + // Parse the XML file and process the RSS feed entries. + qDebug() << "Finished loading RSS feed."; + + m_newsNetJob.reset(); + QDomDocument doc; + { + // Stuff to store error info in. + QString errorMsg = "Unknown error."; + int errorLine = -1; + int errorCol = -1; + + QFile feed(m_entry->getFullPath()); + + if (feed.open(QFile::ReadOnly | QFile::Text)) { + QTextStream in(&feed); + // Parse the XML. + if (!doc.setContent(in.readAll(), false, &errorMsg, &errorLine, &errorCol)) { + fail(QString("Error parsing RSS feed XML. %1 at %2:%3.").arg(errorMsg).arg(errorLine).arg(errorCol)); + return; + } + } + } + + // If the parsing succeeded, read it. + QDomNodeList items = doc.elementsByTagName("entry"); + m_newsEntries.clear(); + for (int i = 0; i < items.length(); i++) { + QDomElement element = items.at(i).toElement(); + NewsEntryPtr entry; + entry.reset(new NewsEntry()); + QString errorMsg = "An unknown error occurred."; + if (NewsEntry::fromXmlElement(element, entry.get(), &errorMsg)) { + qDebug() << "Loaded news entry" << entry->title; + m_newsEntries.append(entry); + } else { + qWarning() << "Failed to load news entry at index" << i << ":" << errorMsg; + } + } + + succeed(); +} + +void NewsChecker::rssDownloadFailed(QString reason) +{ + // Set an error message and fail. + fail(tr("Failed to load news RSS feed:\n%1").arg(reason)); +} + +QList NewsChecker::getNewsEntries() const +{ + return m_newsEntries; +} + +bool NewsChecker::isLoadingNews() const +{ + return m_newsNetJob.get() != nullptr; +} + +QString NewsChecker::getLastLoadErrorMsg() const +{ + return m_lastLoadError; +} + +void NewsChecker::succeed() +{ + m_lastLoadError = ""; + qDebug() << "News loading succeeded."; + m_newsNetJob.reset(); + emit newsLoaded(); +} + +void NewsChecker::fail(const QString& errorMsg) +{ + m_lastLoadError = errorMsg; + qDebug() << "Failed to load news:" << errorMsg; + m_newsNetJob.reset(); + emit newsLoadingFailed(errorMsg); +} diff --git a/launcher/news/NewsChecker.h b/launcher/news/NewsChecker.h new file mode 100644 index 0000000..497ae23 --- /dev/null +++ b/launcher/news/NewsChecker.h @@ -0,0 +1,104 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +#include + +#include "NewsEntry.h" + +class NewsChecker : public QObject { + Q_OBJECT + public: + /*! + * Constructs a news reader to read from the given RSS feed URL. + */ + NewsChecker(QNetworkAccessManager* network, const QString& feedUrl); + + /*! + * Returns the error message for the last time the news was loaded. + * Empty string if the last load was successful. + */ + QString getLastLoadErrorMsg() const; + + /*! + * Returns true if the news has been loaded successfully. + */ + bool isNewsLoaded() const; + + //! True if the news is currently loading. If true, reloadNews() will do nothing. + bool isLoadingNews() const; + + /*! + * Returns a list of news entries. + */ + QList getNewsEntries() const; + + /*! + * Reloads the news from the website's RSS feed. + * If the news is already loading, this does nothing. + */ + void Q_SLOT reloadNews(); + + signals: + /*! + * Signal fired after the news has finished loading. + */ + void newsLoaded(); + + /*! + * Signal fired after the news fails to load. + */ + void newsLoadingFailed(QString errorMsg); + + protected slots: + void rssDownloadFinished(); + void rssDownloadFailed(QString reason); + + protected: /* data */ + //! The URL for the RSS feed to fetch. + QString m_feedUrl; + + //! List of news entries. + QList m_newsEntries; + + //! The network job to use to load the news. + NetJob::Ptr m_newsNetJob; + + //! True if news has been loaded. + bool m_loadedNews; + + //! The cache entry for the feed. + MetaEntryPtr m_entry; + + /*! + * Gets the error message that was given last time the news was loaded. + * If the last news load succeeded, this will be an empty string. + */ + QString m_lastLoadError; + + QNetworkAccessManager* m_network; + + protected slots: + /// Emits newsLoaded() and sets m_lastLoadError to empty string. + void succeed(); + + /// Emits newsLoadingFailed() and sets m_lastLoadError to the given message. + void fail(const QString& errorMsg); +}; diff --git a/launcher/news/NewsEntry.cpp b/launcher/news/NewsEntry.cpp new file mode 100644 index 0000000..ea25f2e --- /dev/null +++ b/launcher/news/NewsEntry.cpp @@ -0,0 +1,59 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "NewsEntry.h" + +#include +#include + +NewsEntry::NewsEntry(QObject* parent) : QObject(parent) +{ + this->title = tr("Untitled"); + this->content = tr("No content."); + this->link = ""; +} + +NewsEntry::NewsEntry(const QString& title, const QString& content, const QString& link, QObject* parent) : QObject(parent) +{ + this->title = title; + this->content = content; + this->link = link; +} + +/*! + * Gets the text content of the given child element as a QVariant. + */ +inline QString childValue(const QDomElement& element, const QString& childName, QString defaultVal = "") +{ + QDomNodeList nodes = element.elementsByTagName(childName); + if (nodes.count() > 0) { + QDomElement elem = nodes.at(0).toElement(); + return elem.text(); + } else { + return defaultVal; + } +} + +bool NewsEntry::fromXmlElement(const QDomElement& element, NewsEntry* entry, [[maybe_unused]] QString* errorMsg) +{ + QString title = childValue(element, "title", tr("Untitled")); + QString content = childValue(element, "content", tr("No content.")); + QString link = childValue(element, "id"); + + entry->title = title; + entry->content = content; + entry->link = link; + return true; +} diff --git a/launcher/news/NewsEntry.h b/launcher/news/NewsEntry.h new file mode 100644 index 0000000..ab717ec --- /dev/null +++ b/launcher/news/NewsEntry.h @@ -0,0 +1,54 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include + +class NewsEntry : public QObject { + Q_OBJECT + + public: + /*! + * Constructs an empty news entry. + */ + explicit NewsEntry(QObject* parent = 0); + + /*! + * Constructs a new news entry. + * Note that content may contain HTML. + */ + NewsEntry(const QString& title, const QString& content, const QString& link, QObject* parent = 0); + + /*! + * Attempts to load information from the given XML element into the given news entry pointer. + * If this fails, the function will return false and store an error message in the errorMsg pointer. + */ + static bool fromXmlElement(const QDomElement& element, NewsEntry* entry, QString* errorMsg = 0); + + //! The post title. + QString title; + + //! The post's content. May contain HTML. + QString content; + + //! URL to the post. + QString link; +}; + +using NewsEntryPtr = std::shared_ptr; diff --git a/launcher/portable.txt b/launcher/portable.txt new file mode 100644 index 0000000..a69f47c --- /dev/null +++ b/launcher/portable.txt @@ -0,0 +1,4 @@ +This file enables the portable mode for the launcher. + +If this file is present in the root directory of the launcher, it will store all data here. Otherwise it will store your data in your appdata directory. +You can safely delete this file, if you don't want the launcher to store your data here. diff --git a/launcher/qtlogging.ini b/launcher/qtlogging.ini new file mode 100644 index 0000000..10f7241 --- /dev/null +++ b/launcher/qtlogging.ini @@ -0,0 +1,19 @@ +[Rules] +*.debug=true +# prevent log spam and strange bugs +# qt.qpa.drawing in particular causes theme artifacts on MacOS +qt.*.debug=false +# supress image format noise +kf.imageformats.plugins.hdr=false +kf.imageformats.plugins.xcf=false +# don't log credentials by default +launcher.auth.credentials.debug=false +# remove the debug lines, other log levels still get through +launcher.task.net.download.debug=false +# enable or disable whole catageries +launcher.task.net=true +launcher.task=false +launcher.task.net.upload=true +launcher.task.net.metacache=false +launcher.task.net.metacache.http=true + diff --git a/launcher/resources/OSX/OSX.qrc b/launcher/resources/OSX/OSX.qrc new file mode 100644 index 0000000..49f56b0 --- /dev/null +++ b/launcher/resources/OSX/OSX.qrc @@ -0,0 +1,43 @@ + + + + index.theme + scalable/about.svg + scalable/accounts.svg + scalable/bug.svg + scalable/centralmods.svg + scalable/checkupdate.svg + scalable/copy.svg + scalable/coremods.svg + scalable/custom-commands.svg + scalable/externaltools.svg + scalable/help.svg + scalable/instance-settings.svg + scalable/jarmods.svg + scalable/java.svg + scalable/language.svg + scalable/loadermods.svg + scalable/log.svg + scalable/minecraft.svg + scalable/new.svg + scalable/news.svg + scalable/notes.svg + scalable/proxy.svg + scalable/refresh.svg + scalable/resourcepacks.svg + scalable/shaderpacks.svg + scalable/screenshots.svg + scalable/settings.svg + scalable/status-bad.svg + scalable/status-good.svg + scalable/status-yellow.svg + scalable/viewfolder.svg + scalable/worlds.svg + scalable/delete.svg + scalable/tag.svg + scalable/export.svg + scalable/rename.svg + scalable/launch.svg + scalable/shortcut.svg + + diff --git a/launcher/resources/OSX/index.theme b/launcher/resources/OSX/index.theme new file mode 100644 index 0000000..7f90a32 --- /dev/null +++ b/launcher/resources/OSX/index.theme @@ -0,0 +1,11 @@ +[Icon Theme] +Name=OSX +Comment=OSX theme by pexner +Inherits=multimc +Directories=scalable + +[scalable] +Size=48 +Type=Scalable +MinSize=16 +MaxSize=256 diff --git a/launcher/resources/OSX/scalable/about.svg b/launcher/resources/OSX/scalable/about.svg new file mode 100644 index 0000000..eb87ccf --- /dev/null +++ b/launcher/resources/OSX/scalable/about.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/OSX/scalable/accounts.svg b/launcher/resources/OSX/scalable/accounts.svg new file mode 100644 index 0000000..163bcee --- /dev/null +++ b/launcher/resources/OSX/scalable/accounts.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + diff --git a/launcher/resources/OSX/scalable/bug.svg b/launcher/resources/OSX/scalable/bug.svg new file mode 100644 index 0000000..00565bb --- /dev/null +++ b/launcher/resources/OSX/scalable/bug.svg @@ -0,0 +1,25 @@ + + + + + + + + + diff --git a/launcher/resources/OSX/scalable/centralmods.svg b/launcher/resources/OSX/scalable/centralmods.svg new file mode 100644 index 0000000..37b821e --- /dev/null +++ b/launcher/resources/OSX/scalable/centralmods.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + diff --git a/launcher/resources/OSX/scalable/checkupdate.svg b/launcher/resources/OSX/scalable/checkupdate.svg new file mode 100644 index 0000000..30cec51 --- /dev/null +++ b/launcher/resources/OSX/scalable/checkupdate.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + diff --git a/launcher/resources/OSX/scalable/copy.svg b/launcher/resources/OSX/scalable/copy.svg new file mode 100644 index 0000000..7382d6e --- /dev/null +++ b/launcher/resources/OSX/scalable/copy.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + diff --git a/launcher/resources/OSX/scalable/coremods.svg b/launcher/resources/OSX/scalable/coremods.svg new file mode 100644 index 0000000..b0df605 --- /dev/null +++ b/launcher/resources/OSX/scalable/coremods.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/OSX/scalable/custom-commands.svg b/launcher/resources/OSX/scalable/custom-commands.svg new file mode 100644 index 0000000..e663452 --- /dev/null +++ b/launcher/resources/OSX/scalable/custom-commands.svg @@ -0,0 +1,71 @@ + +image/svg+xml diff --git a/launcher/resources/OSX/scalable/delete.svg b/launcher/resources/OSX/scalable/delete.svg new file mode 100644 index 0000000..bec8c7d --- /dev/null +++ b/launcher/resources/OSX/scalable/delete.svg @@ -0,0 +1,49 @@ + + + + + + + + + diff --git a/launcher/resources/OSX/scalable/export.svg b/launcher/resources/OSX/scalable/export.svg new file mode 100644 index 0000000..62145a7 --- /dev/null +++ b/launcher/resources/OSX/scalable/export.svg @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/OSX/scalable/externaltools.svg b/launcher/resources/OSX/scalable/externaltools.svg new file mode 100644 index 0000000..a2b7488 --- /dev/null +++ b/launcher/resources/OSX/scalable/externaltools.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/launcher/resources/OSX/scalable/help.svg b/launcher/resources/OSX/scalable/help.svg new file mode 100644 index 0000000..9d1b367 --- /dev/null +++ b/launcher/resources/OSX/scalable/help.svg @@ -0,0 +1,51 @@ + +image/svg+xml \ No newline at end of file diff --git a/launcher/resources/OSX/scalable/instance-settings.svg b/launcher/resources/OSX/scalable/instance-settings.svg new file mode 100644 index 0000000..394877f --- /dev/null +++ b/launcher/resources/OSX/scalable/instance-settings.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + diff --git a/launcher/resources/OSX/scalable/jarmods.svg b/launcher/resources/OSX/scalable/jarmods.svg new file mode 100644 index 0000000..213ec83 --- /dev/null +++ b/launcher/resources/OSX/scalable/jarmods.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/OSX/scalable/java.svg b/launcher/resources/OSX/scalable/java.svg new file mode 100644 index 0000000..e1aee15 --- /dev/null +++ b/launcher/resources/OSX/scalable/java.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/OSX/scalable/language.svg b/launcher/resources/OSX/scalable/language.svg new file mode 100644 index 0000000..4f7d002 --- /dev/null +++ b/launcher/resources/OSX/scalable/language.svg @@ -0,0 +1,40 @@ + +image/svg+xml \ No newline at end of file diff --git a/launcher/resources/OSX/scalable/launch.svg b/launcher/resources/OSX/scalable/launch.svg new file mode 100644 index 0000000..fb18916 --- /dev/null +++ b/launcher/resources/OSX/scalable/launch.svg @@ -0,0 +1,33 @@ + + + + diff --git a/launcher/resources/OSX/scalable/loadermods.svg b/launcher/resources/OSX/scalable/loadermods.svg new file mode 100644 index 0000000..76951eb --- /dev/null +++ b/launcher/resources/OSX/scalable/loadermods.svg @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/launcher/resources/OSX/scalable/log.svg b/launcher/resources/OSX/scalable/log.svg new file mode 100644 index 0000000..0ac45d5 --- /dev/null +++ b/launcher/resources/OSX/scalable/log.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + diff --git a/launcher/resources/OSX/scalable/minecraft.svg b/launcher/resources/OSX/scalable/minecraft.svg new file mode 100644 index 0000000..86c915b --- /dev/null +++ b/launcher/resources/OSX/scalable/minecraft.svg @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/launcher/resources/OSX/scalable/new.svg b/launcher/resources/OSX/scalable/new.svg new file mode 100644 index 0000000..79ee87b --- /dev/null +++ b/launcher/resources/OSX/scalable/new.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + diff --git a/launcher/resources/OSX/scalable/news.svg b/launcher/resources/OSX/scalable/news.svg new file mode 100644 index 0000000..b8ce3cd --- /dev/null +++ b/launcher/resources/OSX/scalable/news.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + diff --git a/launcher/resources/OSX/scalable/notes.svg b/launcher/resources/OSX/scalable/notes.svg new file mode 100644 index 0000000..c2e95cf --- /dev/null +++ b/launcher/resources/OSX/scalable/notes.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + diff --git a/launcher/resources/OSX/scalable/patreon.svg b/launcher/resources/OSX/scalable/patreon.svg new file mode 100644 index 0000000..4f0da3e --- /dev/null +++ b/launcher/resources/OSX/scalable/patreon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + diff --git a/launcher/resources/OSX/scalable/proxy.svg b/launcher/resources/OSX/scalable/proxy.svg new file mode 100644 index 0000000..99acaa2 --- /dev/null +++ b/launcher/resources/OSX/scalable/proxy.svg @@ -0,0 +1,16 @@ + + + + + + + + + + diff --git a/launcher/resources/OSX/scalable/refresh.svg b/launcher/resources/OSX/scalable/refresh.svg new file mode 100644 index 0000000..c97489c --- /dev/null +++ b/launcher/resources/OSX/scalable/refresh.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + diff --git a/launcher/resources/OSX/scalable/rename.svg b/launcher/resources/OSX/scalable/rename.svg new file mode 100644 index 0000000..83ae5cb --- /dev/null +++ b/launcher/resources/OSX/scalable/rename.svg @@ -0,0 +1,27 @@ + + + + diff --git a/launcher/resources/OSX/scalable/resourcepacks.svg b/launcher/resources/OSX/scalable/resourcepacks.svg new file mode 100644 index 0000000..c85d4e3 --- /dev/null +++ b/launcher/resources/OSX/scalable/resourcepacks.svg @@ -0,0 +1,17 @@ + + + + + + + + + + diff --git a/launcher/resources/OSX/scalable/screenshots.svg b/launcher/resources/OSX/scalable/screenshots.svg new file mode 100644 index 0000000..12df0c8 --- /dev/null +++ b/launcher/resources/OSX/scalable/screenshots.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + diff --git a/launcher/resources/OSX/scalable/settings.svg b/launcher/resources/OSX/scalable/settings.svg new file mode 100644 index 0000000..dcdd9f1 --- /dev/null +++ b/launcher/resources/OSX/scalable/settings.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + diff --git a/launcher/resources/OSX/scalable/shaderpacks.svg b/launcher/resources/OSX/scalable/shaderpacks.svg new file mode 100644 index 0000000..cf8251b --- /dev/null +++ b/launcher/resources/OSX/scalable/shaderpacks.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + diff --git a/launcher/resources/OSX/scalable/shortcut.svg b/launcher/resources/OSX/scalable/shortcut.svg new file mode 100644 index 0000000..a2b7488 --- /dev/null +++ b/launcher/resources/OSX/scalable/shortcut.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/launcher/resources/OSX/scalable/status-bad.svg b/launcher/resources/OSX/scalable/status-bad.svg new file mode 100644 index 0000000..add7a6f --- /dev/null +++ b/launcher/resources/OSX/scalable/status-bad.svg @@ -0,0 +1,11 @@ + + + + + + + diff --git a/launcher/resources/OSX/scalable/status-good.svg b/launcher/resources/OSX/scalable/status-good.svg new file mode 100644 index 0000000..f10da75 --- /dev/null +++ b/launcher/resources/OSX/scalable/status-good.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + diff --git a/launcher/resources/OSX/scalable/status-yellow.svg b/launcher/resources/OSX/scalable/status-yellow.svg new file mode 100644 index 0000000..fba697b --- /dev/null +++ b/launcher/resources/OSX/scalable/status-yellow.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + diff --git a/launcher/resources/OSX/scalable/tag.svg b/launcher/resources/OSX/scalable/tag.svg new file mode 100644 index 0000000..56438e3 --- /dev/null +++ b/launcher/resources/OSX/scalable/tag.svg @@ -0,0 +1,35 @@ + + + + + + + + + diff --git a/launcher/resources/OSX/scalable/viewfolder.svg b/launcher/resources/OSX/scalable/viewfolder.svg new file mode 100644 index 0000000..682c72c --- /dev/null +++ b/launcher/resources/OSX/scalable/viewfolder.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + diff --git a/launcher/resources/OSX/scalable/worlds.svg b/launcher/resources/OSX/scalable/worlds.svg new file mode 100644 index 0000000..b149127 --- /dev/null +++ b/launcher/resources/OSX/scalable/worlds.svg @@ -0,0 +1,58 @@ + +image/svg+xml \ No newline at end of file diff --git a/launcher/resources/assets/underconstruction.png b/launcher/resources/assets/underconstruction.png new file mode 100644 index 0000000..5f2fdf9 Binary files /dev/null and b/launcher/resources/assets/underconstruction.png differ diff --git a/launcher/resources/backgrounds/backgrounds.qrc b/launcher/resources/backgrounds/backgrounds.qrc new file mode 100644 index 0000000..f8ad3ec --- /dev/null +++ b/launcher/resources/backgrounds/backgrounds.qrc @@ -0,0 +1,31 @@ + + + + kitteh.png + kitteh-xmas.png + kitteh-bday.png + kitteh-spooky.png + rory.png + rory-xmas.png + rory-bday.png + rory-spooky.png + rory-flat.png + rory-flat-xmas.png + rory-flat-bday.png + rory-flat-spooky.png + + + + + teawie.png + + teawie-xmas.png + + teawie-bday.png + + teawie-spooky.png + + + racked_ru.png + + diff --git a/launcher/resources/backgrounds/kitteh-bday.png b/launcher/resources/backgrounds/kitteh-bday.png new file mode 100644 index 0000000..f4a7bbc Binary files /dev/null and b/launcher/resources/backgrounds/kitteh-bday.png differ diff --git a/launcher/resources/backgrounds/kitteh-spooky.png b/launcher/resources/backgrounds/kitteh-spooky.png new file mode 100644 index 0000000..bb3765f Binary files /dev/null and b/launcher/resources/backgrounds/kitteh-spooky.png differ diff --git a/launcher/resources/backgrounds/kitteh-xmas.png b/launcher/resources/backgrounds/kitteh-xmas.png new file mode 100644 index 0000000..1e92e90 Binary files /dev/null and b/launcher/resources/backgrounds/kitteh-xmas.png differ diff --git a/launcher/resources/backgrounds/kitteh.png b/launcher/resources/backgrounds/kitteh.png new file mode 100644 index 0000000..fa3d525 Binary files /dev/null and b/launcher/resources/backgrounds/kitteh.png differ diff --git a/launcher/resources/backgrounds/racked_ru.png b/launcher/resources/backgrounds/racked_ru.png new file mode 100644 index 0000000..4f83267 Binary files /dev/null and b/launcher/resources/backgrounds/racked_ru.png differ diff --git a/launcher/resources/backgrounds/rory-bday.png b/launcher/resources/backgrounds/rory-bday.png new file mode 100644 index 0000000..8c79692 Binary files /dev/null and b/launcher/resources/backgrounds/rory-bday.png differ diff --git a/launcher/resources/backgrounds/rory-flat-bday.png b/launcher/resources/backgrounds/rory-flat-bday.png new file mode 100644 index 0000000..94c4509 Binary files /dev/null and b/launcher/resources/backgrounds/rory-flat-bday.png differ diff --git a/launcher/resources/backgrounds/rory-flat-spooky.png b/launcher/resources/backgrounds/rory-flat-spooky.png new file mode 100644 index 0000000..4a0046c Binary files /dev/null and b/launcher/resources/backgrounds/rory-flat-spooky.png differ diff --git a/launcher/resources/backgrounds/rory-flat-xmas.png b/launcher/resources/backgrounds/rory-flat-xmas.png new file mode 100644 index 0000000..e6278ed Binary files /dev/null and b/launcher/resources/backgrounds/rory-flat-xmas.png differ diff --git a/launcher/resources/backgrounds/rory-flat.png b/launcher/resources/backgrounds/rory-flat.png new file mode 100644 index 0000000..22fe618 Binary files /dev/null and b/launcher/resources/backgrounds/rory-flat.png differ diff --git a/launcher/resources/backgrounds/rory-spooky.png b/launcher/resources/backgrounds/rory-spooky.png new file mode 100644 index 0000000..1aa9286 Binary files /dev/null and b/launcher/resources/backgrounds/rory-spooky.png differ diff --git a/launcher/resources/backgrounds/rory-xmas.png b/launcher/resources/backgrounds/rory-xmas.png new file mode 100644 index 0000000..f33e926 Binary files /dev/null and b/launcher/resources/backgrounds/rory-xmas.png differ diff --git a/launcher/resources/backgrounds/rory.png b/launcher/resources/backgrounds/rory.png new file mode 100644 index 0000000..5570499 Binary files /dev/null and b/launcher/resources/backgrounds/rory.png differ diff --git a/launcher/resources/backgrounds/teawie-bday.png b/launcher/resources/backgrounds/teawie-bday.png new file mode 100644 index 0000000..b4621f9 Binary files /dev/null and b/launcher/resources/backgrounds/teawie-bday.png differ diff --git a/launcher/resources/backgrounds/teawie-spooky.png b/launcher/resources/backgrounds/teawie-spooky.png new file mode 100644 index 0000000..194d8ab Binary files /dev/null and b/launcher/resources/backgrounds/teawie-spooky.png differ diff --git a/launcher/resources/backgrounds/teawie-xmas.png b/launcher/resources/backgrounds/teawie-xmas.png new file mode 100644 index 0000000..54a09ae Binary files /dev/null and b/launcher/resources/backgrounds/teawie-xmas.png differ diff --git a/launcher/resources/backgrounds/teawie.png b/launcher/resources/backgrounds/teawie.png new file mode 100644 index 0000000..99b60ad Binary files /dev/null and b/launcher/resources/backgrounds/teawie.png differ diff --git a/launcher/resources/breeze_dark/breeze_dark.qrc b/launcher/resources/breeze_dark/breeze_dark.qrc new file mode 100644 index 0000000..585f2c6 --- /dev/null +++ b/launcher/resources/breeze_dark/breeze_dark.qrc @@ -0,0 +1,48 @@ + + + index.theme + scalable/about.svg + scalable/accounts.svg + scalable/bug.svg + scalable/centralmods.svg + scalable/checkupdate.svg + scalable/copy.svg + scalable/coremods.svg + scalable/custom-commands.svg + scalable/datapacks.svg + scalable/discord.svg + scalable/externaltools.svg + scalable/help.svg + scalable/instance-settings.svg + scalable/jarmods.svg + scalable/java.svg + scalable/language.svg + scalable/loadermods.svg + scalable/log.svg + scalable/minecraft.svg + scalable/matrix.svg + scalable/new.svg + scalable/news.svg + scalable/notes.svg + scalable/proxy.svg + scalable/reddit-alien.svg + scalable/refresh.svg + scalable/resourcepacks.svg + scalable/shaderpacks.svg + scalable/shortcut.svg + scalable/screenshots.svg + scalable/settings.svg + scalable/status-bad.svg + scalable/status-good.svg + scalable/status-yellow.svg + scalable/viewfolder.svg + scalable/worlds.svg + scalable/delete.svg + scalable/tag.svg + scalable/export.svg + scalable/rename.svg + scalable/launch.svg + scalable/server.svg + scalable/appearance.svg + + diff --git a/launcher/resources/breeze_dark/index.theme b/launcher/resources/breeze_dark/index.theme new file mode 100644 index 0000000..f9f6f4d --- /dev/null +++ b/launcher/resources/breeze_dark/index.theme @@ -0,0 +1,11 @@ +[Icon Theme] +Name=Breeze Dark +Comment=Breeze Dark Icons +Inherits=multimc +Directories=scalable + +[scalable] +Size=48 +Type=Scalable +MinSize=16 +MaxSize=256 diff --git a/launcher/resources/breeze_dark/scalable/about.svg b/launcher/resources/breeze_dark/scalable/about.svg new file mode 100644 index 0000000..856d1b2 --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/about.svg @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/launcher/resources/breeze_dark/scalable/accounts.svg b/launcher/resources/breeze_dark/scalable/accounts.svg new file mode 100644 index 0000000..fbb5195 --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/accounts.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/launcher/resources/breeze_dark/scalable/appearance.svg b/launcher/resources/breeze_dark/scalable/appearance.svg new file mode 100644 index 0000000..93e6ffa --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/appearance.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/launcher/resources/breeze_dark/scalable/bug.svg b/launcher/resources/breeze_dark/scalable/bug.svg new file mode 100644 index 0000000..6ddf482 --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/bug.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/launcher/resources/breeze_dark/scalable/centralmods.svg b/launcher/resources/breeze_dark/scalable/centralmods.svg new file mode 100644 index 0000000..4035e51 --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/centralmods.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/launcher/resources/breeze_dark/scalable/checkupdate.svg b/launcher/resources/breeze_dark/scalable/checkupdate.svg new file mode 100644 index 0000000..cc5dfc1 --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/checkupdate.svg @@ -0,0 +1,14 @@ + + + + + + diff --git a/launcher/resources/breeze_dark/scalable/copy.svg b/launcher/resources/breeze_dark/scalable/copy.svg new file mode 100644 index 0000000..fe4a36a --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/copy.svg @@ -0,0 +1,11 @@ + + + + + + + diff --git a/launcher/resources/breeze_dark/scalable/coremods.svg b/launcher/resources/breeze_dark/scalable/coremods.svg new file mode 100644 index 0000000..ec4ecea --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/coremods.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/launcher/resources/breeze_dark/scalable/custom-commands.svg b/launcher/resources/breeze_dark/scalable/custom-commands.svg new file mode 100644 index 0000000..44efd39 --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/custom-commands.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/launcher/resources/breeze_dark/scalable/datapacks.svg b/launcher/resources/breeze_dark/scalable/datapacks.svg new file mode 100644 index 0000000..308c4a2 --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/datapacks.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/launcher/resources/breeze_dark/scalable/delete.svg b/launcher/resources/breeze_dark/scalable/delete.svg new file mode 100644 index 0000000..c707458 --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/delete.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/launcher/resources/breeze_dark/scalable/discord.svg b/launcher/resources/breeze_dark/scalable/discord.svg new file mode 100644 index 0000000..2e6d889 --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/discord.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/launcher/resources/breeze_dark/scalable/export.svg b/launcher/resources/breeze_dark/scalable/export.svg new file mode 100644 index 0000000..b1fe39d --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/export.svg @@ -0,0 +1,11 @@ + + + + + + + diff --git a/launcher/resources/breeze_dark/scalable/externaltools.svg b/launcher/resources/breeze_dark/scalable/externaltools.svg new file mode 100644 index 0000000..dd19fb9 --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/externaltools.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/launcher/resources/breeze_dark/scalable/help.svg b/launcher/resources/breeze_dark/scalable/help.svg new file mode 100644 index 0000000..b273a8b --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/help.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/launcher/resources/breeze_dark/scalable/instance-settings.svg b/launcher/resources/breeze_dark/scalable/instance-settings.svg new file mode 100644 index 0000000..c5f0504 --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/instance-settings.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/launcher/resources/breeze_dark/scalable/jarmods.svg b/launcher/resources/breeze_dark/scalable/jarmods.svg new file mode 100644 index 0000000..49a45d3 --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/jarmods.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/launcher/resources/breeze_dark/scalable/java.svg b/launcher/resources/breeze_dark/scalable/java.svg new file mode 100644 index 0000000..7149981 --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/java.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/launcher/resources/breeze_dark/scalable/language.svg b/launcher/resources/breeze_dark/scalable/language.svg new file mode 100644 index 0000000..239cdf9 --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/language.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/launcher/resources/breeze_dark/scalable/launch.svg b/launcher/resources/breeze_dark/scalable/launch.svg new file mode 100644 index 0000000..25c5fab --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/launch.svg @@ -0,0 +1,8 @@ + + + + diff --git a/launcher/resources/breeze_dark/scalable/loadermods.svg b/launcher/resources/breeze_dark/scalable/loadermods.svg new file mode 100644 index 0000000..7bd8718 --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/loadermods.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/launcher/resources/breeze_dark/scalable/log.svg b/launcher/resources/breeze_dark/scalable/log.svg new file mode 100644 index 0000000..fcd83c4 --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/log.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/launcher/resources/breeze_dark/scalable/matrix.svg b/launcher/resources/breeze_dark/scalable/matrix.svg new file mode 100644 index 0000000..214f570 --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/matrix.svg @@ -0,0 +1,9 @@ + + + Matrix (protocol) logo + + + + + + \ No newline at end of file diff --git a/launcher/resources/breeze_dark/scalable/minecraft.svg b/launcher/resources/breeze_dark/scalable/minecraft.svg new file mode 100644 index 0000000..1d8d016 --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/minecraft.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/launcher/resources/breeze_dark/scalable/new.svg b/launcher/resources/breeze_dark/scalable/new.svg new file mode 100644 index 0000000..3160172 --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/new.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/launcher/resources/breeze_dark/scalable/news.svg b/launcher/resources/breeze_dark/scalable/news.svg new file mode 100644 index 0000000..a2ff0c8 --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/news.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/launcher/resources/breeze_dark/scalable/notes.svg b/launcher/resources/breeze_dark/scalable/notes.svg new file mode 100644 index 0000000..6452d3c --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/notes.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/launcher/resources/breeze_dark/scalable/patreon.svg b/launcher/resources/breeze_dark/scalable/patreon.svg new file mode 100644 index 0000000..7f98dd1 --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/patreon.svg @@ -0,0 +1,3 @@ + + + diff --git a/launcher/resources/breeze_dark/scalable/proxy.svg b/launcher/resources/breeze_dark/scalable/proxy.svg new file mode 100644 index 0000000..c6efb17 --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/proxy.svg @@ -0,0 +1,14 @@ + + + + + + diff --git a/launcher/resources/breeze_dark/scalable/reddit-alien.svg b/launcher/resources/breeze_dark/scalable/reddit-alien.svg new file mode 100644 index 0000000..00f82bb --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/reddit-alien.svg @@ -0,0 +1,3 @@ + + + diff --git a/launcher/resources/breeze_dark/scalable/refresh.svg b/launcher/resources/breeze_dark/scalable/refresh.svg new file mode 100644 index 0000000..7b48646 --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/refresh.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/launcher/resources/breeze_dark/scalable/rename.svg b/launcher/resources/breeze_dark/scalable/rename.svg new file mode 100644 index 0000000..6a84496 --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/rename.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/launcher/resources/breeze_dark/scalable/resourcepacks.svg b/launcher/resources/breeze_dark/scalable/resourcepacks.svg new file mode 100644 index 0000000..0986c21 --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/resourcepacks.svg @@ -0,0 +1,11 @@ + + + + + + + diff --git a/launcher/resources/breeze_dark/scalable/screenshots.svg b/launcher/resources/breeze_dark/scalable/screenshots.svg new file mode 100644 index 0000000..a10ed71 --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/screenshots.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/launcher/resources/breeze_dark/scalable/server.svg b/launcher/resources/breeze_dark/scalable/server.svg new file mode 100644 index 0000000..7d9af3e --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/server.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/launcher/resources/breeze_dark/scalable/settings.svg b/launcher/resources/breeze_dark/scalable/settings.svg new file mode 100644 index 0000000..009d815 --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/settings.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/launcher/resources/breeze_dark/scalable/shaderpacks.svg b/launcher/resources/breeze_dark/scalable/shaderpacks.svg new file mode 100644 index 0000000..b288794 --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/shaderpacks.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/launcher/resources/breeze_dark/scalable/shortcut.svg b/launcher/resources/breeze_dark/scalable/shortcut.svg new file mode 100644 index 0000000..5559be1 --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/shortcut.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/launcher/resources/breeze_dark/scalable/status-bad.svg b/launcher/resources/breeze_dark/scalable/status-bad.svg new file mode 100644 index 0000000..6fc3137 --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/status-bad.svg @@ -0,0 +1,9 @@ + + + + + diff --git a/launcher/resources/breeze_dark/scalable/status-good.svg b/launcher/resources/breeze_dark/scalable/status-good.svg new file mode 100644 index 0000000..eb8bc03 --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/status-good.svg @@ -0,0 +1,10 @@ + + + + + diff --git a/launcher/resources/breeze_dark/scalable/status-yellow.svg b/launcher/resources/breeze_dark/scalable/status-yellow.svg new file mode 100644 index 0000000..1dc4d0f --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/status-yellow.svg @@ -0,0 +1,9 @@ + + + + + diff --git a/launcher/resources/breeze_dark/scalable/tag.svg b/launcher/resources/breeze_dark/scalable/tag.svg new file mode 100644 index 0000000..b54b515 --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/tag.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/launcher/resources/breeze_dark/scalable/viewfolder.svg b/launcher/resources/breeze_dark/scalable/viewfolder.svg new file mode 100644 index 0000000..0189b95 --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/viewfolder.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/launcher/resources/breeze_dark/scalable/worlds.svg b/launcher/resources/breeze_dark/scalable/worlds.svg new file mode 100644 index 0000000..0cff826 --- /dev/null +++ b/launcher/resources/breeze_dark/scalable/worlds.svg @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/launcher/resources/breeze_light/breeze_light.qrc b/launcher/resources/breeze_light/breeze_light.qrc new file mode 100644 index 0000000..2b0adba --- /dev/null +++ b/launcher/resources/breeze_light/breeze_light.qrc @@ -0,0 +1,48 @@ + + + index.theme + scalable/about.svg + scalable/accounts.svg + scalable/bug.svg + scalable/centralmods.svg + scalable/checkupdate.svg + scalable/copy.svg + scalable/coremods.svg + scalable/custom-commands.svg + scalable/datapacks.svg + scalable/discord.svg + scalable/externaltools.svg + scalable/help.svg + scalable/instance-settings.svg + scalable/jarmods.svg + scalable/java.svg + scalable/language.svg + scalable/loadermods.svg + scalable/log.svg + scalable/minecraft.svg + scalable/matrix.svg + scalable/new.svg + scalable/news.svg + scalable/notes.svg + scalable/proxy.svg + scalable/reddit-alien.svg + scalable/refresh.svg + scalable/resourcepacks.svg + scalable/shaderpacks.svg + scalable/shortcut.svg + scalable/screenshots.svg + scalable/settings.svg + scalable/status-bad.svg + scalable/status-good.svg + scalable/status-yellow.svg + scalable/viewfolder.svg + scalable/worlds.svg + scalable/delete.svg + scalable/tag.svg + scalable/export.svg + scalable/rename.svg + scalable/launch.svg + scalable/server.svg + scalable/appearance.svg + + diff --git a/launcher/resources/breeze_light/index.theme b/launcher/resources/breeze_light/index.theme new file mode 100644 index 0000000..126d42d --- /dev/null +++ b/launcher/resources/breeze_light/index.theme @@ -0,0 +1,11 @@ +[Icon Theme] +Name=Breeze Light +Comment=Breeze Light Icons +Inherits=multimc +Directories=scalable + +[scalable] +Size=48 +Type=Scalable +MinSize=16 +MaxSize=256 diff --git a/launcher/resources/breeze_light/scalable/about.svg b/launcher/resources/breeze_light/scalable/about.svg new file mode 100644 index 0000000..ea1dc02 --- /dev/null +++ b/launcher/resources/breeze_light/scalable/about.svg @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/launcher/resources/breeze_light/scalable/accounts.svg b/launcher/resources/breeze_light/scalable/accounts.svg new file mode 100644 index 0000000..8a542f3 --- /dev/null +++ b/launcher/resources/breeze_light/scalable/accounts.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/launcher/resources/breeze_light/scalable/appearance.svg b/launcher/resources/breeze_light/scalable/appearance.svg new file mode 100644 index 0000000..6e6d64a --- /dev/null +++ b/launcher/resources/breeze_light/scalable/appearance.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/launcher/resources/breeze_light/scalable/bug.svg b/launcher/resources/breeze_light/scalable/bug.svg new file mode 100644 index 0000000..4f41ad6 --- /dev/null +++ b/launcher/resources/breeze_light/scalable/bug.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/launcher/resources/breeze_light/scalable/centralmods.svg b/launcher/resources/breeze_light/scalable/centralmods.svg new file mode 100644 index 0000000..174206c --- /dev/null +++ b/launcher/resources/breeze_light/scalable/centralmods.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/launcher/resources/breeze_light/scalable/checkupdate.svg b/launcher/resources/breeze_light/scalable/checkupdate.svg new file mode 100644 index 0000000..06b3182 --- /dev/null +++ b/launcher/resources/breeze_light/scalable/checkupdate.svg @@ -0,0 +1,14 @@ + + + + + + diff --git a/launcher/resources/breeze_light/scalable/copy.svg b/launcher/resources/breeze_light/scalable/copy.svg new file mode 100644 index 0000000..2557953 --- /dev/null +++ b/launcher/resources/breeze_light/scalable/copy.svg @@ -0,0 +1,11 @@ + + + + + + + diff --git a/launcher/resources/breeze_light/scalable/coremods.svg b/launcher/resources/breeze_light/scalable/coremods.svg new file mode 100644 index 0000000..e4615cf --- /dev/null +++ b/launcher/resources/breeze_light/scalable/coremods.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/launcher/resources/breeze_light/scalable/custom-commands.svg b/launcher/resources/breeze_light/scalable/custom-commands.svg new file mode 100644 index 0000000..b2ac78c --- /dev/null +++ b/launcher/resources/breeze_light/scalable/custom-commands.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/launcher/resources/breeze_light/scalable/datapacks.svg b/launcher/resources/breeze_light/scalable/datapacks.svg new file mode 100644 index 0000000..f5d4acc --- /dev/null +++ b/launcher/resources/breeze_light/scalable/datapacks.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/launcher/resources/breeze_light/scalable/delete.svg b/launcher/resources/breeze_light/scalable/delete.svg new file mode 100644 index 0000000..f2aea6e --- /dev/null +++ b/launcher/resources/breeze_light/scalable/delete.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/launcher/resources/breeze_light/scalable/discord.svg b/launcher/resources/breeze_light/scalable/discord.svg new file mode 100644 index 0000000..136239f --- /dev/null +++ b/launcher/resources/breeze_light/scalable/discord.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/launcher/resources/breeze_light/scalable/export.svg b/launcher/resources/breeze_light/scalable/export.svg new file mode 100644 index 0000000..d6314bd --- /dev/null +++ b/launcher/resources/breeze_light/scalable/export.svg @@ -0,0 +1,11 @@ + + + + + + + diff --git a/launcher/resources/breeze_light/scalable/externaltools.svg b/launcher/resources/breeze_light/scalable/externaltools.svg new file mode 100644 index 0000000..c965b6c --- /dev/null +++ b/launcher/resources/breeze_light/scalable/externaltools.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/launcher/resources/breeze_light/scalable/help.svg b/launcher/resources/breeze_light/scalable/help.svg new file mode 100644 index 0000000..bcd14e0 --- /dev/null +++ b/launcher/resources/breeze_light/scalable/help.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/launcher/resources/breeze_light/scalable/instance-settings.svg b/launcher/resources/breeze_light/scalable/instance-settings.svg new file mode 100644 index 0000000..69854d7 --- /dev/null +++ b/launcher/resources/breeze_light/scalable/instance-settings.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/launcher/resources/breeze_light/scalable/jarmods.svg b/launcher/resources/breeze_light/scalable/jarmods.svg new file mode 100644 index 0000000..72a8e50 --- /dev/null +++ b/launcher/resources/breeze_light/scalable/jarmods.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/launcher/resources/breeze_light/scalable/java.svg b/launcher/resources/breeze_light/scalable/java.svg new file mode 100644 index 0000000..ff86c9c --- /dev/null +++ b/launcher/resources/breeze_light/scalable/java.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/launcher/resources/breeze_light/scalable/language.svg b/launcher/resources/breeze_light/scalable/language.svg new file mode 100644 index 0000000..3d56d33 --- /dev/null +++ b/launcher/resources/breeze_light/scalable/language.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/launcher/resources/breeze_light/scalable/launch.svg b/launcher/resources/breeze_light/scalable/launch.svg new file mode 100644 index 0000000..678fd09 --- /dev/null +++ b/launcher/resources/breeze_light/scalable/launch.svg @@ -0,0 +1,8 @@ + + + + diff --git a/launcher/resources/breeze_light/scalable/loadermods.svg b/launcher/resources/breeze_light/scalable/loadermods.svg new file mode 100644 index 0000000..4fb0f96 --- /dev/null +++ b/launcher/resources/breeze_light/scalable/loadermods.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/launcher/resources/breeze_light/scalable/log.svg b/launcher/resources/breeze_light/scalable/log.svg new file mode 100644 index 0000000..cf9c9b2 --- /dev/null +++ b/launcher/resources/breeze_light/scalable/log.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/launcher/resources/breeze_light/scalable/matrix.svg b/launcher/resources/breeze_light/scalable/matrix.svg new file mode 100644 index 0000000..4745efc --- /dev/null +++ b/launcher/resources/breeze_light/scalable/matrix.svg @@ -0,0 +1,9 @@ + + + Matrix (protocol) logo + + + + + + \ No newline at end of file diff --git a/launcher/resources/breeze_light/scalable/minecraft.svg b/launcher/resources/breeze_light/scalable/minecraft.svg new file mode 100644 index 0000000..1ffb456 --- /dev/null +++ b/launcher/resources/breeze_light/scalable/minecraft.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/launcher/resources/breeze_light/scalable/new.svg b/launcher/resources/breeze_light/scalable/new.svg new file mode 100644 index 0000000..6434a18 --- /dev/null +++ b/launcher/resources/breeze_light/scalable/new.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/launcher/resources/breeze_light/scalable/news.svg b/launcher/resources/breeze_light/scalable/news.svg new file mode 100644 index 0000000..3e3ebe9 --- /dev/null +++ b/launcher/resources/breeze_light/scalable/news.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/launcher/resources/breeze_light/scalable/notes.svg b/launcher/resources/breeze_light/scalable/notes.svg new file mode 100644 index 0000000..a8eaf27 --- /dev/null +++ b/launcher/resources/breeze_light/scalable/notes.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/launcher/resources/breeze_light/scalable/patreon.svg b/launcher/resources/breeze_light/scalable/patreon.svg new file mode 100644 index 0000000..e12f1f8 --- /dev/null +++ b/launcher/resources/breeze_light/scalable/patreon.svg @@ -0,0 +1,3 @@ + + + diff --git a/launcher/resources/breeze_light/scalable/proxy.svg b/launcher/resources/breeze_light/scalable/proxy.svg new file mode 100644 index 0000000..2e67ff6 --- /dev/null +++ b/launcher/resources/breeze_light/scalable/proxy.svg @@ -0,0 +1,14 @@ + + + + + + diff --git a/launcher/resources/breeze_light/scalable/reddit-alien.svg b/launcher/resources/breeze_light/scalable/reddit-alien.svg new file mode 100644 index 0000000..93b8eed --- /dev/null +++ b/launcher/resources/breeze_light/scalable/reddit-alien.svg @@ -0,0 +1,3 @@ + + + diff --git a/launcher/resources/breeze_light/scalable/refresh.svg b/launcher/resources/breeze_light/scalable/refresh.svg new file mode 100644 index 0000000..ecd2b39 --- /dev/null +++ b/launcher/resources/breeze_light/scalable/refresh.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/launcher/resources/breeze_light/scalable/rename.svg b/launcher/resources/breeze_light/scalable/rename.svg new file mode 100644 index 0000000..18ccc58 --- /dev/null +++ b/launcher/resources/breeze_light/scalable/rename.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/launcher/resources/breeze_light/scalable/resourcepacks.svg b/launcher/resources/breeze_light/scalable/resourcepacks.svg new file mode 100644 index 0000000..913d3c1 --- /dev/null +++ b/launcher/resources/breeze_light/scalable/resourcepacks.svg @@ -0,0 +1,11 @@ + + + + + + + diff --git a/launcher/resources/breeze_light/scalable/screenshots.svg b/launcher/resources/breeze_light/scalable/screenshots.svg new file mode 100644 index 0000000..d984b33 --- /dev/null +++ b/launcher/resources/breeze_light/scalable/screenshots.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/launcher/resources/breeze_light/scalable/server.svg b/launcher/resources/breeze_light/scalable/server.svg new file mode 100644 index 0000000..52d7dd7 --- /dev/null +++ b/launcher/resources/breeze_light/scalable/server.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/launcher/resources/breeze_light/scalable/settings.svg b/launcher/resources/breeze_light/scalable/settings.svg new file mode 100644 index 0000000..19e86e2 --- /dev/null +++ b/launcher/resources/breeze_light/scalable/settings.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/launcher/resources/breeze_light/scalable/shaderpacks.svg b/launcher/resources/breeze_light/scalable/shaderpacks.svg new file mode 100644 index 0000000..591c6af --- /dev/null +++ b/launcher/resources/breeze_light/scalable/shaderpacks.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/launcher/resources/breeze_light/scalable/shortcut.svg b/launcher/resources/breeze_light/scalable/shortcut.svg new file mode 100644 index 0000000..426769d --- /dev/null +++ b/launcher/resources/breeze_light/scalable/shortcut.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/launcher/resources/breeze_light/scalable/status-bad.svg b/launcher/resources/breeze_light/scalable/status-bad.svg new file mode 100644 index 0000000..6fc3137 --- /dev/null +++ b/launcher/resources/breeze_light/scalable/status-bad.svg @@ -0,0 +1,9 @@ + + + + + diff --git a/launcher/resources/breeze_light/scalable/status-good.svg b/launcher/resources/breeze_light/scalable/status-good.svg new file mode 100644 index 0000000..eb8bc03 --- /dev/null +++ b/launcher/resources/breeze_light/scalable/status-good.svg @@ -0,0 +1,10 @@ + + + + + diff --git a/launcher/resources/breeze_light/scalable/status-yellow.svg b/launcher/resources/breeze_light/scalable/status-yellow.svg new file mode 100644 index 0000000..1dc4d0f --- /dev/null +++ b/launcher/resources/breeze_light/scalable/status-yellow.svg @@ -0,0 +1,9 @@ + + + + + diff --git a/launcher/resources/breeze_light/scalable/tag.svg b/launcher/resources/breeze_light/scalable/tag.svg new file mode 100644 index 0000000..4887d12 --- /dev/null +++ b/launcher/resources/breeze_light/scalable/tag.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/launcher/resources/breeze_light/scalable/viewfolder.svg b/launcher/resources/breeze_light/scalable/viewfolder.svg new file mode 100644 index 0000000..4a8498c --- /dev/null +++ b/launcher/resources/breeze_light/scalable/viewfolder.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/launcher/resources/breeze_light/scalable/worlds.svg b/launcher/resources/breeze_light/scalable/worlds.svg new file mode 100644 index 0000000..543cc55 --- /dev/null +++ b/launcher/resources/breeze_light/scalable/worlds.svg @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/launcher/resources/documents/credits.html b/launcher/resources/documents/credits.html new file mode 100644 index 0000000..7ff470a --- /dev/null +++ b/launcher/resources/documents/credits.html @@ -0,0 +1,41 @@ +
    +

    %1

    +

    Sefa Eyeoglu (Scrumplex) <Website>

    +

    d-513 <GitHub>

    +

    txtsd <Website>

    +

    timoreo <GitHub>

    +

    ZekeZ <GitHub>

    +

    cozyGalvinism <GitHub>

    +

    DioEgizio <GitHub>

    +

    flowln <GitHub>

    +

    ViRb3 <GitHub>

    +

    Rachel Powers (Ryex) <GitHub>

    +

    TayouVR <GitHub>

    +

    TheKodeToad <GitHub>

    +

    getchoo <GitHub>

    +

    Alexandru Tripon (Trial97) <GitHub>

    +
    +

    %2

    +

    Andrew Okin <forkk@forkk.net>

    +

    Petr Mrázek <peterix@gmail.com>

    +

    Sky Welch <multimc@bunnies.io>

    +

    Jan (02JanDal) <02jandal@gmail.com>

    +

    RoboSky <@RoboSky_>

    +
    +

    %3

    +

    Boba <Website>

    +

    AutiOne <Website>

    +

    Fulmine <Website>

    +

    ely <GitHub>

    +

    gon sawa <GitHub>

    +

    Pankakes

    +

    tobimori <GitHub>

    +

    Orochimarufan <orochimarufan.x3@gmail.com>

    +

    TakSuyu <taksuyu@gmail.com>

    +

    Kilobyte <stiepen22@gmx.de>

    +

    Rootbear75 <@rootbear75>

    +

    Zeker Zhayard <@Zeker_Zhayard>

    +

    Everyone who helped establish our branding!

    +

    And everyone else who contributed!

    +
    +
    diff --git a/launcher/resources/documents/documents.qrc b/launcher/resources/documents/documents.qrc new file mode 100644 index 0000000..f4202c8 --- /dev/null +++ b/launcher/resources/documents/documents.qrc @@ -0,0 +1,8 @@ + + + + ../../../COPYING.md + credits.html + + + diff --git a/launcher/resources/flat/flat.qrc b/launcher/resources/flat/flat.qrc new file mode 100644 index 0000000..2cc9f46 --- /dev/null +++ b/launcher/resources/flat/flat.qrc @@ -0,0 +1,54 @@ + + + + index.theme + scalable/about.svg + scalable/accounts.svg + scalable/bug.svg + scalable/cat.svg + scalable/centralmods.svg + scalable/checkupdate.svg + scalable/copy.svg + scalable/coremods.svg + scalable/custom-commands.svg + scalable/datapacks.svg + scalable/discord.svg + scalable/externaltools.svg + scalable/help.svg + scalable/instance-settings.svg + scalable/jarmods.svg + scalable/java.svg + scalable/language.svg + scalable/loadermods.svg + scalable/log.svg + scalable/minecraft.svg + scalable/new.svg + scalable/news.svg + scalable/notes.svg + scalable/packages.svg + scalable/proxy.svg + scalable/quickmods.svg + scalable/reddit-alien.svg + scalable/refresh.svg + scalable/resourcepacks.svg + scalable/shaderpacks.svg + scalable/screenshot-placeholder.svg + scalable/screenshots.svg + scalable/settings.svg + scalable/shortcut.svg + scalable/star.svg + scalable/status-bad.svg + scalable/status-good.svg + scalable/status-running.svg + scalable/status-yellow.svg + scalable/viewfolder.svg + scalable/worlds.svg + scalable/delete.svg + scalable/tag.svg + scalable/export.svg + scalable/rename.svg + scalable/server.svg + scalable/launch.svg + scalable/appearance.svg + + diff --git a/launcher/resources/flat/index.theme b/launcher/resources/flat/index.theme new file mode 100644 index 0000000..34e27aa --- /dev/null +++ b/launcher/resources/flat/index.theme @@ -0,0 +1,11 @@ +[Icon Theme] +Name=Flat +Comment=Flat icons +Inherits=multimc +Directories=scalable + +[scalable] +Size=48 +Type=Scalable +MinSize=16 +MaxSize=256 diff --git a/launcher/resources/flat/scalable/about.svg b/launcher/resources/flat/scalable/about.svg new file mode 100644 index 0000000..4f85045 --- /dev/null +++ b/launcher/resources/flat/scalable/about.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/launcher/resources/flat/scalable/accounts.svg b/launcher/resources/flat/scalable/accounts.svg new file mode 100644 index 0000000..e6a1328 --- /dev/null +++ b/launcher/resources/flat/scalable/accounts.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/launcher/resources/flat/scalable/appearance.svg b/launcher/resources/flat/scalable/appearance.svg new file mode 100644 index 0000000..11dcb3f --- /dev/null +++ b/launcher/resources/flat/scalable/appearance.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/launcher/resources/flat/scalable/bug.svg b/launcher/resources/flat/scalable/bug.svg new file mode 100644 index 0000000..ea370fa --- /dev/null +++ b/launcher/resources/flat/scalable/bug.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/launcher/resources/flat/scalable/cat.svg b/launcher/resources/flat/scalable/cat.svg new file mode 100644 index 0000000..e90763b --- /dev/null +++ b/launcher/resources/flat/scalable/cat.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/launcher/resources/flat/scalable/centralmods.svg b/launcher/resources/flat/scalable/centralmods.svg new file mode 100644 index 0000000..c694662 --- /dev/null +++ b/launcher/resources/flat/scalable/centralmods.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/launcher/resources/flat/scalable/checkupdate.svg b/launcher/resources/flat/scalable/checkupdate.svg new file mode 100644 index 0000000..e6525a0 --- /dev/null +++ b/launcher/resources/flat/scalable/checkupdate.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/launcher/resources/flat/scalable/copy.svg b/launcher/resources/flat/scalable/copy.svg new file mode 100644 index 0000000..36986e0 --- /dev/null +++ b/launcher/resources/flat/scalable/copy.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/launcher/resources/flat/scalable/coremods.svg b/launcher/resources/flat/scalable/coremods.svg new file mode 100644 index 0000000..21a3450 --- /dev/null +++ b/launcher/resources/flat/scalable/coremods.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/launcher/resources/flat/scalable/custom-commands.svg b/launcher/resources/flat/scalable/custom-commands.svg new file mode 100644 index 0000000..f2e5878 --- /dev/null +++ b/launcher/resources/flat/scalable/custom-commands.svg @@ -0,0 +1 @@ + diff --git a/launcher/resources/flat/scalable/datapacks.svg b/launcher/resources/flat/scalable/datapacks.svg new file mode 100644 index 0000000..a35634b --- /dev/null +++ b/launcher/resources/flat/scalable/datapacks.svg @@ -0,0 +1,86 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/flat/scalable/delete.svg b/launcher/resources/flat/scalable/delete.svg new file mode 100644 index 0000000..89a0948 --- /dev/null +++ b/launcher/resources/flat/scalable/delete.svg @@ -0,0 +1 @@ + diff --git a/launcher/resources/flat/scalable/discord.svg b/launcher/resources/flat/scalable/discord.svg new file mode 100644 index 0000000..ad63180 --- /dev/null +++ b/launcher/resources/flat/scalable/discord.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/launcher/resources/flat/scalable/export.svg b/launcher/resources/flat/scalable/export.svg new file mode 100644 index 0000000..a3b711a --- /dev/null +++ b/launcher/resources/flat/scalable/export.svg @@ -0,0 +1 @@ + diff --git a/launcher/resources/flat/scalable/externaltools.svg b/launcher/resources/flat/scalable/externaltools.svg new file mode 100644 index 0000000..55820df --- /dev/null +++ b/launcher/resources/flat/scalable/externaltools.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/launcher/resources/flat/scalable/help.svg b/launcher/resources/flat/scalable/help.svg new file mode 100644 index 0000000..26d5d7f --- /dev/null +++ b/launcher/resources/flat/scalable/help.svg @@ -0,0 +1,17 @@ + + + + diff --git a/launcher/resources/flat/scalable/instance-settings.svg b/launcher/resources/flat/scalable/instance-settings.svg new file mode 100644 index 0000000..dd9d86e --- /dev/null +++ b/launcher/resources/flat/scalable/instance-settings.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/launcher/resources/flat/scalable/jarmods.svg b/launcher/resources/flat/scalable/jarmods.svg new file mode 100644 index 0000000..db90fa3 --- /dev/null +++ b/launcher/resources/flat/scalable/jarmods.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/launcher/resources/flat/scalable/java.svg b/launcher/resources/flat/scalable/java.svg new file mode 100644 index 0000000..dc19ee2 --- /dev/null +++ b/launcher/resources/flat/scalable/java.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/launcher/resources/flat/scalable/language.svg b/launcher/resources/flat/scalable/language.svg new file mode 100644 index 0000000..f4d3f2f --- /dev/null +++ b/launcher/resources/flat/scalable/language.svg @@ -0,0 +1,103 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/flat/scalable/launch.svg b/launcher/resources/flat/scalable/launch.svg new file mode 100644 index 0000000..b462f2e --- /dev/null +++ b/launcher/resources/flat/scalable/launch.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/launcher/resources/flat/scalable/loadermods.svg b/launcher/resources/flat/scalable/loadermods.svg new file mode 100644 index 0000000..8a2fd12 --- /dev/null +++ b/launcher/resources/flat/scalable/loadermods.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/launcher/resources/flat/scalable/log.svg b/launcher/resources/flat/scalable/log.svg new file mode 100644 index 0000000..e8caa08 --- /dev/null +++ b/launcher/resources/flat/scalable/log.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/launcher/resources/flat/scalable/minecraft.svg b/launcher/resources/flat/scalable/minecraft.svg new file mode 100644 index 0000000..c17c44c --- /dev/null +++ b/launcher/resources/flat/scalable/minecraft.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/launcher/resources/flat/scalable/multimc.svg b/launcher/resources/flat/scalable/multimc.svg new file mode 100644 index 0000000..1c1f235 --- /dev/null +++ b/launcher/resources/flat/scalable/multimc.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/launcher/resources/flat/scalable/new.svg b/launcher/resources/flat/scalable/new.svg new file mode 100644 index 0000000..01f19d7 --- /dev/null +++ b/launcher/resources/flat/scalable/new.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/launcher/resources/flat/scalable/news.svg b/launcher/resources/flat/scalable/news.svg new file mode 100644 index 0000000..8868414 --- /dev/null +++ b/launcher/resources/flat/scalable/news.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/launcher/resources/flat/scalable/notes.svg b/launcher/resources/flat/scalable/notes.svg new file mode 100644 index 0000000..ebe0cb5 --- /dev/null +++ b/launcher/resources/flat/scalable/notes.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/launcher/resources/flat/scalable/packages.svg b/launcher/resources/flat/scalable/packages.svg new file mode 100644 index 0000000..fe576a4 --- /dev/null +++ b/launcher/resources/flat/scalable/packages.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/launcher/resources/flat/scalable/patreon.svg b/launcher/resources/flat/scalable/patreon.svg new file mode 100644 index 0000000..ad561f5 --- /dev/null +++ b/launcher/resources/flat/scalable/patreon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/launcher/resources/flat/scalable/proxy.svg b/launcher/resources/flat/scalable/proxy.svg new file mode 100644 index 0000000..4956fec --- /dev/null +++ b/launcher/resources/flat/scalable/proxy.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/launcher/resources/flat/scalable/quickmods.svg b/launcher/resources/flat/scalable/quickmods.svg new file mode 100644 index 0000000..952d1e0 --- /dev/null +++ b/launcher/resources/flat/scalable/quickmods.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/launcher/resources/flat/scalable/reddit-alien.svg b/launcher/resources/flat/scalable/reddit-alien.svg new file mode 100644 index 0000000..9bcfbed --- /dev/null +++ b/launcher/resources/flat/scalable/reddit-alien.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/launcher/resources/flat/scalable/refresh.svg b/launcher/resources/flat/scalable/refresh.svg new file mode 100644 index 0000000..94be1e2 --- /dev/null +++ b/launcher/resources/flat/scalable/refresh.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/launcher/resources/flat/scalable/rename.svg b/launcher/resources/flat/scalable/rename.svg new file mode 100644 index 0000000..d0b5672 --- /dev/null +++ b/launcher/resources/flat/scalable/rename.svg @@ -0,0 +1 @@ + diff --git a/launcher/resources/flat/scalable/resourcepacks.svg b/launcher/resources/flat/scalable/resourcepacks.svg new file mode 100644 index 0000000..b6054ba --- /dev/null +++ b/launcher/resources/flat/scalable/resourcepacks.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/launcher/resources/flat/scalable/screenshot-placeholder.svg b/launcher/resources/flat/scalable/screenshot-placeholder.svg new file mode 100644 index 0000000..99e0c17 --- /dev/null +++ b/launcher/resources/flat/scalable/screenshot-placeholder.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/launcher/resources/flat/scalable/screenshots.svg b/launcher/resources/flat/scalable/screenshots.svg new file mode 100644 index 0000000..208bb10 --- /dev/null +++ b/launcher/resources/flat/scalable/screenshots.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/launcher/resources/flat/scalable/server.svg b/launcher/resources/flat/scalable/server.svg new file mode 100644 index 0000000..c1d09d2 --- /dev/null +++ b/launcher/resources/flat/scalable/server.svg @@ -0,0 +1,44 @@ + + + + + + + diff --git a/launcher/resources/flat/scalable/settings.svg b/launcher/resources/flat/scalable/settings.svg new file mode 100644 index 0000000..dd9d86e --- /dev/null +++ b/launcher/resources/flat/scalable/settings.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/launcher/resources/flat/scalable/shaderpacks.svg b/launcher/resources/flat/scalable/shaderpacks.svg new file mode 100644 index 0000000..f1460bd --- /dev/null +++ b/launcher/resources/flat/scalable/shaderpacks.svg @@ -0,0 +1,56 @@ + + + + + + + + + + + diff --git a/launcher/resources/flat/scalable/shortcut.svg b/launcher/resources/flat/scalable/shortcut.svg new file mode 100644 index 0000000..83878d1 --- /dev/null +++ b/launcher/resources/flat/scalable/shortcut.svg @@ -0,0 +1,3 @@ + + + diff --git a/launcher/resources/flat/scalable/star.svg b/launcher/resources/flat/scalable/star.svg new file mode 100644 index 0000000..878bdca --- /dev/null +++ b/launcher/resources/flat/scalable/star.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/launcher/resources/flat/scalable/status-bad.svg b/launcher/resources/flat/scalable/status-bad.svg new file mode 100644 index 0000000..3f8e011 --- /dev/null +++ b/launcher/resources/flat/scalable/status-bad.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/launcher/resources/flat/scalable/status-good.svg b/launcher/resources/flat/scalable/status-good.svg new file mode 100644 index 0000000..3503d6b --- /dev/null +++ b/launcher/resources/flat/scalable/status-good.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/launcher/resources/flat/scalable/status-running.svg b/launcher/resources/flat/scalable/status-running.svg new file mode 100644 index 0000000..7c75031 --- /dev/null +++ b/launcher/resources/flat/scalable/status-running.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/launcher/resources/flat/scalable/status-yellow.svg b/launcher/resources/flat/scalable/status-yellow.svg new file mode 100644 index 0000000..ac2d234 --- /dev/null +++ b/launcher/resources/flat/scalable/status-yellow.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/launcher/resources/flat/scalable/tag.svg b/launcher/resources/flat/scalable/tag.svg new file mode 100644 index 0000000..0629b18 --- /dev/null +++ b/launcher/resources/flat/scalable/tag.svg @@ -0,0 +1 @@ + diff --git a/launcher/resources/flat/scalable/viewfolder.svg b/launcher/resources/flat/scalable/viewfolder.svg new file mode 100644 index 0000000..2f5e29c --- /dev/null +++ b/launcher/resources/flat/scalable/viewfolder.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/launcher/resources/flat/scalable/worlds.svg b/launcher/resources/flat/scalable/worlds.svg new file mode 100644 index 0000000..95a59bd --- /dev/null +++ b/launcher/resources/flat/scalable/worlds.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/launcher/resources/flat_white/flat_white.qrc b/launcher/resources/flat_white/flat_white.qrc new file mode 100644 index 0000000..b57c576 --- /dev/null +++ b/launcher/resources/flat_white/flat_white.qrc @@ -0,0 +1,54 @@ + + + + index.theme + scalable/about.svg + scalable/accounts.svg + scalable/bug.svg + scalable/cat.svg + scalable/centralmods.svg + scalable/checkupdate.svg + scalable/copy.svg + scalable/coremods.svg + scalable/custom-commands.svg + scalable/datapacks.svg + scalable/discord.svg + scalable/externaltools.svg + scalable/help.svg + scalable/instance-settings.svg + scalable/jarmods.svg + scalable/java.svg + scalable/language.svg + scalable/loadermods.svg + scalable/log.svg + scalable/minecraft.svg + scalable/new.svg + scalable/news.svg + scalable/notes.svg + scalable/packages.svg + scalable/proxy.svg + scalable/quickmods.svg + scalable/reddit-alien.svg + scalable/refresh.svg + scalable/resourcepacks.svg + scalable/shaderpacks.svg + scalable/screenshot-placeholder.svg + scalable/screenshots.svg + scalable/settings.svg + scalable/shortcut.svg + scalable/star.svg + scalable/status-bad.svg + scalable/status-good.svg + scalable/status-running.svg + scalable/status-yellow.svg + scalable/viewfolder.svg + scalable/worlds.svg + scalable/delete.svg + scalable/export.svg + scalable/rename.svg + scalable/tag.svg + scalable/launch.svg + scalable/server.svg + scalable/appearance.svg + + diff --git a/launcher/resources/flat_white/index.theme b/launcher/resources/flat_white/index.theme new file mode 100644 index 0000000..54dd0e1 --- /dev/null +++ b/launcher/resources/flat_white/index.theme @@ -0,0 +1,11 @@ +[Icon Theme] +Name=Flat (White) +Comment=White version of the flat icons (dark mode) +Inherits=multimc +Directories=scalable + +[scalable] +Size=48 +Type=Scalable +MinSize=16 +MaxSize=256 diff --git a/launcher/resources/flat_white/scalable/about.svg b/launcher/resources/flat_white/scalable/about.svg new file mode 100644 index 0000000..e2071c8 --- /dev/null +++ b/launcher/resources/flat_white/scalable/about.svg @@ -0,0 +1,3 @@ + + + diff --git a/launcher/resources/flat_white/scalable/accounts.svg b/launcher/resources/flat_white/scalable/accounts.svg new file mode 100644 index 0000000..0b413e2 --- /dev/null +++ b/launcher/resources/flat_white/scalable/accounts.svg @@ -0,0 +1,3 @@ + + + diff --git a/launcher/resources/flat_white/scalable/appearance.svg b/launcher/resources/flat_white/scalable/appearance.svg new file mode 100644 index 0000000..b20d91f --- /dev/null +++ b/launcher/resources/flat_white/scalable/appearance.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/launcher/resources/flat_white/scalable/bug.svg b/launcher/resources/flat_white/scalable/bug.svg new file mode 100644 index 0000000..1e270ac --- /dev/null +++ b/launcher/resources/flat_white/scalable/bug.svg @@ -0,0 +1,3 @@ + + + diff --git a/launcher/resources/flat_white/scalable/cat.svg b/launcher/resources/flat_white/scalable/cat.svg new file mode 100644 index 0000000..93470c4 --- /dev/null +++ b/launcher/resources/flat_white/scalable/cat.svg @@ -0,0 +1,3 @@ + + + diff --git a/launcher/resources/flat_white/scalable/centralmods.svg b/launcher/resources/flat_white/scalable/centralmods.svg new file mode 100644 index 0000000..277fe11 --- /dev/null +++ b/launcher/resources/flat_white/scalable/centralmods.svg @@ -0,0 +1,3 @@ + + + diff --git a/launcher/resources/flat_white/scalable/checkupdate.svg b/launcher/resources/flat_white/scalable/checkupdate.svg new file mode 100644 index 0000000..78db2b0 --- /dev/null +++ b/launcher/resources/flat_white/scalable/checkupdate.svg @@ -0,0 +1,3 @@ + + + diff --git a/launcher/resources/flat_white/scalable/copy.svg b/launcher/resources/flat_white/scalable/copy.svg new file mode 100644 index 0000000..abcb2b6 --- /dev/null +++ b/launcher/resources/flat_white/scalable/copy.svg @@ -0,0 +1,3 @@ + + + diff --git a/launcher/resources/flat_white/scalable/coremods.svg b/launcher/resources/flat_white/scalable/coremods.svg new file mode 100644 index 0000000..f3132a5 --- /dev/null +++ b/launcher/resources/flat_white/scalable/coremods.svg @@ -0,0 +1,3 @@ + + + diff --git a/launcher/resources/flat_white/scalable/custom-commands.svg b/launcher/resources/flat_white/scalable/custom-commands.svg new file mode 100644 index 0000000..0ba459c --- /dev/null +++ b/launcher/resources/flat_white/scalable/custom-commands.svg @@ -0,0 +1 @@ + diff --git a/launcher/resources/flat_white/scalable/datapacks.svg b/launcher/resources/flat_white/scalable/datapacks.svg new file mode 100644 index 0000000..fe1cf99 --- /dev/null +++ b/launcher/resources/flat_white/scalable/datapacks.svg @@ -0,0 +1,86 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/flat_white/scalable/delete.svg b/launcher/resources/flat_white/scalable/delete.svg new file mode 100644 index 0000000..653ecd3 --- /dev/null +++ b/launcher/resources/flat_white/scalable/delete.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/launcher/resources/flat_white/scalable/discord.svg b/launcher/resources/flat_white/scalable/discord.svg new file mode 100644 index 0000000..6a07d22 --- /dev/null +++ b/launcher/resources/flat_white/scalable/discord.svg @@ -0,0 +1,4 @@ + + + + diff --git a/launcher/resources/flat_white/scalable/export.svg b/launcher/resources/flat_white/scalable/export.svg new file mode 100644 index 0000000..0959521 --- /dev/null +++ b/launcher/resources/flat_white/scalable/export.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/launcher/resources/flat_white/scalable/externaltools.svg b/launcher/resources/flat_white/scalable/externaltools.svg new file mode 100644 index 0000000..d641f4f --- /dev/null +++ b/launcher/resources/flat_white/scalable/externaltools.svg @@ -0,0 +1,3 @@ + + + diff --git a/launcher/resources/flat_white/scalable/help.svg b/launcher/resources/flat_white/scalable/help.svg new file mode 100644 index 0000000..31e8c09 --- /dev/null +++ b/launcher/resources/flat_white/scalable/help.svg @@ -0,0 +1,17 @@ + + + + diff --git a/launcher/resources/flat_white/scalable/instance-settings.svg b/launcher/resources/flat_white/scalable/instance-settings.svg new file mode 100644 index 0000000..95a0a80 --- /dev/null +++ b/launcher/resources/flat_white/scalable/instance-settings.svg @@ -0,0 +1,3 @@ + + + diff --git a/launcher/resources/flat_white/scalable/jarmods.svg b/launcher/resources/flat_white/scalable/jarmods.svg new file mode 100644 index 0000000..603a8ae --- /dev/null +++ b/launcher/resources/flat_white/scalable/jarmods.svg @@ -0,0 +1,3 @@ + + + diff --git a/launcher/resources/flat_white/scalable/java.svg b/launcher/resources/flat_white/scalable/java.svg new file mode 100644 index 0000000..db81128 --- /dev/null +++ b/launcher/resources/flat_white/scalable/java.svg @@ -0,0 +1,3 @@ + + + diff --git a/launcher/resources/flat_white/scalable/language.svg b/launcher/resources/flat_white/scalable/language.svg new file mode 100644 index 0000000..4aef294 --- /dev/null +++ b/launcher/resources/flat_white/scalable/language.svg @@ -0,0 +1,103 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/flat_white/scalable/launch.svg b/launcher/resources/flat_white/scalable/launch.svg new file mode 100644 index 0000000..ddd6d5f --- /dev/null +++ b/launcher/resources/flat_white/scalable/launch.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/launcher/resources/flat_white/scalable/loadermods.svg b/launcher/resources/flat_white/scalable/loadermods.svg new file mode 100644 index 0000000..95c7208 --- /dev/null +++ b/launcher/resources/flat_white/scalable/loadermods.svg @@ -0,0 +1,3 @@ + + + diff --git a/launcher/resources/flat_white/scalable/log.svg b/launcher/resources/flat_white/scalable/log.svg new file mode 100644 index 0000000..a40139d --- /dev/null +++ b/launcher/resources/flat_white/scalable/log.svg @@ -0,0 +1,3 @@ + + + diff --git a/launcher/resources/flat_white/scalable/minecraft.svg b/launcher/resources/flat_white/scalable/minecraft.svg new file mode 100644 index 0000000..94aaebd --- /dev/null +++ b/launcher/resources/flat_white/scalable/minecraft.svg @@ -0,0 +1,3 @@ + + + diff --git a/launcher/resources/flat_white/scalable/multimc.svg b/launcher/resources/flat_white/scalable/multimc.svg new file mode 100644 index 0000000..9afe68d --- /dev/null +++ b/launcher/resources/flat_white/scalable/multimc.svg @@ -0,0 +1,3 @@ + + + diff --git a/launcher/resources/flat_white/scalable/new.svg b/launcher/resources/flat_white/scalable/new.svg new file mode 100644 index 0000000..22c6a6f --- /dev/null +++ b/launcher/resources/flat_white/scalable/new.svg @@ -0,0 +1,3 @@ + + + diff --git a/launcher/resources/flat_white/scalable/news.svg b/launcher/resources/flat_white/scalable/news.svg new file mode 100644 index 0000000..76623f3 --- /dev/null +++ b/launcher/resources/flat_white/scalable/news.svg @@ -0,0 +1,3 @@ + + + diff --git a/launcher/resources/flat_white/scalable/notes.svg b/launcher/resources/flat_white/scalable/notes.svg new file mode 100644 index 0000000..18a1265 --- /dev/null +++ b/launcher/resources/flat_white/scalable/notes.svg @@ -0,0 +1,3 @@ + + + diff --git a/launcher/resources/flat_white/scalable/packages.svg b/launcher/resources/flat_white/scalable/packages.svg new file mode 100644 index 0000000..d2c8795 --- /dev/null +++ b/launcher/resources/flat_white/scalable/packages.svg @@ -0,0 +1,3 @@ + + + diff --git a/launcher/resources/flat_white/scalable/patreon.svg b/launcher/resources/flat_white/scalable/patreon.svg new file mode 100644 index 0000000..d5385ea --- /dev/null +++ b/launcher/resources/flat_white/scalable/patreon.svg @@ -0,0 +1,3 @@ + + + diff --git a/launcher/resources/flat_white/scalable/proxy.svg b/launcher/resources/flat_white/scalable/proxy.svg new file mode 100644 index 0000000..30e27e8 --- /dev/null +++ b/launcher/resources/flat_white/scalable/proxy.svg @@ -0,0 +1,3 @@ + + + diff --git a/launcher/resources/flat_white/scalable/quickmods.svg b/launcher/resources/flat_white/scalable/quickmods.svg new file mode 100644 index 0000000..599bd2b --- /dev/null +++ b/launcher/resources/flat_white/scalable/quickmods.svg @@ -0,0 +1,3 @@ + + + diff --git a/launcher/resources/flat_white/scalable/reddit-alien.svg b/launcher/resources/flat_white/scalable/reddit-alien.svg new file mode 100644 index 0000000..291b12e --- /dev/null +++ b/launcher/resources/flat_white/scalable/reddit-alien.svg @@ -0,0 +1,3 @@ + + + diff --git a/launcher/resources/flat_white/scalable/refresh.svg b/launcher/resources/flat_white/scalable/refresh.svg new file mode 100644 index 0000000..e8c6c44 --- /dev/null +++ b/launcher/resources/flat_white/scalable/refresh.svg @@ -0,0 +1,3 @@ + + + diff --git a/launcher/resources/flat_white/scalable/rename.svg b/launcher/resources/flat_white/scalable/rename.svg new file mode 100644 index 0000000..e7d6634 --- /dev/null +++ b/launcher/resources/flat_white/scalable/rename.svg @@ -0,0 +1,4 @@ + + + + diff --git a/launcher/resources/flat_white/scalable/resourcepacks.svg b/launcher/resources/flat_white/scalable/resourcepacks.svg new file mode 100644 index 0000000..272af76 --- /dev/null +++ b/launcher/resources/flat_white/scalable/resourcepacks.svg @@ -0,0 +1,3 @@ + + + diff --git a/launcher/resources/flat_white/scalable/screenshot-placeholder.svg b/launcher/resources/flat_white/scalable/screenshot-placeholder.svg new file mode 100644 index 0000000..162b780 --- /dev/null +++ b/launcher/resources/flat_white/scalable/screenshot-placeholder.svg @@ -0,0 +1,3 @@ + + + diff --git a/launcher/resources/flat_white/scalable/screenshots.svg b/launcher/resources/flat_white/scalable/screenshots.svg new file mode 100644 index 0000000..ae1c876 --- /dev/null +++ b/launcher/resources/flat_white/scalable/screenshots.svg @@ -0,0 +1,3 @@ + + + diff --git a/launcher/resources/flat_white/scalable/server.svg b/launcher/resources/flat_white/scalable/server.svg new file mode 100644 index 0000000..f41db1b --- /dev/null +++ b/launcher/resources/flat_white/scalable/server.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/launcher/resources/flat_white/scalable/settings.svg b/launcher/resources/flat_white/scalable/settings.svg new file mode 100644 index 0000000..95a0a80 --- /dev/null +++ b/launcher/resources/flat_white/scalable/settings.svg @@ -0,0 +1,3 @@ + + + diff --git a/launcher/resources/flat_white/scalable/shaderpacks.svg b/launcher/resources/flat_white/scalable/shaderpacks.svg new file mode 100644 index 0000000..bfd8b83 --- /dev/null +++ b/launcher/resources/flat_white/scalable/shaderpacks.svg @@ -0,0 +1,56 @@ + + + + + + + + + + + diff --git a/launcher/resources/flat_white/scalable/shortcut.svg b/launcher/resources/flat_white/scalable/shortcut.svg new file mode 100644 index 0000000..77ccbdd --- /dev/null +++ b/launcher/resources/flat_white/scalable/shortcut.svg @@ -0,0 +1,3 @@ + + + diff --git a/launcher/resources/flat_white/scalable/star.svg b/launcher/resources/flat_white/scalable/star.svg new file mode 100644 index 0000000..2a573ca --- /dev/null +++ b/launcher/resources/flat_white/scalable/star.svg @@ -0,0 +1,3 @@ + + + diff --git a/launcher/resources/flat_white/scalable/status-bad.svg b/launcher/resources/flat_white/scalable/status-bad.svg new file mode 100644 index 0000000..b6b42a9 --- /dev/null +++ b/launcher/resources/flat_white/scalable/status-bad.svg @@ -0,0 +1,3 @@ + + + diff --git a/launcher/resources/flat_white/scalable/status-good.svg b/launcher/resources/flat_white/scalable/status-good.svg new file mode 100644 index 0000000..aee4c52 --- /dev/null +++ b/launcher/resources/flat_white/scalable/status-good.svg @@ -0,0 +1,3 @@ + + + diff --git a/launcher/resources/flat_white/scalable/status-running.svg b/launcher/resources/flat_white/scalable/status-running.svg new file mode 100644 index 0000000..d4d5519 --- /dev/null +++ b/launcher/resources/flat_white/scalable/status-running.svg @@ -0,0 +1,3 @@ + + + diff --git a/launcher/resources/flat_white/scalable/status-yellow.svg b/launcher/resources/flat_white/scalable/status-yellow.svg new file mode 100644 index 0000000..00737f5 --- /dev/null +++ b/launcher/resources/flat_white/scalable/status-yellow.svg @@ -0,0 +1,3 @@ + + + diff --git a/launcher/resources/flat_white/scalable/tag.svg b/launcher/resources/flat_white/scalable/tag.svg new file mode 100644 index 0000000..0d7661e --- /dev/null +++ b/launcher/resources/flat_white/scalable/tag.svg @@ -0,0 +1,4 @@ + + + + diff --git a/launcher/resources/flat_white/scalable/viewfolder.svg b/launcher/resources/flat_white/scalable/viewfolder.svg new file mode 100644 index 0000000..b13c8eb --- /dev/null +++ b/launcher/resources/flat_white/scalable/viewfolder.svg @@ -0,0 +1,3 @@ + + + diff --git a/launcher/resources/flat_white/scalable/worlds.svg b/launcher/resources/flat_white/scalable/worlds.svg new file mode 100644 index 0000000..d7aaef1 --- /dev/null +++ b/launcher/resources/flat_white/scalable/worlds.svg @@ -0,0 +1,3 @@ + + + diff --git a/launcher/resources/iOS/iOS.qrc b/launcher/resources/iOS/iOS.qrc new file mode 100644 index 0000000..9b8d84f --- /dev/null +++ b/launcher/resources/iOS/iOS.qrc @@ -0,0 +1,43 @@ + + + + index.theme + scalable/about.svg + scalable/accounts.svg + scalable/bug.svg + scalable/centralmods.svg + scalable/checkupdate.svg + scalable/copy.svg + scalable/coremods.svg + scalable/custom-commands.svg + scalable/externaltools.svg + scalable/help.svg + scalable/instance-settings.svg + scalable/jarmods.svg + scalable/java.svg + scalable/language.svg + scalable/loadermods.svg + scalable/log.svg + scalable/minecraft.svg + scalable/new.svg + scalable/news.svg + scalable/notes.svg + scalable/proxy.svg + scalable/refresh.svg + scalable/resourcepacks.svg + scalable/shaderpacks.svg + scalable/screenshots.svg + scalable/settings.svg + scalable/status-bad.svg + scalable/status-good.svg + scalable/status-yellow.svg + scalable/viewfolder.svg + scalable/worlds.svg + scalable/delete.svg + scalable/tag.svg + scalable/export.svg + scalable/rename.svg + scalable/launch.svg + scalable/shortcut.svg + + diff --git a/launcher/resources/iOS/index.theme b/launcher/resources/iOS/index.theme new file mode 100644 index 0000000..b0f2f6b --- /dev/null +++ b/launcher/resources/iOS/index.theme @@ -0,0 +1,11 @@ +[Icon Theme] +Name=iOS +Comment=iOS theme by pexner +Inherits=multimc +Directories=scalable + +[scalable] +Size=48 +Type=Scalable +MinSize=16 +MaxSize=256 diff --git a/launcher/resources/iOS/scalable/about.svg b/launcher/resources/iOS/scalable/about.svg new file mode 100644 index 0000000..c4d3547 --- /dev/null +++ b/launcher/resources/iOS/scalable/about.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + diff --git a/launcher/resources/iOS/scalable/accounts.svg b/launcher/resources/iOS/scalable/accounts.svg new file mode 100644 index 0000000..65f76c3 --- /dev/null +++ b/launcher/resources/iOS/scalable/accounts.svg @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/launcher/resources/iOS/scalable/bug.svg b/launcher/resources/iOS/scalable/bug.svg new file mode 100644 index 0000000..fc4a3d6 --- /dev/null +++ b/launcher/resources/iOS/scalable/bug.svg @@ -0,0 +1,22 @@ + + + + + + diff --git a/launcher/resources/iOS/scalable/centralmods.svg b/launcher/resources/iOS/scalable/centralmods.svg new file mode 100644 index 0000000..1b4c474 --- /dev/null +++ b/launcher/resources/iOS/scalable/centralmods.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/launcher/resources/iOS/scalable/checkupdate.svg b/launcher/resources/iOS/scalable/checkupdate.svg new file mode 100644 index 0000000..9fc983d --- /dev/null +++ b/launcher/resources/iOS/scalable/checkupdate.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + diff --git a/launcher/resources/iOS/scalable/copy.svg b/launcher/resources/iOS/scalable/copy.svg new file mode 100644 index 0000000..3ccc2f0 --- /dev/null +++ b/launcher/resources/iOS/scalable/copy.svg @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/launcher/resources/iOS/scalable/coremods.svg b/launcher/resources/iOS/scalable/coremods.svg new file mode 100644 index 0000000..ea47872 --- /dev/null +++ b/launcher/resources/iOS/scalable/coremods.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + diff --git a/launcher/resources/iOS/scalable/custom-commands.svg b/launcher/resources/iOS/scalable/custom-commands.svg new file mode 100644 index 0000000..f44e2bf --- /dev/null +++ b/launcher/resources/iOS/scalable/custom-commands.svg @@ -0,0 +1,63 @@ + +image/svg+xml + + + + + diff --git a/launcher/resources/iOS/scalable/delete.svg b/launcher/resources/iOS/scalable/delete.svg new file mode 100644 index 0000000..a542fa4 --- /dev/null +++ b/launcher/resources/iOS/scalable/delete.svg @@ -0,0 +1,31 @@ + + + diff --git a/launcher/resources/iOS/scalable/export.svg b/launcher/resources/iOS/scalable/export.svg new file mode 100644 index 0000000..db2f4c3 --- /dev/null +++ b/launcher/resources/iOS/scalable/export.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + diff --git a/launcher/resources/iOS/scalable/externaltools.svg b/launcher/resources/iOS/scalable/externaltools.svg new file mode 100644 index 0000000..16e9fa4 --- /dev/null +++ b/launcher/resources/iOS/scalable/externaltools.svg @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/launcher/resources/iOS/scalable/help.svg b/launcher/resources/iOS/scalable/help.svg new file mode 100644 index 0000000..9c2d2e9 --- /dev/null +++ b/launcher/resources/iOS/scalable/help.svg @@ -0,0 +1,38 @@ + +image/svg+xml \ No newline at end of file diff --git a/launcher/resources/iOS/scalable/instance-settings.svg b/launcher/resources/iOS/scalable/instance-settings.svg new file mode 100644 index 0000000..95b8a50 --- /dev/null +++ b/launcher/resources/iOS/scalable/instance-settings.svg @@ -0,0 +1,19 @@ + + + + + + + + + + diff --git a/launcher/resources/iOS/scalable/jarmods.svg b/launcher/resources/iOS/scalable/jarmods.svg new file mode 100644 index 0000000..c4c5ca8 --- /dev/null +++ b/launcher/resources/iOS/scalable/jarmods.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/iOS/scalable/java.svg b/launcher/resources/iOS/scalable/java.svg new file mode 100644 index 0000000..8d7c279 --- /dev/null +++ b/launcher/resources/iOS/scalable/java.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/iOS/scalable/language.svg b/launcher/resources/iOS/scalable/language.svg new file mode 100644 index 0000000..fcc3436 --- /dev/null +++ b/launcher/resources/iOS/scalable/language.svg @@ -0,0 +1,32 @@ + +image/svg+xml + + + + + \ No newline at end of file diff --git a/launcher/resources/iOS/scalable/launch.svg b/launcher/resources/iOS/scalable/launch.svg new file mode 100644 index 0000000..c16d5c3 --- /dev/null +++ b/launcher/resources/iOS/scalable/launch.svg @@ -0,0 +1,17 @@ + + + + diff --git a/launcher/resources/iOS/scalable/loadermods.svg b/launcher/resources/iOS/scalable/loadermods.svg new file mode 100644 index 0000000..010efa1 --- /dev/null +++ b/launcher/resources/iOS/scalable/loadermods.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/launcher/resources/iOS/scalable/log.svg b/launcher/resources/iOS/scalable/log.svg new file mode 100644 index 0000000..5d1c7f0 --- /dev/null +++ b/launcher/resources/iOS/scalable/log.svg @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/launcher/resources/iOS/scalable/minecraft.svg b/launcher/resources/iOS/scalable/minecraft.svg new file mode 100644 index 0000000..069b4e7 --- /dev/null +++ b/launcher/resources/iOS/scalable/minecraft.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/launcher/resources/iOS/scalable/multimc.svg b/launcher/resources/iOS/scalable/multimc.svg new file mode 100644 index 0000000..bc81943 --- /dev/null +++ b/launcher/resources/iOS/scalable/multimc.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/launcher/resources/iOS/scalable/new.svg b/launcher/resources/iOS/scalable/new.svg new file mode 100644 index 0000000..9f22158 --- /dev/null +++ b/launcher/resources/iOS/scalable/new.svg @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/launcher/resources/iOS/scalable/news.svg b/launcher/resources/iOS/scalable/news.svg new file mode 100644 index 0000000..d3c010b --- /dev/null +++ b/launcher/resources/iOS/scalable/news.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/launcher/resources/iOS/scalable/notes.svg b/launcher/resources/iOS/scalable/notes.svg new file mode 100644 index 0000000..b42ebee --- /dev/null +++ b/launcher/resources/iOS/scalable/notes.svg @@ -0,0 +1,15 @@ + + + + + + + + + + diff --git a/launcher/resources/iOS/scalable/patreon.svg b/launcher/resources/iOS/scalable/patreon.svg new file mode 100644 index 0000000..1bd06f4 --- /dev/null +++ b/launcher/resources/iOS/scalable/patreon.svg @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/launcher/resources/iOS/scalable/proxy.svg b/launcher/resources/iOS/scalable/proxy.svg new file mode 100644 index 0000000..f655228 --- /dev/null +++ b/launcher/resources/iOS/scalable/proxy.svg @@ -0,0 +1,11 @@ + + + + + + diff --git a/launcher/resources/iOS/scalable/refresh.svg b/launcher/resources/iOS/scalable/refresh.svg new file mode 100644 index 0000000..297b79c --- /dev/null +++ b/launcher/resources/iOS/scalable/refresh.svg @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/launcher/resources/iOS/scalable/rename.svg b/launcher/resources/iOS/scalable/rename.svg new file mode 100644 index 0000000..064e84b --- /dev/null +++ b/launcher/resources/iOS/scalable/rename.svg @@ -0,0 +1,16 @@ + + + diff --git a/launcher/resources/iOS/scalable/resourcepacks.svg b/launcher/resources/iOS/scalable/resourcepacks.svg new file mode 100644 index 0000000..5b359d6 --- /dev/null +++ b/launcher/resources/iOS/scalable/resourcepacks.svg @@ -0,0 +1,15 @@ + + + + + + + + + + diff --git a/launcher/resources/iOS/scalable/screenshots.svg b/launcher/resources/iOS/scalable/screenshots.svg new file mode 100644 index 0000000..39ce7b8 --- /dev/null +++ b/launcher/resources/iOS/scalable/screenshots.svg @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/launcher/resources/iOS/scalable/settings.svg b/launcher/resources/iOS/scalable/settings.svg new file mode 100644 index 0000000..95b8a50 --- /dev/null +++ b/launcher/resources/iOS/scalable/settings.svg @@ -0,0 +1,19 @@ + + + + + + + + + + diff --git a/launcher/resources/iOS/scalable/shaderpacks.svg b/launcher/resources/iOS/scalable/shaderpacks.svg new file mode 100644 index 0000000..a2aa1b2 --- /dev/null +++ b/launcher/resources/iOS/scalable/shaderpacks.svg @@ -0,0 +1,38 @@ + + + + + + + + + + diff --git a/launcher/resources/iOS/scalable/shortcut.svg b/launcher/resources/iOS/scalable/shortcut.svg new file mode 100644 index 0000000..16e9fa4 --- /dev/null +++ b/launcher/resources/iOS/scalable/shortcut.svg @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/launcher/resources/iOS/scalable/status-bad.svg b/launcher/resources/iOS/scalable/status-bad.svg new file mode 100644 index 0000000..4019c8d --- /dev/null +++ b/launcher/resources/iOS/scalable/status-bad.svg @@ -0,0 +1,10 @@ + + + + + + diff --git a/launcher/resources/iOS/scalable/status-good.svg b/launcher/resources/iOS/scalable/status-good.svg new file mode 100644 index 0000000..e185911 --- /dev/null +++ b/launcher/resources/iOS/scalable/status-good.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + diff --git a/launcher/resources/iOS/scalable/status-yellow.svg b/launcher/resources/iOS/scalable/status-yellow.svg new file mode 100644 index 0000000..d8a28e2 --- /dev/null +++ b/launcher/resources/iOS/scalable/status-yellow.svg @@ -0,0 +1,56 @@ + + + +image/svg+xml \ No newline at end of file diff --git a/launcher/resources/iOS/scalable/tag.svg b/launcher/resources/iOS/scalable/tag.svg new file mode 100644 index 0000000..23b549e --- /dev/null +++ b/launcher/resources/iOS/scalable/tag.svg @@ -0,0 +1,20 @@ + + + diff --git a/launcher/resources/iOS/scalable/viewfolder.svg b/launcher/resources/iOS/scalable/viewfolder.svg new file mode 100644 index 0000000..0ae0c0b --- /dev/null +++ b/launcher/resources/iOS/scalable/viewfolder.svg @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/launcher/resources/iOS/scalable/worlds.svg b/launcher/resources/iOS/scalable/worlds.svg new file mode 100644 index 0000000..1596fd7 --- /dev/null +++ b/launcher/resources/iOS/scalable/worlds.svg @@ -0,0 +1,44 @@ + +image/svg+xml \ No newline at end of file diff --git a/launcher/resources/pe_blue/index.theme b/launcher/resources/pe_blue/index.theme new file mode 100644 index 0000000..6d842b5 --- /dev/null +++ b/launcher/resources/pe_blue/index.theme @@ -0,0 +1,11 @@ +[Icon Theme] +Name=Simple (Blue) +Comment=Icons by pexner (blue) +Inherits=multimc +Directories=scalable + +[scalable] +Size=48 +Type=Scalable +MinSize=16 +MaxSize=256 diff --git a/launcher/resources/pe_blue/pe_blue.qrc b/launcher/resources/pe_blue/pe_blue.qrc new file mode 100644 index 0000000..1e6b5d3 --- /dev/null +++ b/launcher/resources/pe_blue/pe_blue.qrc @@ -0,0 +1,46 @@ + + + + index.theme + scalable/about.svg + scalable/accounts.svg + scalable/bug.svg + scalable/centralmods.svg + scalable/checkupdate.svg + scalable/copy.svg + scalable/coremods.svg + scalable/custom-commands.svg + scalable/datapacks.svg + scalable/externaltools.svg + scalable/help.svg + scalable/instance-settings.svg + scalable/jarmods.svg + scalable/java.svg + scalable/language.svg + scalable/loadermods.svg + scalable/log.svg + scalable/minecraft.svg + scalable/new.svg + scalable/news.svg + scalable/notes.svg + scalable/proxy.svg + scalable/refresh.svg + scalable/resourcepacks.svg + scalable/shaderpacks.svg + scalable/screenshots.svg + scalable/settings.svg + scalable/status-bad.svg + scalable/status-good.svg + scalable/status-yellow.svg + scalable/viewfolder.svg + scalable/worlds.svg + scalable/delete.svg + scalable/tag.svg + scalable/export.svg + scalable/rename.svg + scalable/launch.svg + scalable/shortcut.svg + scalable/server.svg + scalable/appearance.svg + + diff --git a/launcher/resources/pe_blue/scalable/about.svg b/launcher/resources/pe_blue/scalable/about.svg new file mode 100644 index 0000000..56e7fc9 --- /dev/null +++ b/launcher/resources/pe_blue/scalable/about.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + diff --git a/launcher/resources/pe_blue/scalable/accounts.svg b/launcher/resources/pe_blue/scalable/accounts.svg new file mode 100644 index 0000000..77e3f45 --- /dev/null +++ b/launcher/resources/pe_blue/scalable/accounts.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_blue/scalable/appearance.svg b/launcher/resources/pe_blue/scalable/appearance.svg new file mode 100644 index 0000000..9323ec0 --- /dev/null +++ b/launcher/resources/pe_blue/scalable/appearance.svg @@ -0,0 +1,65 @@ + + + + diff --git a/launcher/resources/pe_blue/scalable/bug.svg b/launcher/resources/pe_blue/scalable/bug.svg new file mode 100644 index 0000000..75a19e2 --- /dev/null +++ b/launcher/resources/pe_blue/scalable/bug.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_blue/scalable/centralmods.svg b/launcher/resources/pe_blue/scalable/centralmods.svg new file mode 100644 index 0000000..cda39b1 --- /dev/null +++ b/launcher/resources/pe_blue/scalable/centralmods.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_blue/scalable/checkupdate.svg b/launcher/resources/pe_blue/scalable/checkupdate.svg new file mode 100644 index 0000000..a7d9ee8 --- /dev/null +++ b/launcher/resources/pe_blue/scalable/checkupdate.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_blue/scalable/copy.svg b/launcher/resources/pe_blue/scalable/copy.svg new file mode 100644 index 0000000..7ce014e --- /dev/null +++ b/launcher/resources/pe_blue/scalable/copy.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_blue/scalable/coremods.svg b/launcher/resources/pe_blue/scalable/coremods.svg new file mode 100644 index 0000000..4cc030d --- /dev/null +++ b/launcher/resources/pe_blue/scalable/coremods.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_blue/scalable/custom-commands.svg b/launcher/resources/pe_blue/scalable/custom-commands.svg new file mode 100644 index 0000000..be76ece --- /dev/null +++ b/launcher/resources/pe_blue/scalable/custom-commands.svg @@ -0,0 +1,336 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_blue/scalable/datapacks.svg b/launcher/resources/pe_blue/scalable/datapacks.svg new file mode 100644 index 0000000..61c63a4 --- /dev/null +++ b/launcher/resources/pe_blue/scalable/datapacks.svg @@ -0,0 +1,345 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_blue/scalable/delete.svg b/launcher/resources/pe_blue/scalable/delete.svg new file mode 100644 index 0000000..54a7037 --- /dev/null +++ b/launcher/resources/pe_blue/scalable/delete.svg @@ -0,0 +1,70 @@ + + + + + diff --git a/launcher/resources/pe_blue/scalable/export.svg b/launcher/resources/pe_blue/scalable/export.svg new file mode 100644 index 0000000..560bf3e --- /dev/null +++ b/launcher/resources/pe_blue/scalable/export.svg @@ -0,0 +1,40 @@ + + + + + + + diff --git a/launcher/resources/pe_blue/scalable/externaltools.svg b/launcher/resources/pe_blue/scalable/externaltools.svg new file mode 100644 index 0000000..45b7349 --- /dev/null +++ b/launcher/resources/pe_blue/scalable/externaltools.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_blue/scalable/help.svg b/launcher/resources/pe_blue/scalable/help.svg new file mode 100644 index 0000000..e98540c --- /dev/null +++ b/launcher/resources/pe_blue/scalable/help.svg @@ -0,0 +1,40 @@ + +image/svg+xml \ No newline at end of file diff --git a/launcher/resources/pe_blue/scalable/instance-settings.svg b/launcher/resources/pe_blue/scalable/instance-settings.svg new file mode 100644 index 0000000..43f0b2f --- /dev/null +++ b/launcher/resources/pe_blue/scalable/instance-settings.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_blue/scalable/jarmods.svg b/launcher/resources/pe_blue/scalable/jarmods.svg new file mode 100644 index 0000000..bb75f4b --- /dev/null +++ b/launcher/resources/pe_blue/scalable/jarmods.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + diff --git a/launcher/resources/pe_blue/scalable/java.svg b/launcher/resources/pe_blue/scalable/java.svg new file mode 100644 index 0000000..5e36920 --- /dev/null +++ b/launcher/resources/pe_blue/scalable/java.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_blue/scalable/language.svg b/launcher/resources/pe_blue/scalable/language.svg new file mode 100644 index 0000000..9286851 --- /dev/null +++ b/launcher/resources/pe_blue/scalable/language.svg @@ -0,0 +1,46 @@ + +image/svg+xml \ No newline at end of file diff --git a/launcher/resources/pe_blue/scalable/launch.svg b/launcher/resources/pe_blue/scalable/launch.svg new file mode 100644 index 0000000..b3bd124 --- /dev/null +++ b/launcher/resources/pe_blue/scalable/launch.svg @@ -0,0 +1,20 @@ + + + + diff --git a/launcher/resources/pe_blue/scalable/loadermods.svg b/launcher/resources/pe_blue/scalable/loadermods.svg new file mode 100644 index 0000000..a54dc21 --- /dev/null +++ b/launcher/resources/pe_blue/scalable/loadermods.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_blue/scalable/log.svg b/launcher/resources/pe_blue/scalable/log.svg new file mode 100644 index 0000000..89d373f --- /dev/null +++ b/launcher/resources/pe_blue/scalable/log.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_blue/scalable/minecraft.svg b/launcher/resources/pe_blue/scalable/minecraft.svg new file mode 100644 index 0000000..2fe6a02 --- /dev/null +++ b/launcher/resources/pe_blue/scalable/minecraft.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_blue/scalable/new.svg b/launcher/resources/pe_blue/scalable/new.svg new file mode 100644 index 0000000..dcc8579 --- /dev/null +++ b/launcher/resources/pe_blue/scalable/new.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_blue/scalable/news.svg b/launcher/resources/pe_blue/scalable/news.svg new file mode 100644 index 0000000..3ca3be3 --- /dev/null +++ b/launcher/resources/pe_blue/scalable/news.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/launcher/resources/pe_blue/scalable/notes.svg b/launcher/resources/pe_blue/scalable/notes.svg new file mode 100644 index 0000000..d099125 --- /dev/null +++ b/launcher/resources/pe_blue/scalable/notes.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_blue/scalable/patreon.svg b/launcher/resources/pe_blue/scalable/patreon.svg new file mode 100644 index 0000000..644b9b4 --- /dev/null +++ b/launcher/resources/pe_blue/scalable/patreon.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_blue/scalable/proxy.svg b/launcher/resources/pe_blue/scalable/proxy.svg new file mode 100644 index 0000000..8266f9b --- /dev/null +++ b/launcher/resources/pe_blue/scalable/proxy.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_blue/scalable/refresh.svg b/launcher/resources/pe_blue/scalable/refresh.svg new file mode 100644 index 0000000..a3d2281 --- /dev/null +++ b/launcher/resources/pe_blue/scalable/refresh.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_blue/scalable/rename.svg b/launcher/resources/pe_blue/scalable/rename.svg new file mode 100644 index 0000000..f9ca562 --- /dev/null +++ b/launcher/resources/pe_blue/scalable/rename.svg @@ -0,0 +1,19 @@ + + + diff --git a/launcher/resources/pe_blue/scalable/resourcepacks.svg b/launcher/resources/pe_blue/scalable/resourcepacks.svg new file mode 100644 index 0000000..a17e7e8 --- /dev/null +++ b/launcher/resources/pe_blue/scalable/resourcepacks.svg @@ -0,0 +1,13 @@ + + + + + + + diff --git a/launcher/resources/pe_blue/scalable/screenshots.svg b/launcher/resources/pe_blue/scalable/screenshots.svg new file mode 100644 index 0000000..1aa4e55 --- /dev/null +++ b/launcher/resources/pe_blue/scalable/screenshots.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_blue/scalable/server.svg b/launcher/resources/pe_blue/scalable/server.svg new file mode 100644 index 0000000..85fa6dc --- /dev/null +++ b/launcher/resources/pe_blue/scalable/server.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/launcher/resources/pe_blue/scalable/settings.svg b/launcher/resources/pe_blue/scalable/settings.svg new file mode 100644 index 0000000..43f0b2f --- /dev/null +++ b/launcher/resources/pe_blue/scalable/settings.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_blue/scalable/shaderpacks.svg b/launcher/resources/pe_blue/scalable/shaderpacks.svg new file mode 100644 index 0000000..530e1ad --- /dev/null +++ b/launcher/resources/pe_blue/scalable/shaderpacks.svg @@ -0,0 +1,77 @@ + +image/svg+xml diff --git a/launcher/resources/pe_blue/scalable/shortcut.svg b/launcher/resources/pe_blue/scalable/shortcut.svg new file mode 100644 index 0000000..45b7349 --- /dev/null +++ b/launcher/resources/pe_blue/scalable/shortcut.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_blue/scalable/status-bad.svg b/launcher/resources/pe_blue/scalable/status-bad.svg new file mode 100644 index 0000000..4a48b5d --- /dev/null +++ b/launcher/resources/pe_blue/scalable/status-bad.svg @@ -0,0 +1,10 @@ + + + + + + + diff --git a/launcher/resources/pe_blue/scalable/status-good.svg b/launcher/resources/pe_blue/scalable/status-good.svg new file mode 100644 index 0000000..4cfa56f --- /dev/null +++ b/launcher/resources/pe_blue/scalable/status-good.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + diff --git a/launcher/resources/pe_blue/scalable/status-yellow.svg b/launcher/resources/pe_blue/scalable/status-yellow.svg new file mode 100644 index 0000000..0551fed --- /dev/null +++ b/launcher/resources/pe_blue/scalable/status-yellow.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + diff --git a/launcher/resources/pe_blue/scalable/tag.svg b/launcher/resources/pe_blue/scalable/tag.svg new file mode 100644 index 0000000..02f6693 --- /dev/null +++ b/launcher/resources/pe_blue/scalable/tag.svg @@ -0,0 +1,39 @@ + + + + diff --git a/launcher/resources/pe_blue/scalable/viewfolder.svg b/launcher/resources/pe_blue/scalable/viewfolder.svg new file mode 100644 index 0000000..2634f8f --- /dev/null +++ b/launcher/resources/pe_blue/scalable/viewfolder.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_blue/scalable/worlds.svg b/launcher/resources/pe_blue/scalable/worlds.svg new file mode 100644 index 0000000..1670c03 --- /dev/null +++ b/launcher/resources/pe_blue/scalable/worlds.svg @@ -0,0 +1,63 @@ + +image/svg+xml \ No newline at end of file diff --git a/launcher/resources/pe_colored/index.theme b/launcher/resources/pe_colored/index.theme new file mode 100644 index 0000000..bca5494 --- /dev/null +++ b/launcher/resources/pe_colored/index.theme @@ -0,0 +1,11 @@ +[Icon Theme] +Name=Simple (Colored) +Comment=Icons by pexner (colored) +Inherits=multimc +Directories=scalable + +[scalable] +Size=48 +Type=Scalable +MinSize=16 +MaxSize=256 diff --git a/launcher/resources/pe_colored/pe_colored.qrc b/launcher/resources/pe_colored/pe_colored.qrc new file mode 100644 index 0000000..71b3802 --- /dev/null +++ b/launcher/resources/pe_colored/pe_colored.qrc @@ -0,0 +1,46 @@ + + + + index.theme + scalable/about.svg + scalable/accounts.svg + scalable/bug.svg + scalable/centralmods.svg + scalable/checkupdate.svg + scalable/copy.svg + scalable/coremods.svg + scalable/custom-commands.svg + scalable/datapacks.svg + scalable/externaltools.svg + scalable/help.svg + scalable/instance-settings.svg + scalable/jarmods.svg + scalable/java.svg + scalable/language.svg + scalable/loadermods.svg + scalable/log.svg + scalable/minecraft.svg + scalable/new.svg + scalable/news.svg + scalable/notes.svg + scalable/proxy.svg + scalable/refresh.svg + scalable/resourcepacks.svg + scalable/screenshots.svg + scalable/settings.svg + scalable/shaderpacks.svg + scalable/status-bad.svg + scalable/status-good.svg + scalable/status-yellow.svg + scalable/viewfolder.svg + scalable/worlds.svg + scalable/delete.svg + scalable/tag.svg + scalable/export.svg + scalable/rename.svg + scalable/launch.svg + scalable/shortcut.svg + scalable/server.svg + scalable/appearance.svg + + diff --git a/launcher/resources/pe_colored/scalable/about.svg b/launcher/resources/pe_colored/scalable/about.svg new file mode 100644 index 0000000..95e9968 --- /dev/null +++ b/launcher/resources/pe_colored/scalable/about.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_colored/scalable/accounts.svg b/launcher/resources/pe_colored/scalable/accounts.svg new file mode 100644 index 0000000..301eb36 --- /dev/null +++ b/launcher/resources/pe_colored/scalable/accounts.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + diff --git a/launcher/resources/pe_colored/scalable/appearance.svg b/launcher/resources/pe_colored/scalable/appearance.svg new file mode 100644 index 0000000..88c1eaf --- /dev/null +++ b/launcher/resources/pe_colored/scalable/appearance.svg @@ -0,0 +1,71 @@ + + + + diff --git a/launcher/resources/pe_colored/scalable/bug.svg b/launcher/resources/pe_colored/scalable/bug.svg new file mode 100644 index 0000000..8c92df0 --- /dev/null +++ b/launcher/resources/pe_colored/scalable/bug.svg @@ -0,0 +1,17 @@ + + + + + + + + + + diff --git a/launcher/resources/pe_colored/scalable/centralmods.svg b/launcher/resources/pe_colored/scalable/centralmods.svg new file mode 100644 index 0000000..57a9725 --- /dev/null +++ b/launcher/resources/pe_colored/scalable/centralmods.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + diff --git a/launcher/resources/pe_colored/scalable/checkupdate.svg b/launcher/resources/pe_colored/scalable/checkupdate.svg new file mode 100644 index 0000000..0adc8ee --- /dev/null +++ b/launcher/resources/pe_colored/scalable/checkupdate.svg @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/launcher/resources/pe_colored/scalable/copy.svg b/launcher/resources/pe_colored/scalable/copy.svg new file mode 100644 index 0000000..b9b0f1b --- /dev/null +++ b/launcher/resources/pe_colored/scalable/copy.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_colored/scalable/coremods.svg b/launcher/resources/pe_colored/scalable/coremods.svg new file mode 100644 index 0000000..ca7a22f --- /dev/null +++ b/launcher/resources/pe_colored/scalable/coremods.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + diff --git a/launcher/resources/pe_colored/scalable/custom-commands.svg b/launcher/resources/pe_colored/scalable/custom-commands.svg new file mode 100644 index 0000000..44dd199 --- /dev/null +++ b/launcher/resources/pe_colored/scalable/custom-commands.svg @@ -0,0 +1,345 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_colored/scalable/datapacks.svg b/launcher/resources/pe_colored/scalable/datapacks.svg new file mode 100644 index 0000000..c1aab6b --- /dev/null +++ b/launcher/resources/pe_colored/scalable/datapacks.svg @@ -0,0 +1,347 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_colored/scalable/delete.svg b/launcher/resources/pe_colored/scalable/delete.svg new file mode 100644 index 0000000..d9bbddc --- /dev/null +++ b/launcher/resources/pe_colored/scalable/delete.svg @@ -0,0 +1,70 @@ + + + + + diff --git a/launcher/resources/pe_colored/scalable/export.svg b/launcher/resources/pe_colored/scalable/export.svg new file mode 100644 index 0000000..267cc49 --- /dev/null +++ b/launcher/resources/pe_colored/scalable/export.svg @@ -0,0 +1,44 @@ + + + + + + + diff --git a/launcher/resources/pe_colored/scalable/externaltools.svg b/launcher/resources/pe_colored/scalable/externaltools.svg new file mode 100644 index 0000000..1469674 --- /dev/null +++ b/launcher/resources/pe_colored/scalable/externaltools.svg @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/launcher/resources/pe_colored/scalable/help.svg b/launcher/resources/pe_colored/scalable/help.svg new file mode 100644 index 0000000..c1ee525 --- /dev/null +++ b/launcher/resources/pe_colored/scalable/help.svg @@ -0,0 +1,46 @@ + +image/svg+xml \ No newline at end of file diff --git a/launcher/resources/pe_colored/scalable/instance-settings.svg b/launcher/resources/pe_colored/scalable/instance-settings.svg new file mode 100644 index 0000000..72032f8 --- /dev/null +++ b/launcher/resources/pe_colored/scalable/instance-settings.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_colored/scalable/jarmods.svg b/launcher/resources/pe_colored/scalable/jarmods.svg new file mode 100644 index 0000000..bb75f4b --- /dev/null +++ b/launcher/resources/pe_colored/scalable/jarmods.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + diff --git a/launcher/resources/pe_colored/scalable/java.svg b/launcher/resources/pe_colored/scalable/java.svg new file mode 100644 index 0000000..32c0225 --- /dev/null +++ b/launcher/resources/pe_colored/scalable/java.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_colored/scalable/language.svg b/launcher/resources/pe_colored/scalable/language.svg new file mode 100644 index 0000000..80c1dca --- /dev/null +++ b/launcher/resources/pe_colored/scalable/language.svg @@ -0,0 +1,44 @@ + +image/svg+xml + + + + + + + + \ No newline at end of file diff --git a/launcher/resources/pe_colored/scalable/launch.svg b/launcher/resources/pe_colored/scalable/launch.svg new file mode 100644 index 0000000..7671338 --- /dev/null +++ b/launcher/resources/pe_colored/scalable/launch.svg @@ -0,0 +1,23 @@ + + + + diff --git a/launcher/resources/pe_colored/scalable/loadermods.svg b/launcher/resources/pe_colored/scalable/loadermods.svg new file mode 100644 index 0000000..2d80c7f --- /dev/null +++ b/launcher/resources/pe_colored/scalable/loadermods.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + diff --git a/launcher/resources/pe_colored/scalable/log.svg b/launcher/resources/pe_colored/scalable/log.svg new file mode 100644 index 0000000..42659b5 --- /dev/null +++ b/launcher/resources/pe_colored/scalable/log.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + diff --git a/launcher/resources/pe_colored/scalable/minecraft.svg b/launcher/resources/pe_colored/scalable/minecraft.svg new file mode 100644 index 0000000..5281548 --- /dev/null +++ b/launcher/resources/pe_colored/scalable/minecraft.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + diff --git a/launcher/resources/pe_colored/scalable/new.svg b/launcher/resources/pe_colored/scalable/new.svg new file mode 100644 index 0000000..f18ed28 --- /dev/null +++ b/launcher/resources/pe_colored/scalable/new.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + diff --git a/launcher/resources/pe_colored/scalable/news.svg b/launcher/resources/pe_colored/scalable/news.svg new file mode 100644 index 0000000..4f924cd --- /dev/null +++ b/launcher/resources/pe_colored/scalable/news.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/launcher/resources/pe_colored/scalable/notes.svg b/launcher/resources/pe_colored/scalable/notes.svg new file mode 100644 index 0000000..55ece16 --- /dev/null +++ b/launcher/resources/pe_colored/scalable/notes.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_colored/scalable/patreon.svg b/launcher/resources/pe_colored/scalable/patreon.svg new file mode 100644 index 0000000..d3c6d2d --- /dev/null +++ b/launcher/resources/pe_colored/scalable/patreon.svg @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/launcher/resources/pe_colored/scalable/proxy.svg b/launcher/resources/pe_colored/scalable/proxy.svg new file mode 100644 index 0000000..0aee69b --- /dev/null +++ b/launcher/resources/pe_colored/scalable/proxy.svg @@ -0,0 +1,15 @@ + + + + + + + + + + diff --git a/launcher/resources/pe_colored/scalable/refresh.svg b/launcher/resources/pe_colored/scalable/refresh.svg new file mode 100644 index 0000000..c2e7e91 --- /dev/null +++ b/launcher/resources/pe_colored/scalable/refresh.svg @@ -0,0 +1,11 @@ + + + + + + + diff --git a/launcher/resources/pe_colored/scalable/rename.svg b/launcher/resources/pe_colored/scalable/rename.svg new file mode 100644 index 0000000..216cccb --- /dev/null +++ b/launcher/resources/pe_colored/scalable/rename.svg @@ -0,0 +1,22 @@ + + + diff --git a/launcher/resources/pe_colored/scalable/resourcepacks.svg b/launcher/resources/pe_colored/scalable/resourcepacks.svg new file mode 100644 index 0000000..0318354 --- /dev/null +++ b/launcher/resources/pe_colored/scalable/resourcepacks.svg @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/launcher/resources/pe_colored/scalable/screenshots.svg b/launcher/resources/pe_colored/scalable/screenshots.svg new file mode 100644 index 0000000..844fcba --- /dev/null +++ b/launcher/resources/pe_colored/scalable/screenshots.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + diff --git a/launcher/resources/pe_colored/scalable/server.svg b/launcher/resources/pe_colored/scalable/server.svg new file mode 100644 index 0000000..60b469e --- /dev/null +++ b/launcher/resources/pe_colored/scalable/server.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/launcher/resources/pe_colored/scalable/settings.svg b/launcher/resources/pe_colored/scalable/settings.svg new file mode 100644 index 0000000..72032f8 --- /dev/null +++ b/launcher/resources/pe_colored/scalable/settings.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_colored/scalable/shaderpacks.svg b/launcher/resources/pe_colored/scalable/shaderpacks.svg new file mode 100644 index 0000000..9400b93 --- /dev/null +++ b/launcher/resources/pe_colored/scalable/shaderpacks.svg @@ -0,0 +1,83 @@ + +image/svg+xml + + + + + + diff --git a/launcher/resources/pe_colored/scalable/shortcut.svg b/launcher/resources/pe_colored/scalable/shortcut.svg new file mode 100644 index 0000000..1469674 --- /dev/null +++ b/launcher/resources/pe_colored/scalable/shortcut.svg @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/launcher/resources/pe_colored/scalable/status-bad.svg b/launcher/resources/pe_colored/scalable/status-bad.svg new file mode 100644 index 0000000..bc42c24 --- /dev/null +++ b/launcher/resources/pe_colored/scalable/status-bad.svg @@ -0,0 +1,10 @@ + + + + + + + diff --git a/launcher/resources/pe_colored/scalable/status-good.svg b/launcher/resources/pe_colored/scalable/status-good.svg new file mode 100644 index 0000000..4cfa56f --- /dev/null +++ b/launcher/resources/pe_colored/scalable/status-good.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + diff --git a/launcher/resources/pe_colored/scalable/status-yellow.svg b/launcher/resources/pe_colored/scalable/status-yellow.svg new file mode 100644 index 0000000..0551fed --- /dev/null +++ b/launcher/resources/pe_colored/scalable/status-yellow.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + diff --git a/launcher/resources/pe_colored/scalable/tag.svg b/launcher/resources/pe_colored/scalable/tag.svg new file mode 100644 index 0000000..69303fe --- /dev/null +++ b/launcher/resources/pe_colored/scalable/tag.svg @@ -0,0 +1,42 @@ + + + + diff --git a/launcher/resources/pe_colored/scalable/viewfolder.svg b/launcher/resources/pe_colored/scalable/viewfolder.svg new file mode 100644 index 0000000..9183257 --- /dev/null +++ b/launcher/resources/pe_colored/scalable/viewfolder.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + diff --git a/launcher/resources/pe_colored/scalable/worlds.svg b/launcher/resources/pe_colored/scalable/worlds.svg new file mode 100644 index 0000000..087ba7c --- /dev/null +++ b/launcher/resources/pe_colored/scalable/worlds.svg @@ -0,0 +1,50 @@ + +image/svg+xml \ No newline at end of file diff --git a/launcher/resources/pe_dark/index.theme b/launcher/resources/pe_dark/index.theme new file mode 100644 index 0000000..4cfbf09 --- /dev/null +++ b/launcher/resources/pe_dark/index.theme @@ -0,0 +1,11 @@ +[Icon Theme] +Name=Simple (Dark) +Comment=Icons by pexner (dark) +Inherits=multimc +Directories=scalable + +[scalable] +Size=48 +Type=Scalable +MinSize=16 +MaxSize=256 diff --git a/launcher/resources/pe_dark/pe_dark.qrc b/launcher/resources/pe_dark/pe_dark.qrc new file mode 100644 index 0000000..9ccfece --- /dev/null +++ b/launcher/resources/pe_dark/pe_dark.qrc @@ -0,0 +1,46 @@ + + + + index.theme + scalable/about.svg + scalable/accounts.svg + scalable/bug.svg + scalable/centralmods.svg + scalable/checkupdate.svg + scalable/copy.svg + scalable/coremods.svg + scalable/custom-commands.svg + scalable/datapacks.svg + scalable/externaltools.svg + scalable/help.svg + scalable/instance-settings.svg + scalable/jarmods.svg + scalable/java.svg + scalable/language.svg + scalable/loadermods.svg + scalable/log.svg + scalable/minecraft.svg + scalable/new.svg + scalable/news.svg + scalable/notes.svg + scalable/proxy.svg + scalable/refresh.svg + scalable/resourcepacks.svg + scalable/shaderpacks.svg + scalable/screenshots.svg + scalable/settings.svg + scalable/status-bad.svg + scalable/status-good.svg + scalable/status-yellow.svg + scalable/viewfolder.svg + scalable/worlds.svg + scalable/delete.svg + scalable/tag.svg + scalable/export.svg + scalable/rename.svg + scalable/launch.svg + scalable/shortcut.svg + scalable/server.svg + scalable/appearance.svg + + diff --git a/launcher/resources/pe_dark/scalable/about.svg b/launcher/resources/pe_dark/scalable/about.svg new file mode 100644 index 0000000..e75ea6c --- /dev/null +++ b/launcher/resources/pe_dark/scalable/about.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + diff --git a/launcher/resources/pe_dark/scalable/accounts.svg b/launcher/resources/pe_dark/scalable/accounts.svg new file mode 100644 index 0000000..6d46b2d --- /dev/null +++ b/launcher/resources/pe_dark/scalable/accounts.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_dark/scalable/appearance.svg b/launcher/resources/pe_dark/scalable/appearance.svg new file mode 100644 index 0000000..24b7283 --- /dev/null +++ b/launcher/resources/pe_dark/scalable/appearance.svg @@ -0,0 +1,65 @@ + + + + diff --git a/launcher/resources/pe_dark/scalable/bug.svg b/launcher/resources/pe_dark/scalable/bug.svg new file mode 100644 index 0000000..9da71ad --- /dev/null +++ b/launcher/resources/pe_dark/scalable/bug.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_dark/scalable/centralmods.svg b/launcher/resources/pe_dark/scalable/centralmods.svg new file mode 100644 index 0000000..f3b0c0e --- /dev/null +++ b/launcher/resources/pe_dark/scalable/centralmods.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_dark/scalable/checkupdate.svg b/launcher/resources/pe_dark/scalable/checkupdate.svg new file mode 100644 index 0000000..9758544 --- /dev/null +++ b/launcher/resources/pe_dark/scalable/checkupdate.svg @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/launcher/resources/pe_dark/scalable/copy.svg b/launcher/resources/pe_dark/scalable/copy.svg new file mode 100644 index 0000000..8c30ac0 --- /dev/null +++ b/launcher/resources/pe_dark/scalable/copy.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_dark/scalable/coremods.svg b/launcher/resources/pe_dark/scalable/coremods.svg new file mode 100644 index 0000000..1e2eb22 --- /dev/null +++ b/launcher/resources/pe_dark/scalable/coremods.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_dark/scalable/custom-commands.svg b/launcher/resources/pe_dark/scalable/custom-commands.svg new file mode 100644 index 0000000..42185e3 --- /dev/null +++ b/launcher/resources/pe_dark/scalable/custom-commands.svg @@ -0,0 +1,335 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_dark/scalable/datapacks.svg b/launcher/resources/pe_dark/scalable/datapacks.svg new file mode 100644 index 0000000..46a3445 --- /dev/null +++ b/launcher/resources/pe_dark/scalable/datapacks.svg @@ -0,0 +1,345 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_dark/scalable/delete.svg b/launcher/resources/pe_dark/scalable/delete.svg new file mode 100644 index 0000000..76e52a4 --- /dev/null +++ b/launcher/resources/pe_dark/scalable/delete.svg @@ -0,0 +1,70 @@ + + + + + diff --git a/launcher/resources/pe_dark/scalable/export.svg b/launcher/resources/pe_dark/scalable/export.svg new file mode 100644 index 0000000..faec8fc --- /dev/null +++ b/launcher/resources/pe_dark/scalable/export.svg @@ -0,0 +1,36 @@ + + + + + + + diff --git a/launcher/resources/pe_dark/scalable/externaltools.svg b/launcher/resources/pe_dark/scalable/externaltools.svg new file mode 100644 index 0000000..29b45f2 --- /dev/null +++ b/launcher/resources/pe_dark/scalable/externaltools.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_dark/scalable/help.svg b/launcher/resources/pe_dark/scalable/help.svg new file mode 100644 index 0000000..2a1518a --- /dev/null +++ b/launcher/resources/pe_dark/scalable/help.svg @@ -0,0 +1,34 @@ + +image/svg+xml \ No newline at end of file diff --git a/launcher/resources/pe_dark/scalable/instance-settings.svg b/launcher/resources/pe_dark/scalable/instance-settings.svg new file mode 100644 index 0000000..c9f701e --- /dev/null +++ b/launcher/resources/pe_dark/scalable/instance-settings.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_dark/scalable/jarmods.svg b/launcher/resources/pe_dark/scalable/jarmods.svg new file mode 100644 index 0000000..cb9a97b --- /dev/null +++ b/launcher/resources/pe_dark/scalable/jarmods.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_dark/scalable/java.svg b/launcher/resources/pe_dark/scalable/java.svg new file mode 100644 index 0000000..9e1091f --- /dev/null +++ b/launcher/resources/pe_dark/scalable/java.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_dark/scalable/language.svg b/launcher/resources/pe_dark/scalable/language.svg new file mode 100644 index 0000000..1a9b4c5 --- /dev/null +++ b/launcher/resources/pe_dark/scalable/language.svg @@ -0,0 +1,45 @@ + +image/svg+xml \ No newline at end of file diff --git a/launcher/resources/pe_dark/scalable/launch.svg b/launcher/resources/pe_dark/scalable/launch.svg new file mode 100644 index 0000000..3746e2d --- /dev/null +++ b/launcher/resources/pe_dark/scalable/launch.svg @@ -0,0 +1,17 @@ + + + + diff --git a/launcher/resources/pe_dark/scalable/loadermods.svg b/launcher/resources/pe_dark/scalable/loadermods.svg new file mode 100644 index 0000000..24226a0 --- /dev/null +++ b/launcher/resources/pe_dark/scalable/loadermods.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_dark/scalable/log.svg b/launcher/resources/pe_dark/scalable/log.svg new file mode 100644 index 0000000..68686a7 --- /dev/null +++ b/launcher/resources/pe_dark/scalable/log.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_dark/scalable/minecraft.svg b/launcher/resources/pe_dark/scalable/minecraft.svg new file mode 100644 index 0000000..01baf57 --- /dev/null +++ b/launcher/resources/pe_dark/scalable/minecraft.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_dark/scalable/new.svg b/launcher/resources/pe_dark/scalable/new.svg new file mode 100644 index 0000000..0377ace --- /dev/null +++ b/launcher/resources/pe_dark/scalable/new.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_dark/scalable/news.svg b/launcher/resources/pe_dark/scalable/news.svg new file mode 100644 index 0000000..84979dc --- /dev/null +++ b/launcher/resources/pe_dark/scalable/news.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/launcher/resources/pe_dark/scalable/notes.svg b/launcher/resources/pe_dark/scalable/notes.svg new file mode 100644 index 0000000..7264972 --- /dev/null +++ b/launcher/resources/pe_dark/scalable/notes.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_dark/scalable/patreon.svg b/launcher/resources/pe_dark/scalable/patreon.svg new file mode 100644 index 0000000..01cb279 --- /dev/null +++ b/launcher/resources/pe_dark/scalable/patreon.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_dark/scalable/proxy.svg b/launcher/resources/pe_dark/scalable/proxy.svg new file mode 100644 index 0000000..98bcfac --- /dev/null +++ b/launcher/resources/pe_dark/scalable/proxy.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_dark/scalable/refresh.svg b/launcher/resources/pe_dark/scalable/refresh.svg new file mode 100644 index 0000000..c227cd6 --- /dev/null +++ b/launcher/resources/pe_dark/scalable/refresh.svg @@ -0,0 +1,11 @@ + + + + + + + diff --git a/launcher/resources/pe_dark/scalable/rename.svg b/launcher/resources/pe_dark/scalable/rename.svg new file mode 100644 index 0000000..740f8d2 --- /dev/null +++ b/launcher/resources/pe_dark/scalable/rename.svg @@ -0,0 +1,19 @@ + + + diff --git a/launcher/resources/pe_dark/scalable/resourcepacks.svg b/launcher/resources/pe_dark/scalable/resourcepacks.svg new file mode 100644 index 0000000..0db2beb --- /dev/null +++ b/launcher/resources/pe_dark/scalable/resourcepacks.svg @@ -0,0 +1,13 @@ + + + + + + + diff --git a/launcher/resources/pe_dark/scalable/screenshots.svg b/launcher/resources/pe_dark/scalable/screenshots.svg new file mode 100644 index 0000000..2803b9a --- /dev/null +++ b/launcher/resources/pe_dark/scalable/screenshots.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_dark/scalable/server.svg b/launcher/resources/pe_dark/scalable/server.svg new file mode 100644 index 0000000..493d8d8 --- /dev/null +++ b/launcher/resources/pe_dark/scalable/server.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/launcher/resources/pe_dark/scalable/settings.svg b/launcher/resources/pe_dark/scalable/settings.svg new file mode 100644 index 0000000..c9f701e --- /dev/null +++ b/launcher/resources/pe_dark/scalable/settings.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_dark/scalable/shaderpacks.svg b/launcher/resources/pe_dark/scalable/shaderpacks.svg new file mode 100644 index 0000000..9cca756 --- /dev/null +++ b/launcher/resources/pe_dark/scalable/shaderpacks.svg @@ -0,0 +1,81 @@ + +image/svg+xml diff --git a/launcher/resources/pe_dark/scalable/shortcut.svg b/launcher/resources/pe_dark/scalable/shortcut.svg new file mode 100644 index 0000000..29b45f2 --- /dev/null +++ b/launcher/resources/pe_dark/scalable/shortcut.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_dark/scalable/status-bad.svg b/launcher/resources/pe_dark/scalable/status-bad.svg new file mode 100644 index 0000000..f455965 --- /dev/null +++ b/launcher/resources/pe_dark/scalable/status-bad.svg @@ -0,0 +1,10 @@ + + + + + + + diff --git a/launcher/resources/pe_dark/scalable/status-good.svg b/launcher/resources/pe_dark/scalable/status-good.svg new file mode 100644 index 0000000..4ba91f2 --- /dev/null +++ b/launcher/resources/pe_dark/scalable/status-good.svg @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/launcher/resources/pe_dark/scalable/status-yellow.svg b/launcher/resources/pe_dark/scalable/status-yellow.svg new file mode 100644 index 0000000..6913381 --- /dev/null +++ b/launcher/resources/pe_dark/scalable/status-yellow.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + diff --git a/launcher/resources/pe_dark/scalable/tag.svg b/launcher/resources/pe_dark/scalable/tag.svg new file mode 100644 index 0000000..63772af --- /dev/null +++ b/launcher/resources/pe_dark/scalable/tag.svg @@ -0,0 +1,30 @@ + + + + diff --git a/launcher/resources/pe_dark/scalable/viewfolder.svg b/launcher/resources/pe_dark/scalable/viewfolder.svg new file mode 100644 index 0000000..3af3624 --- /dev/null +++ b/launcher/resources/pe_dark/scalable/viewfolder.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_dark/scalable/worlds.svg b/launcher/resources/pe_dark/scalable/worlds.svg new file mode 100644 index 0000000..2b01070 --- /dev/null +++ b/launcher/resources/pe_dark/scalable/worlds.svg @@ -0,0 +1,63 @@ + +image/svg+xml \ No newline at end of file diff --git a/launcher/resources/pe_light/index.theme b/launcher/resources/pe_light/index.theme new file mode 100644 index 0000000..87b76d1 --- /dev/null +++ b/launcher/resources/pe_light/index.theme @@ -0,0 +1,11 @@ +[Icon Theme] +Name=Simple (Light) +Comment=Icons by pexner (light) +Inherits=multimc +Directories=scalable + +[scalable] +Size=48 +Type=Scalable +MinSize=16 +MaxSize=256 diff --git a/launcher/resources/pe_light/pe_light.qrc b/launcher/resources/pe_light/pe_light.qrc new file mode 100644 index 0000000..a6d49b8 --- /dev/null +++ b/launcher/resources/pe_light/pe_light.qrc @@ -0,0 +1,46 @@ + + + + index.theme + scalable/about.svg + scalable/accounts.svg + scalable/bug.svg + scalable/centralmods.svg + scalable/checkupdate.svg + scalable/copy.svg + scalable/coremods.svg + scalable/custom-commands.svg + scalable/datapacks.svg + scalable/externaltools.svg + scalable/help.svg + scalable/instance-settings.svg + scalable/jarmods.svg + scalable/java.svg + scalable/language.svg + scalable/loadermods.svg + scalable/log.svg + scalable/minecraft.svg + scalable/new.svg + scalable/news.svg + scalable/notes.svg + scalable/proxy.svg + scalable/refresh.svg + scalable/resourcepacks.svg + scalable/shaderpacks.svg + scalable/screenshots.svg + scalable/settings.svg + scalable/status-bad.svg + scalable/status-good.svg + scalable/status-yellow.svg + scalable/viewfolder.svg + scalable/worlds.svg + scalable/delete.svg + scalable/tag.svg + scalable/export.svg + scalable/rename.svg + scalable/launch.svg + scalable/shortcut.svg + scalable/server.svg + scalable/appearance.svg + + diff --git a/launcher/resources/pe_light/scalable/about.svg b/launcher/resources/pe_light/scalable/about.svg new file mode 100644 index 0000000..8d00c32 --- /dev/null +++ b/launcher/resources/pe_light/scalable/about.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + diff --git a/launcher/resources/pe_light/scalable/accounts.svg b/launcher/resources/pe_light/scalable/accounts.svg new file mode 100644 index 0000000..3a092d0 --- /dev/null +++ b/launcher/resources/pe_light/scalable/accounts.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_light/scalable/appearance.svg b/launcher/resources/pe_light/scalable/appearance.svg new file mode 100644 index 0000000..61b2f34 --- /dev/null +++ b/launcher/resources/pe_light/scalable/appearance.svg @@ -0,0 +1,66 @@ + + + + diff --git a/launcher/resources/pe_light/scalable/bug.svg b/launcher/resources/pe_light/scalable/bug.svg new file mode 100644 index 0000000..ccb64bc --- /dev/null +++ b/launcher/resources/pe_light/scalable/bug.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_light/scalable/centralmods.svg b/launcher/resources/pe_light/scalable/centralmods.svg new file mode 100644 index 0000000..050fdc5 --- /dev/null +++ b/launcher/resources/pe_light/scalable/centralmods.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_light/scalable/checkupdate.svg b/launcher/resources/pe_light/scalable/checkupdate.svg new file mode 100644 index 0000000..08b8dcd --- /dev/null +++ b/launcher/resources/pe_light/scalable/checkupdate.svg @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/launcher/resources/pe_light/scalable/copy.svg b/launcher/resources/pe_light/scalable/copy.svg new file mode 100644 index 0000000..abdcce0 --- /dev/null +++ b/launcher/resources/pe_light/scalable/copy.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_light/scalable/coremods.svg b/launcher/resources/pe_light/scalable/coremods.svg new file mode 100644 index 0000000..c8fb0eb --- /dev/null +++ b/launcher/resources/pe_light/scalable/coremods.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_light/scalable/custom-commands.svg b/launcher/resources/pe_light/scalable/custom-commands.svg new file mode 100644 index 0000000..b3dfe12 --- /dev/null +++ b/launcher/resources/pe_light/scalable/custom-commands.svg @@ -0,0 +1,336 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_light/scalable/datapacks.svg b/launcher/resources/pe_light/scalable/datapacks.svg new file mode 100644 index 0000000..b8d562f --- /dev/null +++ b/launcher/resources/pe_light/scalable/datapacks.svg @@ -0,0 +1,345 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_light/scalable/delete.svg b/launcher/resources/pe_light/scalable/delete.svg new file mode 100644 index 0000000..0e41add --- /dev/null +++ b/launcher/resources/pe_light/scalable/delete.svg @@ -0,0 +1,70 @@ + + + + + diff --git a/launcher/resources/pe_light/scalable/export.svg b/launcher/resources/pe_light/scalable/export.svg new file mode 100644 index 0000000..eee6193 --- /dev/null +++ b/launcher/resources/pe_light/scalable/export.svg @@ -0,0 +1,37 @@ + + + + + + + diff --git a/launcher/resources/pe_light/scalable/externaltools.svg b/launcher/resources/pe_light/scalable/externaltools.svg new file mode 100644 index 0000000..4d232bc --- /dev/null +++ b/launcher/resources/pe_light/scalable/externaltools.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_light/scalable/help.svg b/launcher/resources/pe_light/scalable/help.svg new file mode 100644 index 0000000..f820c67 --- /dev/null +++ b/launcher/resources/pe_light/scalable/help.svg @@ -0,0 +1,36 @@ + +image/svg+xml \ No newline at end of file diff --git a/launcher/resources/pe_light/scalable/instance-settings.svg b/launcher/resources/pe_light/scalable/instance-settings.svg new file mode 100644 index 0000000..83b92a5 --- /dev/null +++ b/launcher/resources/pe_light/scalable/instance-settings.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_light/scalable/jarmods.svg b/launcher/resources/pe_light/scalable/jarmods.svg new file mode 100644 index 0000000..9852c80 --- /dev/null +++ b/launcher/resources/pe_light/scalable/jarmods.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_light/scalable/java.svg b/launcher/resources/pe_light/scalable/java.svg new file mode 100644 index 0000000..0584058 --- /dev/null +++ b/launcher/resources/pe_light/scalable/java.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_light/scalable/language.svg b/launcher/resources/pe_light/scalable/language.svg new file mode 100644 index 0000000..57d5e3d --- /dev/null +++ b/launcher/resources/pe_light/scalable/language.svg @@ -0,0 +1,80 @@ + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/launcher/resources/pe_light/scalable/launch.svg b/launcher/resources/pe_light/scalable/launch.svg new file mode 100644 index 0000000..6c27b7e --- /dev/null +++ b/launcher/resources/pe_light/scalable/launch.svg @@ -0,0 +1,17 @@ + + + + diff --git a/launcher/resources/pe_light/scalable/loadermods.svg b/launcher/resources/pe_light/scalable/loadermods.svg new file mode 100644 index 0000000..913c196 --- /dev/null +++ b/launcher/resources/pe_light/scalable/loadermods.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_light/scalable/log.svg b/launcher/resources/pe_light/scalable/log.svg new file mode 100644 index 0000000..82282ca --- /dev/null +++ b/launcher/resources/pe_light/scalable/log.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_light/scalable/minecraft.svg b/launcher/resources/pe_light/scalable/minecraft.svg new file mode 100644 index 0000000..d772111 --- /dev/null +++ b/launcher/resources/pe_light/scalable/minecraft.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_light/scalable/new.svg b/launcher/resources/pe_light/scalable/new.svg new file mode 100644 index 0000000..96fd1f5 --- /dev/null +++ b/launcher/resources/pe_light/scalable/new.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_light/scalable/news.svg b/launcher/resources/pe_light/scalable/news.svg new file mode 100644 index 0000000..6f184af --- /dev/null +++ b/launcher/resources/pe_light/scalable/news.svg @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/launcher/resources/pe_light/scalable/notes.svg b/launcher/resources/pe_light/scalable/notes.svg new file mode 100644 index 0000000..02dc11e --- /dev/null +++ b/launcher/resources/pe_light/scalable/notes.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_light/scalable/patreon.svg b/launcher/resources/pe_light/scalable/patreon.svg new file mode 100644 index 0000000..0bd0882 --- /dev/null +++ b/launcher/resources/pe_light/scalable/patreon.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_light/scalable/proxy.svg b/launcher/resources/pe_light/scalable/proxy.svg new file mode 100644 index 0000000..9de8d6d --- /dev/null +++ b/launcher/resources/pe_light/scalable/proxy.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_light/scalable/refresh.svg b/launcher/resources/pe_light/scalable/refresh.svg new file mode 100644 index 0000000..9a724d9 --- /dev/null +++ b/launcher/resources/pe_light/scalable/refresh.svg @@ -0,0 +1,11 @@ + + + + + + + diff --git a/launcher/resources/pe_light/scalable/rename.svg b/launcher/resources/pe_light/scalable/rename.svg new file mode 100644 index 0000000..f11639a --- /dev/null +++ b/launcher/resources/pe_light/scalable/rename.svg @@ -0,0 +1,19 @@ + + + diff --git a/launcher/resources/pe_light/scalable/resourcepacks.svg b/launcher/resources/pe_light/scalable/resourcepacks.svg new file mode 100644 index 0000000..7d6323f --- /dev/null +++ b/launcher/resources/pe_light/scalable/resourcepacks.svg @@ -0,0 +1,13 @@ + + + + + + + diff --git a/launcher/resources/pe_light/scalable/screenshots.svg b/launcher/resources/pe_light/scalable/screenshots.svg new file mode 100644 index 0000000..f2887be --- /dev/null +++ b/launcher/resources/pe_light/scalable/screenshots.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_light/scalable/server.svg b/launcher/resources/pe_light/scalable/server.svg new file mode 100644 index 0000000..61acb6a --- /dev/null +++ b/launcher/resources/pe_light/scalable/server.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/launcher/resources/pe_light/scalable/settings.svg b/launcher/resources/pe_light/scalable/settings.svg new file mode 100644 index 0000000..83b92a5 --- /dev/null +++ b/launcher/resources/pe_light/scalable/settings.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_light/scalable/shaderpacks.svg b/launcher/resources/pe_light/scalable/shaderpacks.svg new file mode 100644 index 0000000..76356ee --- /dev/null +++ b/launcher/resources/pe_light/scalable/shaderpacks.svg @@ -0,0 +1,81 @@ + +image/svg+xml diff --git a/launcher/resources/pe_light/scalable/shortcut.svg b/launcher/resources/pe_light/scalable/shortcut.svg new file mode 100644 index 0000000..4d232bc --- /dev/null +++ b/launcher/resources/pe_light/scalable/shortcut.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_light/scalable/status-bad.svg b/launcher/resources/pe_light/scalable/status-bad.svg new file mode 100644 index 0000000..2c24970 --- /dev/null +++ b/launcher/resources/pe_light/scalable/status-bad.svg @@ -0,0 +1,10 @@ + + + + + + + diff --git a/launcher/resources/pe_light/scalable/status-good.svg b/launcher/resources/pe_light/scalable/status-good.svg new file mode 100644 index 0000000..bf9a417 --- /dev/null +++ b/launcher/resources/pe_light/scalable/status-good.svg @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/launcher/resources/pe_light/scalable/status-yellow.svg b/launcher/resources/pe_light/scalable/status-yellow.svg new file mode 100644 index 0000000..f7d2236 --- /dev/null +++ b/launcher/resources/pe_light/scalable/status-yellow.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + diff --git a/launcher/resources/pe_light/scalable/tag.svg b/launcher/resources/pe_light/scalable/tag.svg new file mode 100644 index 0000000..3f750a8 --- /dev/null +++ b/launcher/resources/pe_light/scalable/tag.svg @@ -0,0 +1,23 @@ + + + + diff --git a/launcher/resources/pe_light/scalable/viewfolder.svg b/launcher/resources/pe_light/scalable/viewfolder.svg new file mode 100644 index 0000000..b36343f --- /dev/null +++ b/launcher/resources/pe_light/scalable/viewfolder.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/pe_light/scalable/worlds.svg b/launcher/resources/pe_light/scalable/worlds.svg new file mode 100644 index 0000000..bf4c21a --- /dev/null +++ b/launcher/resources/pe_light/scalable/worlds.svg @@ -0,0 +1,64 @@ + +image/svg+xml \ No newline at end of file diff --git a/launcher/resources/racked_ru/128x128/instances/chicken_legacy.png b/launcher/resources/racked_ru/128x128/instances/chicken_legacy.png new file mode 100644 index 0000000..b4945d7 Binary files /dev/null and b/launcher/resources/racked_ru/128x128/instances/chicken_legacy.png differ diff --git a/launcher/resources/racked_ru/128x128/instances/creeper_legacy.png b/launcher/resources/racked_ru/128x128/instances/creeper_legacy.png new file mode 100644 index 0000000..92d9231 Binary files /dev/null and b/launcher/resources/racked_ru/128x128/instances/creeper_legacy.png differ diff --git a/launcher/resources/racked_ru/128x128/instances/enderpearl_legacy.png b/launcher/resources/racked_ru/128x128/instances/enderpearl_legacy.png new file mode 100644 index 0000000..fd910da Binary files /dev/null and b/launcher/resources/racked_ru/128x128/instances/enderpearl_legacy.png differ diff --git a/launcher/resources/racked_ru/128x128/instances/flame_legacy.png b/launcher/resources/racked_ru/128x128/instances/flame_legacy.png new file mode 100644 index 0000000..3dd8500 Binary files /dev/null and b/launcher/resources/racked_ru/128x128/instances/flame_legacy.png differ diff --git a/launcher/resources/racked_ru/128x128/instances/forge.png b/launcher/resources/racked_ru/128x128/instances/forge.png new file mode 100644 index 0000000..10c5f8d Binary files /dev/null and b/launcher/resources/racked_ru/128x128/instances/forge.png differ diff --git a/launcher/resources/racked_ru/128x128/instances/ftb_glow.png b/launcher/resources/racked_ru/128x128/instances/ftb_glow.png new file mode 100644 index 0000000..a8bfbbb Binary files /dev/null and b/launcher/resources/racked_ru/128x128/instances/ftb_glow.png differ diff --git a/launcher/resources/racked_ru/128x128/instances/ftb_logo_legacy.png b/launcher/resources/racked_ru/128x128/instances/ftb_logo_legacy.png new file mode 100644 index 0000000..01aa4d5 Binary files /dev/null and b/launcher/resources/racked_ru/128x128/instances/ftb_logo_legacy.png differ diff --git a/launcher/resources/racked_ru/128x128/instances/gear_legacy.png b/launcher/resources/racked_ru/128x128/instances/gear_legacy.png new file mode 100644 index 0000000..bb46fe0 Binary files /dev/null and b/launcher/resources/racked_ru/128x128/instances/gear_legacy.png differ diff --git a/launcher/resources/racked_ru/128x128/instances/herobrine_legacy.png b/launcher/resources/racked_ru/128x128/instances/herobrine_legacy.png new file mode 100644 index 0000000..d25d1b1 Binary files /dev/null and b/launcher/resources/racked_ru/128x128/instances/herobrine_legacy.png differ diff --git a/launcher/resources/racked_ru/128x128/instances/infinity_legacy.png b/launcher/resources/racked_ru/128x128/instances/infinity_legacy.png new file mode 100644 index 0000000..322ab43 Binary files /dev/null and b/launcher/resources/racked_ru/128x128/instances/infinity_legacy.png differ diff --git a/launcher/resources/racked_ru/128x128/instances/liteloader.png b/launcher/resources/racked_ru/128x128/instances/liteloader.png new file mode 100644 index 0000000..acd977d Binary files /dev/null and b/launcher/resources/racked_ru/128x128/instances/liteloader.png differ diff --git a/launcher/resources/racked_ru/128x128/instances/magitech_legacy.png b/launcher/resources/racked_ru/128x128/instances/magitech_legacy.png new file mode 100644 index 0000000..c83d0c9 Binary files /dev/null and b/launcher/resources/racked_ru/128x128/instances/magitech_legacy.png differ diff --git a/launcher/resources/racked_ru/128x128/instances/meat_legacy.png b/launcher/resources/racked_ru/128x128/instances/meat_legacy.png new file mode 100644 index 0000000..14a50be Binary files /dev/null and b/launcher/resources/racked_ru/128x128/instances/meat_legacy.png differ diff --git a/launcher/resources/racked_ru/128x128/instances/netherstar_legacy.png b/launcher/resources/racked_ru/128x128/instances/netherstar_legacy.png new file mode 100644 index 0000000..86cc87b Binary files /dev/null and b/launcher/resources/racked_ru/128x128/instances/netherstar_legacy.png differ diff --git a/launcher/resources/racked_ru/128x128/instances/skeleton_legacy.png b/launcher/resources/racked_ru/128x128/instances/skeleton_legacy.png new file mode 100644 index 0000000..416ca66 Binary files /dev/null and b/launcher/resources/racked_ru/128x128/instances/skeleton_legacy.png differ diff --git a/launcher/resources/racked_ru/128x128/instances/squarecreeper_legacy.png b/launcher/resources/racked_ru/128x128/instances/squarecreeper_legacy.png new file mode 100644 index 0000000..b7e2bdc Binary files /dev/null and b/launcher/resources/racked_ru/128x128/instances/squarecreeper_legacy.png differ diff --git a/launcher/resources/racked_ru/128x128/instances/steve_legacy.png b/launcher/resources/racked_ru/128x128/instances/steve_legacy.png new file mode 100644 index 0000000..afe8aaf Binary files /dev/null and b/launcher/resources/racked_ru/128x128/instances/steve_legacy.png differ diff --git a/launcher/resources/racked_ru/32x32/instances/brick_legacy.png b/launcher/resources/racked_ru/32x32/instances/brick_legacy.png new file mode 100644 index 0000000..7d35f4d Binary files /dev/null and b/launcher/resources/racked_ru/32x32/instances/brick_legacy.png differ diff --git a/launcher/resources/racked_ru/32x32/instances/chicken_legacy.png b/launcher/resources/racked_ru/32x32/instances/chicken_legacy.png new file mode 100644 index 0000000..7991410 Binary files /dev/null and b/launcher/resources/racked_ru/32x32/instances/chicken_legacy.png differ diff --git a/launcher/resources/racked_ru/32x32/instances/creeper_legacy.png b/launcher/resources/racked_ru/32x32/instances/creeper_legacy.png new file mode 100644 index 0000000..571d2de Binary files /dev/null and b/launcher/resources/racked_ru/32x32/instances/creeper_legacy.png differ diff --git a/launcher/resources/racked_ru/32x32/instances/diamond_legacy.png b/launcher/resources/racked_ru/32x32/instances/diamond_legacy.png new file mode 100644 index 0000000..3ad9c00 Binary files /dev/null and b/launcher/resources/racked_ru/32x32/instances/diamond_legacy.png differ diff --git a/launcher/resources/racked_ru/32x32/instances/dirt_legacy.png b/launcher/resources/racked_ru/32x32/instances/dirt_legacy.png new file mode 100644 index 0000000..719a45e Binary files /dev/null and b/launcher/resources/racked_ru/32x32/instances/dirt_legacy.png differ diff --git a/launcher/resources/racked_ru/32x32/instances/enderpearl_legacy.png b/launcher/resources/racked_ru/32x32/instances/enderpearl_legacy.png new file mode 100644 index 0000000..e0262f6 Binary files /dev/null and b/launcher/resources/racked_ru/32x32/instances/enderpearl_legacy.png differ diff --git a/launcher/resources/racked_ru/32x32/instances/ftb_glow.png b/launcher/resources/racked_ru/32x32/instances/ftb_glow.png new file mode 100644 index 0000000..7437b27 Binary files /dev/null and b/launcher/resources/racked_ru/32x32/instances/ftb_glow.png differ diff --git a/launcher/resources/racked_ru/32x32/instances/ftb_logo_legacy.png b/launcher/resources/racked_ru/32x32/instances/ftb_logo_legacy.png new file mode 100644 index 0000000..a70109b Binary files /dev/null and b/launcher/resources/racked_ru/32x32/instances/ftb_logo_legacy.png differ diff --git a/launcher/resources/racked_ru/32x32/instances/gear_legacy.png b/launcher/resources/racked_ru/32x32/instances/gear_legacy.png new file mode 100644 index 0000000..61dc9f5 Binary files /dev/null and b/launcher/resources/racked_ru/32x32/instances/gear_legacy.png differ diff --git a/launcher/resources/racked_ru/32x32/instances/gold_legacy.png b/launcher/resources/racked_ru/32x32/instances/gold_legacy.png new file mode 100644 index 0000000..99d9179 Binary files /dev/null and b/launcher/resources/racked_ru/32x32/instances/gold_legacy.png differ diff --git a/launcher/resources/racked_ru/32x32/instances/grass_legacy.png b/launcher/resources/racked_ru/32x32/instances/grass_legacy.png new file mode 100644 index 0000000..400f210 Binary files /dev/null and b/launcher/resources/racked_ru/32x32/instances/grass_legacy.png differ diff --git a/launcher/resources/racked_ru/32x32/instances/herobrine_legacy.png b/launcher/resources/racked_ru/32x32/instances/herobrine_legacy.png new file mode 100644 index 0000000..8ed872a Binary files /dev/null and b/launcher/resources/racked_ru/32x32/instances/herobrine_legacy.png differ diff --git a/launcher/resources/racked_ru/32x32/instances/infinity_legacy.png b/launcher/resources/racked_ru/32x32/instances/infinity_legacy.png new file mode 100644 index 0000000..62291c7 Binary files /dev/null and b/launcher/resources/racked_ru/32x32/instances/infinity_legacy.png differ diff --git a/launcher/resources/racked_ru/32x32/instances/iron_legacy.png b/launcher/resources/racked_ru/32x32/instances/iron_legacy.png new file mode 100644 index 0000000..d05d7c0 Binary files /dev/null and b/launcher/resources/racked_ru/32x32/instances/iron_legacy.png differ diff --git a/launcher/resources/racked_ru/32x32/instances/magitech_legacy.png b/launcher/resources/racked_ru/32x32/instances/magitech_legacy.png new file mode 100644 index 0000000..bd630da Binary files /dev/null and b/launcher/resources/racked_ru/32x32/instances/magitech_legacy.png differ diff --git a/launcher/resources/racked_ru/32x32/instances/meat_legacy.png b/launcher/resources/racked_ru/32x32/instances/meat_legacy.png new file mode 100644 index 0000000..422c88e Binary files /dev/null and b/launcher/resources/racked_ru/32x32/instances/meat_legacy.png differ diff --git a/launcher/resources/racked_ru/32x32/instances/netherstar_legacy.png b/launcher/resources/racked_ru/32x32/instances/netherstar_legacy.png new file mode 100644 index 0000000..6f5c6f2 Binary files /dev/null and b/launcher/resources/racked_ru/32x32/instances/netherstar_legacy.png differ diff --git a/launcher/resources/racked_ru/32x32/instances/planks_legacy.png b/launcher/resources/racked_ru/32x32/instances/planks_legacy.png new file mode 100644 index 0000000..0ff6d19 Binary files /dev/null and b/launcher/resources/racked_ru/32x32/instances/planks_legacy.png differ diff --git a/launcher/resources/racked_ru/32x32/instances/skeleton_legacy.png b/launcher/resources/racked_ru/32x32/instances/skeleton_legacy.png new file mode 100644 index 0000000..2327a03 Binary files /dev/null and b/launcher/resources/racked_ru/32x32/instances/skeleton_legacy.png differ diff --git a/launcher/resources/racked_ru/32x32/instances/squarecreeper_legacy.png b/launcher/resources/racked_ru/32x32/instances/squarecreeper_legacy.png new file mode 100644 index 0000000..258c9b3 Binary files /dev/null and b/launcher/resources/racked_ru/32x32/instances/squarecreeper_legacy.png differ diff --git a/launcher/resources/racked_ru/32x32/instances/steve_legacy.png b/launcher/resources/racked_ru/32x32/instances/steve_legacy.png new file mode 100644 index 0000000..3467335 Binary files /dev/null and b/launcher/resources/racked_ru/32x32/instances/steve_legacy.png differ diff --git a/launcher/resources/racked_ru/32x32/instances/stone_legacy.png b/launcher/resources/racked_ru/32x32/instances/stone_legacy.png new file mode 100644 index 0000000..7a4d88c Binary files /dev/null and b/launcher/resources/racked_ru/32x32/instances/stone_legacy.png differ diff --git a/launcher/resources/racked_ru/32x32/instances/tnt_legacy.png b/launcher/resources/racked_ru/32x32/instances/tnt_legacy.png new file mode 100644 index 0000000..7ab8364 Binary files /dev/null and b/launcher/resources/racked_ru/32x32/instances/tnt_legacy.png differ diff --git a/launcher/resources/racked_ru/50x50/instances/enderman_legacy.png b/launcher/resources/racked_ru/50x50/instances/enderman_legacy.png new file mode 100644 index 0000000..36c791e Binary files /dev/null and b/launcher/resources/racked_ru/50x50/instances/enderman_legacy.png differ diff --git a/launcher/resources/racked_ru/racked_ru.qrc b/launcher/resources/racked_ru/racked_ru.qrc new file mode 100644 index 0000000..b44c29a --- /dev/null +++ b/launcher/resources/racked_ru/racked_ru.qrc @@ -0,0 +1,81 @@ + + + theme.json + themeStyle.css + + + 32x32/instances/brick_legacy.png + 32x32/instances/chicken_legacy.png + 32x32/instances/creeper_legacy.png + 32x32/instances/diamond_legacy.png + 32x32/instances/dirt_legacy.png + 32x32/instances/enderpearl_legacy.png + 32x32/instances/ftb_glow.png + 32x32/instances/ftb_logo_legacy.png + 32x32/instances/gear_legacy.png + 32x32/instances/gold_legacy.png + 32x32/instances/grass_legacy.png + 32x32/instances/herobrine_legacy.png + 32x32/instances/infinity_legacy.png + 32x32/instances/iron_legacy.png + 32x32/instances/magitech_legacy.png + 32x32/instances/meat_legacy.png + 32x32/instances/netherstar_legacy.png + 32x32/instances/planks_legacy.png + 32x32/instances/skeleton_legacy.png + 32x32/instances/squarecreeper_legacy.png + 32x32/instances/steve_legacy.png + 32x32/instances/stone_legacy.png + 32x32/instances/tnt_legacy.png + 50x50/instances/enderman_legacy.png + 128x128/instances/chicken_legacy.png + 128x128/instances/creeper_legacy.png + 128x128/instances/enderpearl_legacy.png + 128x128/instances/flame_legacy.png + 128x128/instances/forge.png + 128x128/instances/ftb_glow.png + 128x128/instances/ftb_logo_legacy.png + 128x128/instances/gear_legacy.png + 128x128/instances/herobrine_legacy.png + 128x128/instances/infinity_legacy.png + 128x128/instances/liteloader.png + 128x128/instances/magitech_legacy.png + 128x128/instances/meat_legacy.png + 128x128/instances/netherstar_legacy.png + 128x128/instances/skeleton_legacy.png + 128x128/instances/squarecreeper_legacy.png + 128x128/instances/steve_legacy.png + scalable/instances/bee_legacy.svg + scalable/instances/bee.svg + scalable/instances/brick.svg + scalable/instances/chicken.svg + scalable/instances/creeper.svg + scalable/instances/diamond.svg + scalable/instances/dirt.svg + scalable/instances/enderman.svg + scalable/instances/enderpearl.svg + scalable/instances/fabricmc.svg + scalable/instances/flame.svg + scalable/instances/fox_legacy.svg + scalable/instances/fox.svg + scalable/instances/ftb_logo.svg + scalable/instances/gear.svg + scalable/instances/gold.svg + scalable/instances/grass.svg + scalable/instances/herobrine.svg + scalable/instances/iron.svg + scalable/instances/magitech.svg + scalable/instances/meat.svg + scalable/instances/modrinth.svg + scalable/instances/neoforged.svg + scalable/instances/netherstar.svg + scalable/instances/planks.svg + scalable/instances/prismlauncher.svg + scalable/instances/quiltmc.svg + scalable/instances/skeleton.svg + scalable/instances/squarecreeper.svg + scalable/instances/steve.svg + scalable/instances/stone.svg + scalable/instances/tnt.svg + + diff --git a/launcher/resources/racked_ru/scalable/instances/bee.svg b/launcher/resources/racked_ru/scalable/instances/bee.svg new file mode 100644 index 0000000..110b224 --- /dev/null +++ b/launcher/resources/racked_ru/scalable/instances/bee.svg @@ -0,0 +1,136 @@ + + + +Prism Launcher LogoPrism Launcher Logo19/10/2022Prism LauncherAutiOne, Boba, ely, Fulmine, gon sawa, Pankakes, tobimori, Zekehttps://github.com/PrismLauncher/PrismLauncherCC BY-SA 4.0Prism Launcher diff --git a/launcher/resources/racked_ru/scalable/instances/bee_legacy.svg b/launcher/resources/racked_ru/scalable/instances/bee_legacy.svg new file mode 100644 index 0000000..49f216c --- /dev/null +++ b/launcher/resources/racked_ru/scalable/instances/bee_legacy.svg @@ -0,0 +1,159 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/racked_ru/scalable/instances/brick.svg b/launcher/resources/racked_ru/scalable/instances/brick.svg new file mode 100644 index 0000000..b600eba --- /dev/null +++ b/launcher/resources/racked_ru/scalable/instances/brick.svg @@ -0,0 +1,67 @@ + + + +Prism Launcher LogoPrism Launcher Logo19/10/2022Prism LauncherAutiOne, Boba, ely, Fulmine, gon sawa, Pankakes, tobimori, Zekehttps://github.com/PrismLauncher/PrismLauncherCC BY-SA 4.0Prism Launcher diff --git a/launcher/resources/racked_ru/scalable/instances/chicken.svg b/launcher/resources/racked_ru/scalable/instances/chicken.svg new file mode 100644 index 0000000..0b5bf01 --- /dev/null +++ b/launcher/resources/racked_ru/scalable/instances/chicken.svg @@ -0,0 +1,130 @@ + + + +Prism Launcher LogoPrism Launcher Logo19/10/2022Prism LauncherAutiOne, Boba, ely, Fulmine, gon sawa, Pankakes, tobimori, Zekehttps://github.com/PrismLauncher/PrismLauncherCC BY-SA 4.0Prism Launcher diff --git a/launcher/resources/racked_ru/scalable/instances/creeper.svg b/launcher/resources/racked_ru/scalable/instances/creeper.svg new file mode 100644 index 0000000..4a9fe38 --- /dev/null +++ b/launcher/resources/racked_ru/scalable/instances/creeper.svg @@ -0,0 +1,68 @@ + + + +Prism Launcher LogoPrism Launcher Logo19/10/2022Prism LauncherAutiOne, Boba, ely, Fulmine, gon sawa, Pankakes, tobimori, Zekehttps://github.com/PrismLauncher/PrismLauncherCC BY-SA 4.0Prism Launcher diff --git a/launcher/resources/racked_ru/scalable/instances/diamond.svg b/launcher/resources/racked_ru/scalable/instances/diamond.svg new file mode 100644 index 0000000..1d490b9 --- /dev/null +++ b/launcher/resources/racked_ru/scalable/instances/diamond.svg @@ -0,0 +1,62 @@ + + + +Prism Launcher LogoPrism Launcher Logo19/10/2022Prism LauncherAutiOne, Boba, ely, Fulmine, gon sawa, Pankakes, tobimori, Zekehttps://github.com/PrismLauncher/PrismLauncherCC BY-SA 4.0Prism Launcher diff --git a/launcher/resources/racked_ru/scalable/instances/dirt.svg b/launcher/resources/racked_ru/scalable/instances/dirt.svg new file mode 100644 index 0000000..df28ae9 --- /dev/null +++ b/launcher/resources/racked_ru/scalable/instances/dirt.svg @@ -0,0 +1,52 @@ + + + +Prism Launcher LogoPrism Launcher Logo19/10/2022Prism LauncherAutiOne, Boba, ely, Fulmine, gon sawa, Pankakes, tobimori, Zekehttps://github.com/PrismLauncher/PrismLauncherCC BY-SA 4.0Prism Launcher diff --git a/launcher/resources/racked_ru/scalable/instances/enderman.svg b/launcher/resources/racked_ru/scalable/instances/enderman.svg new file mode 100644 index 0000000..29f25a2 --- /dev/null +++ b/launcher/resources/racked_ru/scalable/instances/enderman.svg @@ -0,0 +1,96 @@ + + + +Prism Launcher LogoPrism Launcher Logo19/10/2022Prism LauncherAutiOne, Boba, ely, Fulmine, gon sawa, Pankakes, tobimori, Zekehttps://github.com/PrismLauncher/PrismLauncherCC BY-SA 4.0Prism Launcher diff --git a/launcher/resources/racked_ru/scalable/instances/enderpearl.svg b/launcher/resources/racked_ru/scalable/instances/enderpearl.svg new file mode 100644 index 0000000..e4c1e10 --- /dev/null +++ b/launcher/resources/racked_ru/scalable/instances/enderpearl.svg @@ -0,0 +1,95 @@ + + + +Prism Launcher LogoPrism Launcher Logo19/10/2022Prism LauncherAutiOne, Boba, ely, Fulmine, gon sawa, Pankakes, tobimori, Zekehttps://github.com/PrismLauncher/PrismLauncherCC BY-SA 4.0Prism Launcher diff --git a/launcher/resources/racked_ru/scalable/instances/fabricmc.svg b/launcher/resources/racked_ru/scalable/instances/fabricmc.svg new file mode 100644 index 0000000..7bfc754 --- /dev/null +++ b/launcher/resources/racked_ru/scalable/instances/fabricmc.svg @@ -0,0 +1,71 @@ + + + + diff --git a/launcher/resources/racked_ru/scalable/instances/flame.svg b/launcher/resources/racked_ru/scalable/instances/flame.svg new file mode 100644 index 0000000..775914b --- /dev/null +++ b/launcher/resources/racked_ru/scalable/instances/flame.svg @@ -0,0 +1,49 @@ + + + +Prism Launcher LogoPrism Launcher Logo19/10/2022Prism LauncherAutiOne, Boba, ely, Fulmine, gon sawa, Pankakes, tobimori, Zekehttps://github.com/PrismLauncher/PrismLauncherCC BY-SA 4.0Prism Launcher diff --git a/launcher/resources/racked_ru/scalable/instances/fox.svg b/launcher/resources/racked_ru/scalable/instances/fox.svg new file mode 100644 index 0000000..95ca6ef --- /dev/null +++ b/launcher/resources/racked_ru/scalable/instances/fox.svg @@ -0,0 +1,151 @@ + + + +Prism Launcher LogoPrism Launcher Logo19/10/2022Prism LauncherAutiOne, Boba, ely, Fulmine, gon sawa, Pankakes, tobimori, Zekehttps://github.com/PrismLauncher/PrismLauncherCC BY-SA 4.0Prism Launcher diff --git a/launcher/resources/racked_ru/scalable/instances/fox_legacy.svg b/launcher/resources/racked_ru/scalable/instances/fox_legacy.svg new file mode 100644 index 0000000..fcf16b2 --- /dev/null +++ b/launcher/resources/racked_ru/scalable/instances/fox_legacy.svg @@ -0,0 +1,290 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/racked_ru/scalable/instances/ftb_logo.svg b/launcher/resources/racked_ru/scalable/instances/ftb_logo.svg new file mode 100644 index 0000000..85e8295 --- /dev/null +++ b/launcher/resources/racked_ru/scalable/instances/ftb_logo.svg @@ -0,0 +1,82 @@ + + + +Prism Launcher LogoPrism Launcher Logo19/10/2022Prism LauncherAutiOne, Boba, ely, Fulmine, gon sawa, Pankakes, tobimori, Zekehttps://github.com/PrismLauncher/PrismLauncherCC BY-SA 4.0Prism Launcher diff --git a/launcher/resources/racked_ru/scalable/instances/gear.svg b/launcher/resources/racked_ru/scalable/instances/gear.svg new file mode 100644 index 0000000..b2923d6 --- /dev/null +++ b/launcher/resources/racked_ru/scalable/instances/gear.svg @@ -0,0 +1,68 @@ + + + +Prism Launcher LogoPrism Launcher Logo19/10/2022Prism LauncherAutiOne, Boba, ely, Fulmine, gon sawa, Pankakes, tobimori, Zekehttps://github.com/PrismLauncher/PrismLauncherCC BY-SA 4.0Prism Launcher diff --git a/launcher/resources/racked_ru/scalable/instances/gold.svg b/launcher/resources/racked_ru/scalable/instances/gold.svg new file mode 100644 index 0000000..f1513d7 --- /dev/null +++ b/launcher/resources/racked_ru/scalable/instances/gold.svg @@ -0,0 +1,63 @@ + + + +Prism Launcher LogoPrism Launcher Logo19/10/2022Prism LauncherAutiOne, Boba, ely, Fulmine, gon sawa, Pankakes, tobimori, Zekehttps://github.com/PrismLauncher/PrismLauncherCC BY-SA 4.0Prism Launcher diff --git a/launcher/resources/racked_ru/scalable/instances/grass.svg b/launcher/resources/racked_ru/scalable/instances/grass.svg new file mode 100644 index 0000000..cd29fd8 --- /dev/null +++ b/launcher/resources/racked_ru/scalable/instances/grass.svg @@ -0,0 +1,84 @@ + + + +Prism Launcher LogoPrism Launcher Logo19/10/2022Prism LauncherAutiOne, Boba, ely, Fulmine, gon sawa, Pankakes, tobimori, Zekehttps://github.com/PrismLauncher/PrismLauncherCC BY-SA 4.0Prism Launcher diff --git a/launcher/resources/racked_ru/scalable/instances/herobrine.svg b/launcher/resources/racked_ru/scalable/instances/herobrine.svg new file mode 100644 index 0000000..24f4d2c --- /dev/null +++ b/launcher/resources/racked_ru/scalable/instances/herobrine.svg @@ -0,0 +1,111 @@ + + + +Prism Launcher LogoPrism Launcher Logo19/10/2022Prism LauncherAutiOne, Boba, ely, Fulmine, gon sawa, Pankakes, tobimori, Zekehttps://github.com/PrismLauncher/PrismLauncherCC BY-SA 4.0Prism Launcher diff --git a/launcher/resources/racked_ru/scalable/instances/iron.svg b/launcher/resources/racked_ru/scalable/instances/iron.svg new file mode 100644 index 0000000..6a6faf7 --- /dev/null +++ b/launcher/resources/racked_ru/scalable/instances/iron.svg @@ -0,0 +1,178 @@ + + + +Prism Launcher LogoPrism Launcher Logo19/10/2022Prism LauncherAutiOne, Boba, ely, Fulmine, gon sawa, Pankakes, tobimori, Zekehttps://github.com/PrismLauncher/PrismLauncherCC BY-SA 4.0Prism Launcher diff --git a/launcher/resources/racked_ru/scalable/instances/magitech.svg b/launcher/resources/racked_ru/scalable/instances/magitech.svg new file mode 100644 index 0000000..57ef6df --- /dev/null +++ b/launcher/resources/racked_ru/scalable/instances/magitech.svg @@ -0,0 +1,85 @@ + + + +Prism Launcher LogoPrism Launcher Logo19/10/2022Prism LauncherAutiOne, Boba, ely, Fulmine, gon sawa, Pankakes, tobimori, Zekehttps://github.com/PrismLauncher/PrismLauncherCC BY-SA 4.0Prism Launcher diff --git a/launcher/resources/racked_ru/scalable/instances/meat.svg b/launcher/resources/racked_ru/scalable/instances/meat.svg new file mode 100644 index 0000000..36f0551 --- /dev/null +++ b/launcher/resources/racked_ru/scalable/instances/meat.svg @@ -0,0 +1,121 @@ + + + +Prism Launcher LogoPrism Launcher Logo19/10/2022Prism LauncherAutiOne, Boba, ely, Fulmine, gon sawa, Pankakes, tobimori, Zekehttps://github.com/PrismLauncher/PrismLauncherCC BY-SA 4.0Prism Launcher diff --git a/launcher/resources/racked_ru/scalable/instances/modrinth.svg b/launcher/resources/racked_ru/scalable/instances/modrinth.svg new file mode 100644 index 0000000..029dc99 --- /dev/null +++ b/launcher/resources/racked_ru/scalable/instances/modrinth.svg @@ -0,0 +1,92 @@ + + + +Prism Launcher LogoPrism Launcher Logo19/10/2022Prism LauncherAutiOne, Boba, ely, Fulmine, gon sawa, Pankakes, tobimori, Zekehttps://github.com/PrismLauncher/PrismLauncherCC BY-SA 4.0Prism Launcher diff --git a/launcher/resources/racked_ru/scalable/instances/neoforged.svg b/launcher/resources/racked_ru/scalable/instances/neoforged.svg new file mode 100644 index 0000000..706d53a --- /dev/null +++ b/launcher/resources/racked_ru/scalable/instances/neoforged.svg @@ -0,0 +1,3 @@ + + +Sefa Eyeoglu <contact@scrumplex.net> diff --git a/launcher/resources/racked_ru/scalable/instances/netherstar.svg b/launcher/resources/racked_ru/scalable/instances/netherstar.svg new file mode 100644 index 0000000..a5d9606 --- /dev/null +++ b/launcher/resources/racked_ru/scalable/instances/netherstar.svg @@ -0,0 +1,81 @@ + + + +Prism Launcher LogoPrism Launcher Logo19/10/2022Prism LauncherAutiOne, Boba, ely, Fulmine, gon sawa, Pankakes, tobimori, Zekehttps://github.com/PrismLauncher/PrismLauncherCC BY-SA 4.0Prism Launcher diff --git a/launcher/resources/racked_ru/scalable/instances/planks.svg b/launcher/resources/racked_ru/scalable/instances/planks.svg new file mode 100644 index 0000000..8febfa6 --- /dev/null +++ b/launcher/resources/racked_ru/scalable/instances/planks.svg @@ -0,0 +1,93 @@ + + + +Prism Launcher LogoPrism Launcher Logo19/10/2022Prism LauncherAutiOne, Boba, ely, Fulmine, gon sawa, Pankakes, tobimori, Zekehttps://github.com/PrismLauncher/PrismLauncherCC BY-SA 4.0Prism Launcher diff --git a/launcher/resources/racked_ru/scalable/instances/prismlauncher.svg b/launcher/resources/racked_ru/scalable/instances/prismlauncher.svg new file mode 100644 index 0000000..93493aa --- /dev/null +++ b/launcher/resources/racked_ru/scalable/instances/prismlauncher.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/racked_ru/scalable/instances/quiltmc.svg b/launcher/resources/racked_ru/scalable/instances/quiltmc.svg new file mode 100644 index 0000000..a7aaca5 --- /dev/null +++ b/launcher/resources/racked_ru/scalable/instances/quiltmc.svg @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/racked_ru/scalable/instances/skeleton.svg b/launcher/resources/racked_ru/scalable/instances/skeleton.svg new file mode 100644 index 0000000..ca9e8dd --- /dev/null +++ b/launcher/resources/racked_ru/scalable/instances/skeleton.svg @@ -0,0 +1,134 @@ + + + +Prism Launcher LogoPrism Launcher Logo19/10/2022Prism LauncherAutiOne, Boba, ely, Fulmine, gon sawa, Pankakes, tobimori, Zekehttps://github.com/PrismLauncher/PrismLauncherCC BY-SA 4.0Prism Launcher diff --git a/launcher/resources/racked_ru/scalable/instances/squarecreeper.svg b/launcher/resources/racked_ru/scalable/instances/squarecreeper.svg new file mode 100644 index 0000000..ddb9aec --- /dev/null +++ b/launcher/resources/racked_ru/scalable/instances/squarecreeper.svg @@ -0,0 +1,81 @@ + + + +Prism Launcher LogoPrism Launcher Logo19/10/2022Prism LauncherAutiOne, Boba, ely, Fulmine, gon sawa, Pankakes, tobimori, Zekehttps://github.com/PrismLauncher/PrismLauncherCC BY-SA 4.0Prism Launcher diff --git a/launcher/resources/racked_ru/scalable/instances/steve.svg b/launcher/resources/racked_ru/scalable/instances/steve.svg new file mode 100644 index 0000000..9b6d259 --- /dev/null +++ b/launcher/resources/racked_ru/scalable/instances/steve.svg @@ -0,0 +1,154 @@ + + + +Prism Launcher LogoPrism Launcher Logo19/10/2022Prism LauncherAutiOne, Boba, ely, Fulmine, gon sawa, Pankakes, tobimori, Zekehttps://github.com/PrismLauncher/PrismLauncherCC BY-SA 4.0Prism Launcher diff --git a/launcher/resources/racked_ru/scalable/instances/stone.svg b/launcher/resources/racked_ru/scalable/instances/stone.svg new file mode 100644 index 0000000..6df534d --- /dev/null +++ b/launcher/resources/racked_ru/scalable/instances/stone.svg @@ -0,0 +1,55 @@ + + + +Prism Launcher LogoPrism Launcher Logo19/10/2022Prism LauncherAutiOne, Boba, ely, Fulmine, gon sawa, Pankakes, tobimori, Zekehttps://github.com/PrismLauncher/PrismLauncherCC BY-SA 4.0Prism Launcher diff --git a/launcher/resources/racked_ru/scalable/instances/tnt.svg b/launcher/resources/racked_ru/scalable/instances/tnt.svg new file mode 100644 index 0000000..e876eba --- /dev/null +++ b/launcher/resources/racked_ru/scalable/instances/tnt.svg @@ -0,0 +1,126 @@ + + + +Prism Launcher LogoPrism Launcher Logo19/10/2022Prism LauncherAutiOne, Boba, ely, Fulmine, gon sawa, Pankakes, tobimori, Zekehttps://github.com/PrismLauncher/PrismLauncherCC BY-SA 4.0Prism Launcher diff --git a/launcher/resources/racked_ru/theme.json b/launcher/resources/racked_ru/theme.json new file mode 100644 index 0000000..3ea141a --- /dev/null +++ b/launcher/resources/racked_ru/theme.json @@ -0,0 +1,21 @@ +{ + "colors": { + "AlternateBase": "#000000", + "Base": "#000000", + "BrightText": "#FFFFFF", + "Button": "#000000", + "ButtonText": "#ffffff", + "Highlight": "#4C4C4C", + "HighlightedText": "#CCCCCC", + "Link": "#FFFFFF", + "Text": "#ffffff", + "ToolTipBase": "#ffffff", + "ToolTipText": "#ffffff", + "Window": "#000000", + "WindowText": "#ffffff", + "fadeAmount": 0.5, + "fadeColor": "#000000" + }, + "name": "racked.ru", + "widgets": "Fusion" +} \ No newline at end of file diff --git a/launcher/resources/racked_ru/themeStyle.css b/launcher/resources/racked_ru/themeStyle.css new file mode 100644 index 0000000..9fce8ae --- /dev/null +++ b/launcher/resources/racked_ru/themeStyle.css @@ -0,0 +1 @@ +QToolTip { color: #ffffff; background-color: #2a82da; border: 1px solid white; } \ No newline at end of file diff --git a/launcher/resources/shaders/fshader.glsl b/launcher/resources/shaders/fshader.glsl new file mode 100644 index 0000000..d6a93db --- /dev/null +++ b/launcher/resources/shaders/fshader.glsl @@ -0,0 +1,20 @@ +// Copyright (C) 2024 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause +// https://code.qt.io/cgit/qt/qtbase.git/tree/examples/opengl/cube/fshader.glsl +#ifdef GL_ES +// Set default precision to medium +precision mediump int; +precision mediump float; +#endif + +uniform sampler2D texture; + +varying vec2 v_texcoord; + +void main() +{ + // Set fragment color from texture + vec4 texColor = texture2D(texture, v_texcoord); + if (texColor.a < 0.1) discard; // Optional: Discard fully transparent pixels + gl_FragColor = texColor; +} diff --git a/launcher/resources/shaders/shaders.qrc b/launcher/resources/shaders/shaders.qrc new file mode 100644 index 0000000..005bdbc --- /dev/null +++ b/launcher/resources/shaders/shaders.qrc @@ -0,0 +1,7 @@ + + + vshader_skin_model.glsl + vshader_skin_background.glsl + fshader.glsl + + diff --git a/launcher/resources/shaders/vshader_skin_background.glsl b/launcher/resources/shaders/vshader_skin_background.glsl new file mode 100644 index 0000000..9072af6 --- /dev/null +++ b/launcher/resources/shaders/vshader_skin_background.glsl @@ -0,0 +1,11 @@ + +attribute vec4 a_position; +attribute vec2 a_texcoord; + +varying vec2 v_texcoord; + +void main() +{ + gl_Position = a_position; + v_texcoord = a_texcoord; +} diff --git a/launcher/resources/shaders/vshader_skin_model.glsl b/launcher/resources/shaders/vshader_skin_model.glsl new file mode 100644 index 0000000..ed8e757 --- /dev/null +++ b/launcher/resources/shaders/vshader_skin_model.glsl @@ -0,0 +1,37 @@ +// Copyright (C) 2024 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause +// https://code.qt.io/cgit/qt/qtbase.git/tree/examples/opengl/cube/vshader.glsl + +// Dylan Schooner - 2025 +// Modification: Implemented final Z-NDC re-inversion to compensate +// for rigid OpenGL 2.0 context forcing glClearDepth(1.0). +// This flips the high-precision Reverse Z output to the standard [0, W] range. + +#ifdef GL_ES +// Set default precision to medium +precision mediump int; +precision mediump float; +#endif + +uniform mat4 mvp_matrix; +uniform mat4 model_matrix; + +attribute vec4 a_position; +attribute vec2 a_texcoord; + +varying vec2 v_texcoord; + +void main() +{ + // Calculate vertex position in screen space + gl_Position = mvp_matrix * model_matrix * a_position; + + // Invert the z component of our Reverse Z matrix back to standard NDC + float near_z = gl_Position.z; + float w_c = gl_Position.w; + gl_Position.z = w_c - near_z; + + // Pass texture coordinate to fragment shader + // Value will be automatically interpolated to fragments inside polygon faces + v_texcoord = a_texcoord; +} diff --git a/launcher/resources/sources/burfcat_hat.png b/launcher/resources/sources/burfcat_hat.png new file mode 100644 index 0000000..6abf178 Binary files /dev/null and b/launcher/resources/sources/burfcat_hat.png differ diff --git a/launcher/resources/sources/cattiversary.xcf b/launcher/resources/sources/cattiversary.xcf new file mode 100644 index 0000000..0026cd3 Binary files /dev/null and b/launcher/resources/sources/cattiversary.xcf differ diff --git a/launcher/resources/sources/clucker.svg b/launcher/resources/sources/clucker.svg new file mode 100644 index 0000000..0c1727e --- /dev/null +++ b/launcher/resources/sources/clucker.svg @@ -0,0 +1,404 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/sources/creeper.svg b/launcher/resources/sources/creeper.svg new file mode 100644 index 0000000..2a2e39b --- /dev/null +++ b/launcher/resources/sources/creeper.svg @@ -0,0 +1,775 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/sources/enderpearl.svg b/launcher/resources/sources/enderpearl.svg new file mode 100644 index 0000000..ac9378f --- /dev/null +++ b/launcher/resources/sources/enderpearl.svg @@ -0,0 +1,271 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/sources/flame.svg b/launcher/resources/sources/flame.svg new file mode 100644 index 0000000..8a6da2f --- /dev/null +++ b/launcher/resources/sources/flame.svg @@ -0,0 +1,51 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + diff --git a/launcher/resources/sources/ftb-glow.svg b/launcher/resources/sources/ftb-glow.svg new file mode 100644 index 0000000..be78c78 --- /dev/null +++ b/launcher/resources/sources/ftb-glow.svg @@ -0,0 +1,606 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/sources/ftb-logo.svg b/launcher/resources/sources/ftb-logo.svg new file mode 100644 index 0000000..8cf7356 --- /dev/null +++ b/launcher/resources/sources/ftb-logo.svg @@ -0,0 +1,257 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/sources/gear.svg b/launcher/resources/sources/gear.svg new file mode 100644 index 0000000..c0169ae --- /dev/null +++ b/launcher/resources/sources/gear.svg @@ -0,0 +1,434 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/launcher/resources/sources/herobrine.svg b/launcher/resources/sources/herobrine.svg new file mode 100644 index 0000000..7392ba3 --- /dev/null +++ b/launcher/resources/sources/herobrine.svg @@ -0,0 +1,583 @@ + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/sources/magitech.svg b/launcher/resources/sources/magitech.svg new file mode 100644 index 0000000..c6dd6bc --- /dev/null +++ b/launcher/resources/sources/magitech.svg @@ -0,0 +1,886 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/sources/meat.svg b/launcher/resources/sources/meat.svg new file mode 100644 index 0000000..69a2007 --- /dev/null +++ b/launcher/resources/sources/meat.svg @@ -0,0 +1,371 @@ + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/sources/netherstar.svg b/launcher/resources/sources/netherstar.svg new file mode 100644 index 0000000..4046e4e --- /dev/null +++ b/launcher/resources/sources/netherstar.svg @@ -0,0 +1,342 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/sources/pskeleton.svg b/launcher/resources/sources/pskeleton.svg new file mode 100644 index 0000000..c2783df --- /dev/null +++ b/launcher/resources/sources/pskeleton.svg @@ -0,0 +1,581 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/sources/skeleton.svg b/launcher/resources/sources/skeleton.svg new file mode 100644 index 0000000..5d55f27 --- /dev/null +++ b/launcher/resources/sources/skeleton.svg @@ -0,0 +1,610 @@ + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/sources/squarecreeper.svg b/launcher/resources/sources/squarecreeper.svg new file mode 100644 index 0000000..a1b0f4d --- /dev/null +++ b/launcher/resources/sources/squarecreeper.svg @@ -0,0 +1,828 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/resources/sources/steve.svg b/launcher/resources/sources/steve.svg new file mode 100644 index 0000000..2233272 --- /dev/null +++ b/launcher/resources/sources/steve.svg @@ -0,0 +1,534 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/screenshots/ImgurAlbumCreation.cpp b/launcher/screenshots/ImgurAlbumCreation.cpp new file mode 100644 index 0000000..411e4f7 --- /dev/null +++ b/launcher/screenshots/ImgurAlbumCreation.cpp @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ImgurAlbumCreation.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "BuildConfig.h" +#include "net/RawHeaderProxy.h" + +Net::NetRequest::Ptr ImgurAlbumCreation::make(std::shared_ptr output, QList screenshots) +{ + auto up = makeShared(); + up->m_url = BuildConfig.IMGUR_BASE_URL + "album"; + up->m_sink.reset(new Sink(output)); + up->m_screenshots = screenshots; + up->addHeaderProxy(std::make_unique( + QList{ { "Content-Type", "application/x-www-form-urlencoded" }, + { "Authorization", QString("Client-ID %1").arg(BuildConfig.IMGUR_CLIENT_ID).toUtf8() }, + { "Accept", "application/json" } })); + return up; +} + +QNetworkReply* ImgurAlbumCreation::getReply(QNetworkRequest& request) +{ + QStringList hashes; + for (auto shot : m_screenshots) { + hashes.append(shot->m_imgurDeleteHash); + } + const QByteArray data = "deletehashes=" + hashes.join(',').toUtf8() + "&title=Minecraft%20Screenshots&privacy=hidden"; + return m_network->post(request, data); +} + +auto ImgurAlbumCreation::Sink::init(QNetworkRequest& request) -> Task::State +{ + m_output.clear(); + return Task::State::Running; +} + +auto ImgurAlbumCreation::Sink::write(QByteArray& data) -> Task::State +{ + m_output.append(data); + return Task::State::Running; +} + +auto ImgurAlbumCreation::Sink::abort() -> Task::State +{ + m_output.clear(); + m_fail_reason = "Aborted"; + return Task::State::Failed; +} + +auto ImgurAlbumCreation::Sink::finalize(QNetworkReply&) -> Task::State +{ + QJsonParseError jsonError; + QJsonDocument doc = QJsonDocument::fromJson(m_output, &jsonError); + if (jsonError.error != QJsonParseError::NoError) { + qDebug() << jsonError.errorString(); + m_fail_reason = "Invalid json reply"; + return Task::State::Failed; + } + auto object = doc.object(); + if (!object.value("success").toBool()) { + qDebug() << doc.toJson(); + m_fail_reason = "Failed to create album"; + return Task::State::Failed; + } + m_result->deleteHash = object.value("data").toObject().value("deletehash").toString(); + m_result->id = object.value("data").toObject().value("id").toString(); + return Task::State::Succeeded; +} diff --git a/launcher/screenshots/ImgurAlbumCreation.h b/launcher/screenshots/ImgurAlbumCreation.h new file mode 100644 index 0000000..f10409b --- /dev/null +++ b/launcher/screenshots/ImgurAlbumCreation.h @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "Screenshot.h" +#include "net/NetRequest.h" + +class ImgurAlbumCreation : public Net::NetRequest { + public: + virtual ~ImgurAlbumCreation() = default; + + struct Result { + QString deleteHash; + QString id; + }; + + class Sink : public Net::Sink { + public: + Sink(std::shared_ptr res) : m_result(res) {}; + virtual ~Sink() = default; + + public: + auto init(QNetworkRequest& request) -> Task::State override; + auto write(QByteArray& data) -> Task::State override; + auto abort() -> Task::State override; + auto finalize(QNetworkReply& reply) -> Task::State override; + auto hasLocalData() -> bool override { return false; } + + private: + std::shared_ptr m_result; + QByteArray m_output; + }; + + static NetRequest::Ptr make(std::shared_ptr output, QList screenshots); + QNetworkReply* getReply(QNetworkRequest& request) override; + + private: + QList m_screenshots; +}; diff --git a/launcher/screenshots/ImgurUpload.cpp b/launcher/screenshots/ImgurUpload.cpp new file mode 100644 index 0000000..e6ba372 --- /dev/null +++ b/launcher/screenshots/ImgurUpload.cpp @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ImgurUpload.h" +#include "BuildConfig.h" +#include "net/RawHeaderProxy.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +QNetworkReply* ImgurUpload::getReply(QNetworkRequest& request) +{ + auto file = new QFile(m_fileInfo.absoluteFilePath(), this); + + if (!file->open(QFile::ReadOnly)) { + emitFailed(tr("Could not open file %1 for reading: %2").arg(m_fileInfo.absoluteFilePath()).arg(file->errorString())); + return nullptr; + } + + QHttpMultiPart* multipart = new QHttpMultiPart(QHttpMultiPart::FormDataType, this); + file->setParent(multipart); + QHttpPart filePart; + filePart.setBodyDevice(file); + filePart.setHeader(QNetworkRequest::ContentTypeHeader, "image/png"); + filePart.setHeader(QNetworkRequest::ContentDispositionHeader, "form-data; name=\"image\"; filename=\"" + file->fileName() + "\""); + multipart->append(filePart); + QHttpPart typePart; + typePart.setHeader(QNetworkRequest::ContentDispositionHeader, "form-data; name=\"type\""); + typePart.setBody("file"); + multipart->append(typePart); + QHttpPart namePart; + namePart.setHeader(QNetworkRequest::ContentDispositionHeader, "form-data; name=\"title\""); + namePart.setBody(m_fileInfo.baseName().toUtf8()); + multipart->append(namePart); + + return m_network->post(request, multipart); +} + +auto ImgurUpload::Sink::init(QNetworkRequest& request) -> Task::State +{ + m_output.clear(); + return Task::State::Running; +} + +auto ImgurUpload::Sink::write(QByteArray& data) -> Task::State +{ + m_output.append(data); + return Task::State::Running; +} + +auto ImgurUpload::Sink::abort() -> Task::State +{ + m_output.clear(); + m_fail_reason = "Aborted"; + return Task::State::Failed; +} + +auto ImgurUpload::Sink::finalize(QNetworkReply&) -> Task::State +{ + QJsonParseError jsonError; + QJsonDocument doc = QJsonDocument::fromJson(m_output, &jsonError); + if (jsonError.error != QJsonParseError::NoError) { + qDebug() << "imgur server did not reply with JSON" << jsonError.errorString(); + m_fail_reason = "Invalid json reply"; + return Task::State::Failed; + } + auto object = doc.object(); + if (!object.value("success").toBool()) { + qDebug() << "Screenshot upload not successful:" << doc.toJson(); + m_fail_reason = "Screenshot was not uploaded successfully"; + return Task::State::Failed; + } + m_shot->m_imgurId = object.value("data").toObject().value("id").toString(); + m_shot->m_url = object.value("data").toObject().value("link").toString(); + m_shot->m_imgurDeleteHash = object.value("data").toObject().value("deletehash").toString(); + return Task::State::Succeeded; +} + +Net::NetRequest::Ptr ImgurUpload::make(ScreenShot::Ptr m_shot) +{ + auto up = makeShared(m_shot->m_file); + up->m_url = std::move(BuildConfig.IMGUR_BASE_URL + "image"); + up->m_sink.reset(new Sink(m_shot)); + up->addHeaderProxy(std::make_unique(QList{ + { "Authorization", QString("Client-ID %1").arg(BuildConfig.IMGUR_CLIENT_ID).toUtf8() }, { "Accept", "application/json" } })); + return up; +} diff --git a/launcher/screenshots/ImgurUpload.h b/launcher/screenshots/ImgurUpload.h new file mode 100644 index 0000000..f4f7185 --- /dev/null +++ b/launcher/screenshots/ImgurUpload.h @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include "Screenshot.h" +#include "net/NetRequest.h" + +class ImgurUpload : public Net::NetRequest { + public: + class Sink : public Net::Sink { + public: + Sink(ScreenShot::Ptr shot) : m_shot(shot) {}; + virtual ~Sink() = default; + + public: + auto init(QNetworkRequest& request) -> Task::State override; + auto write(QByteArray& data) -> Task::State override; + auto abort() -> Task::State override; + auto finalize(QNetworkReply& reply) -> Task::State override; + auto hasLocalData() -> bool override { return false; } + + private: + ScreenShot::Ptr m_shot; + QByteArray m_output; + }; + ImgurUpload(QFileInfo info) : m_fileInfo(info) {} + virtual ~ImgurUpload() = default; + + static NetRequest::Ptr make(ScreenShot::Ptr m_shot); + + private: + virtual QNetworkReply* getReply(QNetworkRequest&) override; + const QFileInfo m_fileInfo; +}; diff --git a/launcher/screenshots/Screenshot.h b/launcher/screenshots/Screenshot.h new file mode 100644 index 0000000..767f390 --- /dev/null +++ b/launcher/screenshots/Screenshot.h @@ -0,0 +1,16 @@ +#pragma once + +#include +#include +#include +#include + +struct ScreenShot { + using Ptr = std::shared_ptr; + + ScreenShot(QFileInfo file) { m_file = file; } + QFileInfo m_file; + QString m_url; + QString m_imgurId; + QString m_imgurDeleteHash; +}; diff --git a/launcher/settings/INIFile.cpp b/launcher/settings/INIFile.cpp new file mode 100644 index 0000000..75e8889 --- /dev/null +++ b/launcher/settings/INIFile.cpp @@ -0,0 +1,248 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2023 flowln + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "settings/INIFile.h" +#include + +#include +#include +#include +#include +#include + +#include +#include "Json.h" + +INIFile::INIFile() {} + +bool INIFile::saveFile(QString fileName) +{ + if (!contains("ConfigVersion")) + insert("ConfigVersion", "1.3"); + QSettings _settings_obj{ fileName, QSettings::Format::IniFormat }; + _settings_obj.setFallbacksEnabled(false); + _settings_obj.clear(); + + for (Iterator iter = begin(); iter != end(); iter++) + _settings_obj.setValue(iter.key(), iter.value()); + + _settings_obj.sync(); + + if (auto status = _settings_obj.status(); status != QSettings::Status::NoError) { + // Shouldn't be possible! + Q_ASSERT(status != QSettings::Status::FormatError); + + if (status == QSettings::Status::AccessError) + qCritical() << "An access error occurred (e.g. trying to write to a read-only file)."; + + return false; + } + + return true; +} + +QString unescape(QString orig) +{ + QString out; + QChar prev = QChar::Null; + for (auto c : orig) { + if (prev == '\\') { + if (c == 'n') + out += '\n'; + else if (c == 't') + out += '\t'; + else if (c == '#') + out += '#'; + else + out += c; + prev = QChar::Null; + } else { + if (c == '\\') { + prev = c; + continue; + } + out += c; + prev = QChar::Null; + } + } + return out; +} + +QString unquote(QString str) +{ + if ((str.contains(QChar(';')) || str.contains(QChar('=')) || str.contains(QChar(','))) && str.endsWith("\"") && str.startsWith("\"")) { +#if QT_VERSION < QT_VERSION_CHECK(6, 5, 0) + str = str.remove(0, 1); + str = str.remove(str.size() - 1, 1); +#else + str = str.removeFirst().removeLast(); +#endif + } + return str; +} + +bool parseOldFileFormat(QIODevice& device, QSettings::SettingsMap& map) +{ + QTextStream in(device.readAll()); +#if QT_VERSION <= QT_VERSION_CHECK(6, 0, 0) + in.setCodec("UTF-8"); +#endif + + QStringList lines = in.readAll().split('\n'); + for (int i = 0; i < lines.count(); i++) { + QString& lineRaw = lines[i]; + // Ignore comments. + int commentIndex = 0; + QString line = lineRaw; + // Search for comments until no more escaped # are available + while ((commentIndex = line.indexOf('#', commentIndex + 1)) != -1) { + if (commentIndex > 0 && line.at(commentIndex - 1) == '\\') { + continue; + } + line = line.left(lineRaw.indexOf('#')).trimmed(); + } + + int eqPos = line.indexOf('='); + if (eqPos == -1) + continue; + QString key = line.left(eqPos).trimmed(); + QString valueStr = line.right(line.length() - eqPos - 1).trimmed(); + + valueStr = unquote(unescape(valueStr)); + + QVariant value(valueStr); + map.insert(key, value); + } + + return true; +} + +QVariant migrateQByteArrayToBase64(QString key, QVariant value) +{ + static const QStringList otherByteArrays = { "MainWindowState", "MainWindowGeometry", "ConsoleWindowState", + "ConsoleWindowGeometry", "PagedGeometry", "NewInstanceGeometry", + "ModDownloadGeometry", "RPDownloadGeometry", "TPDownloadGeometry", + "ShaderDownloadGeometry" }; + if (key.startsWith("WideBarVisibility_") || (key.startsWith("UI/") && key.endsWith("_Page/Columns"))) { + return QString::fromUtf8(value.toByteArray().toBase64()); + } + if (otherByteArrays.contains(key)) { + return QString::fromUtf8(value.toByteArray()); + } + if (key == "linkedInstances") { + return Json::fromStringList(value.toStringList()); + } + if (key == "Env") { + return Json::fromMap(value.toMap()); + } + return value; +} + +bool INIFile::loadFile(QString fileName) +{ + QSettings _settings_obj{ fileName, QSettings::Format::IniFormat }; + _settings_obj.setFallbacksEnabled(false); + + if (auto status = _settings_obj.status(); status != QSettings::Status::NoError) { + if (status == QSettings::Status::AccessError) + qCritical() << "An access error occurred (e.g. trying to write to a read-only file)."; + if (status == QSettings::Status::FormatError) + qCritical() << "A format error occurred (e.g. loading a malformed INI file)."; + return false; + } + if (!_settings_obj.value("ConfigVersion").isValid()) { + QFile file(fileName); + if (!file.open(QIODevice::ReadOnly)) + return false; + QSettings::SettingsMap map; + parseOldFileFormat(file, map); + file.close(); + for (auto&& key : map.keys()) { + auto value = migrateQByteArrayToBase64(key, map.value(key)); + insert(key, value); + } + insert("ConfigVersion", "1.3"); + } else if (_settings_obj.value("ConfigVersion").toString() == "1.1") { + for (auto&& key : _settings_obj.allKeys()) { + auto value = migrateQByteArrayToBase64(key, _settings_obj.value(key)); + if (auto valueStr = value.toString(); + (valueStr.contains(QChar(';')) || valueStr.contains(QChar('=')) || valueStr.contains(QChar(','))) && + valueStr.endsWith("\"") && valueStr.startsWith("\"")) { + insert(key, unquote(valueStr)); + } else { + insert(key, value); + } + } + insert("ConfigVersion", "1.3"); + } else if (_settings_obj.value("ConfigVersion").toString() == "1.2") { + for (auto&& key : _settings_obj.allKeys()) { + auto value = migrateQByteArrayToBase64(key, _settings_obj.value(key)); + insert(key, value); + } + insert("ConfigVersion", "1.3"); + } else { + for (auto&& key : _settings_obj.allKeys()) { + insert(key, _settings_obj.value(key)); + } + } + return true; +} + +bool INIFile::loadFile(QByteArray data) +{ + QTemporaryFile file; + if (!file.open()) + return false; + file.write(data); + file.flush(); + file.close(); + auto loaded = loadFile(file.fileName()); + file.remove(); + return loaded; +} + +QVariant INIFile::get(QString key, QVariant def) const +{ + if (!this->contains(key)) + return def; + else + return this->operator[](key); +} + +void INIFile::set(QString key, QVariant val) +{ + this->operator[](key) = val; +} diff --git a/launcher/settings/INIFile.h b/launcher/settings/INIFile.h new file mode 100644 index 0000000..c15578f --- /dev/null +++ b/launcher/settings/INIFile.h @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2023 flowln + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +#include +#include + +// Sectionless INI parser (for instance config files) +class INIFile : public QMap { + public: + explicit INIFile(); + + bool loadFile(QString fileName); + bool loadFile(QByteArray data); + bool saveFile(QString fileName); + + QVariant get(QString key, QVariant def) const; + void set(QString key, QVariant val); +}; diff --git a/launcher/settings/INISettingsObject.cpp b/launcher/settings/INISettingsObject.cpp new file mode 100644 index 0000000..519b819 --- /dev/null +++ b/launcher/settings/INISettingsObject.cpp @@ -0,0 +1,118 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "INISettingsObject.h" +#include "Setting.h" + +#include +#include + +INISettingsObject::INISettingsObject(QStringList paths, QObject* parent) : SettingsObject(parent) +{ + auto first_path = paths.constFirst(); + for (auto path : paths) { + if (!QFile::exists(path)) + continue; + + if (path != first_path && QFile::exists(path)) { + // Copy the fallback to the preferred path. + QFile::copy(path, first_path); + qDebug() << "Copied settings from" << path << "to" << first_path; + break; + } + } + + m_filePath = first_path; + m_ini.loadFile(first_path); +} + +INISettingsObject::INISettingsObject(QString path, QObject* parent) : SettingsObject(parent) +{ + m_filePath = path; + m_ini.loadFile(path); +} + +void INISettingsObject::setFilePath(const QString& filePath) +{ + m_filePath = filePath; +} + +bool INISettingsObject::reload() +{ + return m_ini.loadFile(m_filePath) && SettingsObject::reload(); +} + +void INISettingsObject::suspendSave() +{ + m_suspendSave = true; +} + +void INISettingsObject::resumeSave() +{ + m_suspendSave = false; + if (m_doSave) { + m_ini.saveFile(m_filePath); + } +} + +void INISettingsObject::changeSetting(const Setting& setting, QVariant value) +{ + if (contains(setting.id())) { + // valid value -> set the main config, remove all the sysnonyms + if (value.isValid()) { + auto list = setting.configKeys(); + m_ini.set(list.takeFirst(), value); + for (auto iter : list) + m_ini.remove(iter); + } + // invalid -> remove all (just like resetSetting) + else { + for (auto iter : setting.configKeys()) + m_ini.remove(iter); + } + doSave(); + } +} + +void INISettingsObject::doSave() +{ + if (m_suspendSave) { + m_doSave = true; + } else { + m_ini.saveFile(m_filePath); + } +} + +void INISettingsObject::resetSetting(const Setting& setting) +{ + // if we have the setting, remove all the synonyms. ALL OF THEM + if (contains(setting.id())) { + for (auto iter : setting.configKeys()) + m_ini.remove(iter); + doSave(); + } +} + +QVariant INISettingsObject::retrieveValue(const Setting& setting) +{ + // if we have the setting, return value of the first matching synonym + if (contains(setting.id())) { + for (auto iter : setting.configKeys()) { + if (m_ini.contains(iter)) + return m_ini[iter]; + } + } + return QVariant(); +} diff --git a/launcher/settings/INISettingsObject.h b/launcher/settings/INISettingsObject.h new file mode 100644 index 0000000..4cc8085 --- /dev/null +++ b/launcher/settings/INISettingsObject.h @@ -0,0 +1,63 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +#include "settings/INIFile.h" + +#include "settings/SettingsObject.h" + +/*! + * \brief A settings object that stores its settings in an INIFile. + */ +class INISettingsObject : public SettingsObject { + Q_OBJECT + public: + /** 'paths' is a list of INI files to try, in order, for fallback support. */ + explicit INISettingsObject(QStringList paths, QObject* parent = nullptr); + + explicit INISettingsObject(QString path, QObject* parent = nullptr); + + /*! + * \brief Gets the path to the INI file. + * \return The path to the INI file. + */ + virtual QString filePath() const { return m_filePath; } + + /*! + * \brief Sets the path to the INI file and reloads it. + * \param filePath The INI file's new path. + */ + virtual void setFilePath(const QString& filePath); + + bool reload() override; + + void suspendSave() override; + void resumeSave() override; + + protected slots: + virtual void changeSetting(const Setting& setting, QVariant value) override; + virtual void resetSetting(const Setting& setting) override; + + protected: + virtual QVariant retrieveValue(const Setting& setting) override; + void doSave(); + + protected: + INIFile m_ini; + QString m_filePath; +}; diff --git a/launcher/settings/OverrideSetting.cpp b/launcher/settings/OverrideSetting.cpp new file mode 100644 index 0000000..fee65b8 --- /dev/null +++ b/launcher/settings/OverrideSetting.cpp @@ -0,0 +1,52 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "OverrideSetting.h" + +OverrideSetting::OverrideSetting(std::shared_ptr other, std::shared_ptr gate) : Setting(other->configKeys(), QVariant()) +{ + Q_ASSERT(other); + Q_ASSERT(gate); + m_other = other; + m_gate = gate; +} + +bool OverrideSetting::isOverriding() const +{ + return m_gate->get().toBool(); +} + +QVariant OverrideSetting::defValue() const +{ + return m_other->get(); +} + +QVariant OverrideSetting::get() const +{ + if (isOverriding()) { + return Setting::get(); + } + return m_other->get(); +} + +void OverrideSetting::reset() +{ + Setting::reset(); +} + +void OverrideSetting::set(QVariant value) +{ + Setting::set(value); +} diff --git a/launcher/settings/OverrideSetting.h b/launcher/settings/OverrideSetting.h new file mode 100644 index 0000000..3763b57 --- /dev/null +++ b/launcher/settings/OverrideSetting.h @@ -0,0 +1,45 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +#include "Setting.h" + +/*! + * \brief A setting that 'overrides another.' + * This means that the setting's default value will be the value of another setting. + * The other setting can be (and usually is) a part of a different SettingsObject + * than this one. + */ +class OverrideSetting : public Setting { + Q_OBJECT + public: + explicit OverrideSetting(std::shared_ptr overridden, std::shared_ptr gate); + + virtual QVariant defValue() const; + virtual QVariant get() const; + virtual void set(QVariant value); + virtual void reset(); + + private: + bool isOverriding() const; + + protected: + std::shared_ptr m_other; + std::shared_ptr m_gate; +}; diff --git a/launcher/settings/PassthroughSetting.cpp b/launcher/settings/PassthroughSetting.cpp new file mode 100644 index 0000000..86bb861 --- /dev/null +++ b/launcher/settings/PassthroughSetting.cpp @@ -0,0 +1,64 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "PassthroughSetting.h" + +PassthroughSetting::PassthroughSetting(std::shared_ptr other, std::shared_ptr gate) + : Setting(other->configKeys(), QVariant()) +{ + Q_ASSERT(other); + m_other = other; + m_gate = gate; +} + +bool PassthroughSetting::isOverriding() const +{ + if (!m_gate) { + return false; + } + return m_gate->get().toBool(); +} + +QVariant PassthroughSetting::defValue() const +{ + if (isOverriding()) { + return m_other->get(); + } + return m_other->defValue(); +} + +QVariant PassthroughSetting::get() const +{ + if (isOverriding()) { + return Setting::get(); + } + return m_other->get(); +} + +void PassthroughSetting::reset() +{ + if (isOverriding()) { + Setting::reset(); + } + m_other->reset(); +} + +void PassthroughSetting::set(QVariant value) +{ + if (isOverriding()) { + Setting::set(value); + } + m_other->set(value); +} diff --git a/launcher/settings/PassthroughSetting.h b/launcher/settings/PassthroughSetting.h new file mode 100644 index 0000000..3f34740 --- /dev/null +++ b/launcher/settings/PassthroughSetting.h @@ -0,0 +1,44 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +#include "Setting.h" + +/*! + * \brief A setting that 'overrides another.' based on the value of a 'gate' setting + * If 'gate' evaluates to true, the override stores and returns data + * If 'gate' evaluates to false, the original does, + */ +class PassthroughSetting : public Setting { + Q_OBJECT + public: + explicit PassthroughSetting(std::shared_ptr overridden, std::shared_ptr gate); + + virtual QVariant defValue() const; + virtual QVariant get() const; + virtual void set(QVariant value); + virtual void reset(); + + private: + bool isOverriding() const; + + protected: + std::shared_ptr m_other; + std::shared_ptr m_gate; +}; diff --git a/launcher/settings/Setting.cpp b/launcher/settings/Setting.cpp new file mode 100644 index 0000000..1e861e3 --- /dev/null +++ b/launcher/settings/Setting.cpp @@ -0,0 +1,47 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "Setting.h" +#include "settings/SettingsObject.h" + +Setting::Setting(QStringList synonyms, QVariant defVal) : QObject(), m_synonyms(synonyms), m_defVal(defVal) {} + +QVariant Setting::get() const +{ + SettingsObject* sbase = m_storage; + if (!sbase) { + return defValue(); + } else { + QVariant test = sbase->retrieveValue(*this); + if (!test.isValid()) + return defValue(); + return test; + } +} + +QVariant Setting::defValue() const +{ + return m_defVal; +} + +void Setting::set(QVariant value) +{ + emit SettingChanged(*this, value); +} + +void Setting::reset() +{ + emit settingReset(*this); +} diff --git a/launcher/settings/Setting.h b/launcher/settings/Setting.h new file mode 100644 index 0000000..44b3503 --- /dev/null +++ b/launcher/settings/Setting.h @@ -0,0 +1,109 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include + +class SettingsObject; + +/*! + * + */ +class Setting : public QObject { + Q_OBJECT + public: + /** + * Construct a Setting + * + * Synonyms are all the possible names used in the settings object, in order of preference. + * First synonym is the ID, which identifies the setting in Prism Launcher. + * + * defVal is the default value that will be returned when the settings object + * doesn't have any value for this setting. + */ + explicit Setting(QStringList synonyms, QVariant defVal = QVariant()); + + /*! + * \brief Gets this setting's ID. + * This is used to refer to the setting within the application. + * \warning Changing the ID while the setting is registered with a SettingsObject results in + * undefined behavior. + * \return The ID of the setting. + */ + virtual QString id() const { return m_synonyms.first(); } + + /*! + * \brief Gets this setting's config file key. + * This is used to store the setting's value in the config file. It is usually + * the same as the setting's ID, but it can be different. + * \return The setting's config file key. + */ + virtual QStringList configKeys() const { return m_synonyms; } + + /*! + * \brief Gets this setting's value as a QVariant. + * This is done by calling the SettingsObject's retrieveValue() function. + * If this Setting doesn't have a SettingsObject, this returns an invalid QVariant. + * \return QVariant containing this setting's value. + * \sa value() + */ + virtual QVariant get() const; + + /*! + * \brief Gets this setting's default value. + * \return The default value of this setting. + */ + virtual QVariant defValue() const; + + signals: + /*! + * \brief Signal emitted when this Setting object's value changes. + * \param setting A reference to the Setting that changed. + * \param value This Setting object's new value. + */ + void SettingChanged(const Setting& setting, QVariant value); + + /*! + * \brief Signal emitted when this Setting object's value resets to default. + * \param setting A reference to the Setting that changed. + */ + void settingReset(const Setting& setting); + + public slots: + /*! + * \brief Changes the setting's value. + * This is done by emitting the SettingChanged() signal which will then be + * handled by the SettingsObject object and cause the setting to change. + * \param value The new value. + */ + virtual void set(QVariant value); + + /*! + * \brief Reset the setting to default + * This is done by emitting the settingReset() signal which will then be + * handled by the SettingsObject object and cause the setting to change. + */ + virtual void reset(); + + protected: + friend class SettingsObject; + SettingsObject* m_storage; + QStringList m_synonyms; + QVariant m_defVal; +}; diff --git a/launcher/settings/SettingsObject.cpp b/launcher/settings/SettingsObject.cpp new file mode 100644 index 0000000..dda8326 --- /dev/null +++ b/launcher/settings/SettingsObject.cpp @@ -0,0 +1,240 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "settings/SettingsObject.h" +#include +#include "PassthroughSetting.h" +#include "settings/OverrideSetting.h" +#include "settings/Setting.h" + +#include +#include +#include + +#ifdef Q_OS_MACOS +#include "macsandbox/SecurityBookmarkFileAccess.h" +#endif + +SettingsObject::SettingsObject(QObject* parent) : QObject(parent) {} + +SettingsObject::~SettingsObject() +{ + m_settings.clear(); +} + +std::shared_ptr SettingsObject::registerOverride(std::shared_ptr original, std::shared_ptr gate) +{ + if (contains(original->id())) { + qCritical() << QString("Failed to register setting %1. ID already exists.").arg(original->id()); + return nullptr; // Fail + } + auto override = std::make_shared(original, gate); + override->m_storage = this; + connectSignals(*override); + m_settings.insert(override->id(), override); + return override; +} + +std::shared_ptr SettingsObject::registerPassthrough(std::shared_ptr original, std::shared_ptr gate) +{ + if (contains(original->id())) { + qCritical() << QString("Failed to register setting %1. ID already exists.").arg(original->id()); + return nullptr; // Fail + } + auto passthrough = std::make_shared(original, gate); + passthrough->m_storage = this; + connectSignals(*passthrough); + m_settings.insert(passthrough->id(), passthrough); + return passthrough; +} + +std::shared_ptr SettingsObject::registerSetting(QStringList synonyms, QVariant defVal) +{ + if (synonyms.empty()) + return nullptr; + if (contains(synonyms.first())) { + qCritical() << QString("Failed to register setting %1. ID already exists.").arg(synonyms.first()); + return nullptr; // Fail + } + auto setting = std::make_shared(synonyms, defVal); + setting->m_storage = this; + connectSignals(*setting); + m_settings.insert(setting->id(), setting); + return setting; +} + +std::shared_ptr SettingsObject::getSetting(const QString& id) const +{ + // Make sure there is a setting with the given ID. + if (!m_settings.contains(id)) + return NULL; + + return m_settings[id]; +} + +QVariant SettingsObject::get(const QString& id) +{ + auto setting = getSetting(id); + +#ifdef Q_OS_MACOS + // for macOS, use a security scoped bookmark for the paths + if (id.endsWith("Dir")) { + return { getPathFromBookmark(id) }; + } +#endif + + return (setting ? setting->get() : QVariant()); +} + +bool SettingsObject::set(const QString& id, QVariant value) +{ + auto setting = getSetting(id); + if (!setting) { + qCritical() << QString("Error changing setting %1. Setting doesn't exist.").arg(id); + return false; + } + +#ifdef Q_OS_MACOS + // for macOS, keep a security scoped bookmark for the paths + if (value.userType() == QMetaType::QString && id.endsWith("Dir")) { + setPathWithBookmark(id, value.toString()); + } +#endif + + setting->set(std::move(value)); + return true; +} + +#ifdef Q_OS_MACOS +QString SettingsObject::getPathFromBookmark(const QString& id) +{ + auto setting = getSetting(id); + if (!setting) { + qCritical() << QString("Error changing setting %1. Setting doesn't exist.").arg(id); + return ""; + } + + // there is no need to use bookmarks if the default value is used or the directory is within the data directory (already can access) + if (setting->get() == setting->defValue() || + QDir(setting->get().toString()).absolutePath().startsWith(QDir::current().absolutePath())) { + return setting->get().toString(); + } + + auto bookmarkId = id + "Bookmark"; + auto bookmarkSetting = getSetting(bookmarkId); + if (!bookmarkSetting) { + qCritical() << QString("Error changing setting %1. Bookmark setting doesn't exist.").arg(id); + return ""; + } + + QByteArray bookmark = bookmarkSetting->get().toByteArray(); + if (bookmark.isEmpty()) { + qDebug() << "Creating bookmark for" << id << "at" << setting->get().toString(); + setPathWithBookmark(id, setting->get().toString()); + return setting->get().toString(); + } + bool stale; + QUrl url = m_sandboxedFileAccess.securityScopedBookmarkToURL(bookmark, stale); + if (url.isValid()) { + if (stale) { + setting->set(url.path()); + bookmarkSetting->set(bookmark); + } + + m_sandboxedFileAccess.startUsingSecurityScopedBookmark(bookmark, stale); + // already did a stale check, no need to do it again + + // convert to relative path to current directory if `url` is a descendant of the current directory + QDir currentDir = QDir::current().absolutePath(); + return url.path().startsWith(currentDir.absolutePath()) ? currentDir.relativeFilePath(url.path()) : url.path(); + } + + return setting->get().toString(); +} + +bool SettingsObject::setPathWithBookmark(const QString& id, const QString& path) +{ + auto setting = getSetting(id); + if (!setting) { + qCritical() << QString("Error changing setting %1. Setting doesn't exist.").arg(id); + return false; + } + + QDir dir(path); + if (!dir.exists()) { + qCritical() << QString("Error changing setting %1. Path doesn't exist.").arg(id); + return false; + } + QString absolutePath = dir.absolutePath(); + QString bookmarkId = id + "Bookmark"; + std::shared_ptr bookmarkSetting = getSetting(bookmarkId); + // there is no need to use bookmarks if the default value is used or the directory is within the data directory (already can access) + if (path == setting->defValue().toString() || absolutePath.startsWith(QDir::current().absolutePath())) { + bookmarkSetting->reset(); + return true; + } + QByteArray bytes = m_sandboxedFileAccess.pathToSecurityScopedBookmark(absolutePath); + if (bytes.isEmpty()) { + qCritical() << QString("Failed to create bookmark for %1 - no access?").arg(id); + // TODO: show an alert to the user asking them to reselect the directory + return false; + } + auto oldBookmark = bookmarkSetting->get().toByteArray(); + m_sandboxedFileAccess.stopUsingSecurityScopedBookmark(oldBookmark); + if (!bytes.isEmpty() && bookmarkSetting) { + bookmarkSetting->set(bytes); + bool stale; + m_sandboxedFileAccess.startUsingSecurityScopedBookmark(bytes, stale); + // just created the bookmark, it shouldn't be stale + } + + setting->set(path); + return true; +} +#endif + +void SettingsObject::reset(const QString& id) const +{ + auto setting = getSetting(id); + if (setting) + setting->reset(); +} + +bool SettingsObject::contains(const QString& id) +{ + return m_settings.contains(id); +} + +bool SettingsObject::reload() +{ + for (auto setting : m_settings.values()) { + setting->set(setting->get()); + } + return true; +} + +void SettingsObject::connectSignals(const Setting& setting) +{ + connect(&setting, &Setting::SettingChanged, this, &SettingsObject::changeSetting); + connect(&setting, &Setting::SettingChanged, this, &SettingsObject::SettingChanged); + + connect(&setting, &Setting::settingReset, this, &SettingsObject::resetSetting); + connect(&setting, &Setting::settingReset, this, &SettingsObject::settingReset); +} + +std::shared_ptr SettingsObject::getOrRegisterSetting(const QString& id, QVariant defVal) +{ + return contains(id) ? getSetting(id) : registerSetting(id, defVal); +} diff --git a/launcher/settings/SettingsObject.h b/launcher/settings/SettingsObject.h new file mode 100644 index 0000000..5743bee --- /dev/null +++ b/launcher/settings/SettingsObject.h @@ -0,0 +1,238 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#ifdef Q_OS_MACOS +#include "macsandbox/SecurityBookmarkFileAccess.h" +#endif + +class Setting; +class SettingsObject; + +/*! + * \brief The SettingsObject handles communicating settings between the application and a + *settings file. + * The class keeps a list of Setting objects. Each Setting object represents one + * of the application's settings. These Setting objects are registered with + * a SettingsObject and can be managed similarly to the way a list works. + * + * \author Andrew Okin + * \date 2/22/2013 + * + * \sa Setting + */ +class SettingsObject : public QObject { + Q_OBJECT + public: + class Lock { + public: + Lock(SettingsObject* locked) : m_locked(locked) { m_locked->suspendSave(); } + ~Lock() { m_locked->resumeSave(); } + + private: + SettingsObject* m_locked; + }; + + public: + explicit SettingsObject(QObject* parent = 0); + virtual ~SettingsObject(); + /*! + * Registers an override setting for the given original setting in this settings object + * gate decides if the passthrough (true) or the original (false) is used for value + * + * This will fail if there is already a setting with the same ID as + * the one that is being registered. + * \return A valid Setting shared pointer if successful. + */ + std::shared_ptr registerOverride(std::shared_ptr original, std::shared_ptr gate); + + /*! + * Registers a passthorugh setting for the given original setting in this settings object + * gate decides if the passthrough (true) or the original (false) is used for value + * + * This will fail if there is already a setting with the same ID as + * the one that is being registered. + * \return A valid Setting shared pointer if successful. + */ + std::shared_ptr registerPassthrough(std::shared_ptr original, std::shared_ptr gate); + + /*! + * Registers the given setting with this SettingsObject and connects the necessary signals. + * + * This will fail if there is already a setting with the same ID as + * the one that is being registered. + * \return A valid Setting shared pointer if successful. + */ + std::shared_ptr registerSetting(QStringList synonyms, QVariant defVal = QVariant()); + + /*! + * Registers the given setting with this SettingsObject and connects the necessary signals. + * + * This will fail if there is already a setting with the same ID as + * the one that is being registered. + * \return A valid Setting shared pointer if successful. + */ + std::shared_ptr registerSetting(QString id, QVariant defVal = QVariant()) { return registerSetting(QStringList(id), defVal); } + + /*! + * \brief Gets the setting with the given ID. + * \param id The ID of the setting to get. + * \return A pointer to the setting with the given ID. + * Returns null if there is no setting with the given ID. + * \sa operator []() + */ + std::shared_ptr getSetting(const QString& id) const; + + /*! + * \brief Gets the setting with the given ID. + * \brief if is not registered yet it does that + * \param id The ID of the setting to get. + * \return A pointer to the setting with the given ID. + * Returns null if there is no setting with the given ID. + * \sa operator []() + */ + std::shared_ptr getOrRegisterSetting(const QString& id, QVariant defVal = QVariant()); + + /*! + * \brief Gets the value of the setting with the given ID. + * \param id The ID of the setting to get. + * \return The setting's value as a QVariant. + * If no setting with the given ID exists, returns an invalid QVariant. + */ + QVariant get(const QString& id); + +#ifdef Q_OS_MACOS + /*! + * \brief Get the path to the file or directory represented by the bookmark stored in the associated setting. + * \param id The setting ID of the relevant directory - this should not include "Bookmark" at the end. + * \return A path to the file or directory represented by the bookmark. + * If a bookmark is not valid or stored, use default logic (directly return the stored path). + * This can attempt to create a bookmark if the path is accessible and the bookmark is not valid. + */ + QString getPathFromBookmark(const QString& id); + /*! + * \brief Set a security-scoped bookmark to the provided path for the associated setting. + * \param id The setting ID of the relevant directory - this should not include "Bookmark" at the end. + * \param path The new desired path. + * \return A boolean indicating whether a bookmark was successfully set. + * The path needs to be accessible to the launcher before calling this function. For example, + * it could come from a user selection in an open panel. + */ + bool setPathWithBookmark(const QString& id, const QString& path); +#endif + + /*! + * \brief Sets the value of the setting with the given ID. + * If no setting with the given ID exists, returns false + * \param id The ID of the setting to change. + * \param value The new value of the setting. + * \return True if successful, false if it failed. + */ + bool set(const QString& id, QVariant value); + + /*! + * \brief Reverts the setting with the given ID to default. + * \param id The ID of the setting to reset. + */ + void reset(const QString& id) const; + + /*! + * \brief Checks if this SettingsObject contains a setting with the given ID. + * \param id The ID to check for. + * \return True if the SettingsObject has a setting with the given ID. + */ + bool contains(const QString& id); + + /*! + * \brief Reloads the settings and emit signals for changed settings + * \return True if reloading was successful + */ + virtual bool reload(); + + virtual void suspendSave() = 0; + virtual void resumeSave() = 0; + signals: + /*! + * \brief Signal emitted when one of this SettingsObject object's settings changes. + * This is usually just connected directly to each Setting object's + * SettingChanged() signals. + * \param setting A reference to the Setting object that changed. + * \param value The Setting object's new value. + */ + void SettingChanged(const Setting& setting, QVariant value); + + /*! + * \brief Signal emitted when one of this SettingsObject object's settings resets. + * This is usually just connected directly to each Setting object's + * settingReset() signals. + * \param setting A reference to the Setting object that changed. + */ + void settingReset(const Setting& setting); + + protected slots: + /*! + * \brief Changes a setting. + * This slot is usually connected to each Setting object's + * SettingChanged() signal. The signal is emitted, causing this slot + * to update the setting's value in the config file. + * \param setting A reference to the Setting object that changed. + * \param value The setting's new value. + */ + virtual void changeSetting(const Setting& setting, QVariant value) = 0; + + /*! + * \brief Resets a setting. + * This slot is usually connected to each Setting object's + * settingReset() signal. The signal is emitted, causing this slot + * to update the setting's value in the config file. + * \param setting A reference to the Setting object that changed. + */ + virtual void resetSetting(const Setting& setting) = 0; + + protected: + /*! + * \brief Connects the necessary signals to the given Setting. + * \param setting The setting to connect. + */ + void connectSignals(const Setting& setting); + + /*! + * \brief Function used by Setting objects to get their values from the SettingsObject. + * \param setting The + * \return + */ + virtual QVariant retrieveValue(const Setting& setting) = 0; + + friend class Setting; + + private: + QMap> m_settings; +#ifdef Q_OS_MACOS + SecurityBookmarkFileAccess m_sandboxedFileAccess; +#endif + + protected: + bool m_suspendSave = false; + bool m_doSave = false; +}; diff --git a/launcher/tasks/ConcurrentTask.cpp b/launcher/tasks/ConcurrentTask.cpp new file mode 100644 index 0000000..84530ec --- /dev/null +++ b/launcher/tasks/ConcurrentTask.cpp @@ -0,0 +1,259 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * Copyright (c) 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +#include "ConcurrentTask.h" + +#include +#include "tasks/Task.h" + +ConcurrentTask::ConcurrentTask(QString task_name, int max_concurrent) : Task(), m_total_max_size(max_concurrent) +{ + setObjectName(task_name); +} + +ConcurrentTask::~ConcurrentTask() +{ + for (auto task : m_doing) { + if (task) + task->disconnect(this); + } +} + +auto ConcurrentTask::getStepProgress() const -> TaskStepProgressList +{ + return m_task_progress.values(); +} + +void ConcurrentTask::addTask(Task::Ptr task) +{ + m_queue.append(task); +} + +void ConcurrentTask::executeTask() +{ + for (auto i = 0; i < m_total_max_size; i++) + QMetaObject::invokeMethod(this, &ConcurrentTask::executeNextSubTask, Qt::QueuedConnection); +} + +bool ConcurrentTask::abort() +{ + m_queue.clear(); + + if (m_doing.isEmpty()) { + // Don't call emitAborted() here, we want to bypass the 'is the task running' check + emit aborted(); + emit finished(); + + return true; + } + + bool suceedeed = true; + + QMutableHashIterator doing_iter(m_doing); + while (doing_iter.hasNext()) { + auto task = doing_iter.next(); + disconnect(task->get(), &Task::aborted, this, 0); + suceedeed &= (task.value())->abort(); + } + + if (suceedeed) + emitAborted(); + else + emitFailed(tr("Failed to abort all running tasks.")); + + return suceedeed; +} + +void ConcurrentTask::clear() +{ + Q_ASSERT(!isRunning()); + + m_done.clear(); + m_failed.clear(); + m_queue.clear(); + m_task_progress.clear(); + + m_progress = 0; +} + +void ConcurrentTask::executeNextSubTask() +{ + if (!isRunning()) { + return; + } + if (m_doing.count() >= m_total_max_size) { + return; + } + if (m_queue.isEmpty()) { + if (m_doing.isEmpty()) { + if (m_failed.isEmpty()) { + emitSucceeded(); + } else if (m_failed.count() == 1) { + auto task = m_failed.keys().first(); + auto reason = task->failReason(); + if (reason.isEmpty()) { // clearly a bug somewhere + reason = tr("Task failed"); + } + emitFailed(reason); + } else { + QStringList failReason; + for (auto t : m_failed) { + auto reason = t->failReason(); + if (!reason.isEmpty()) { + failReason << reason; + } + } + if (failReason.isEmpty()) { + emitFailed(tr("Multiple subtasks failed")); + } else { + emitFailed(tr("Multiple subtasks failed\n%1").arg(failReason.join("\n"))); + } + } + } + return; + } + + startSubTask(m_queue.dequeue()); +} + +void ConcurrentTask::startSubTask(Task::Ptr next) +{ + connect(next.get(), &Task::succeeded, this, [this, next]() { subTaskSucceeded(next); }); + connect(next.get(), &Task::failed, this, [this, next](QString msg) { subTaskFailed(next, msg); }); + // this should never happen but if it does, it's better to fail the task than get stuck + connect(next.get(), &Task::aborted, this, [this, next] { subTaskFailed(next, "Aborted"); }); + + connect(next.get(), &Task::status, this, [this, next](QString msg) { subTaskStatus(next, msg); }); + connect(next.get(), &Task::details, this, [this, next](QString msg) { subTaskDetails(next, msg); }); + connect(next.get(), &Task::stepProgress, this, &ConcurrentTask::stepProgress); + + connect(next.get(), &Task::progress, this, [this, next](qint64 current, qint64 total) { subTaskProgress(next, current, total); }); + + m_doing.insert(next.get(), next); + + auto task_progress = std::make_shared(next->getUid()); + m_task_progress.insert(next->getUid(), task_progress); + + updateState(); + + QMetaObject::invokeMethod(next.get(), &Task::start, Qt::QueuedConnection); +} + +void ConcurrentTask::subTaskFinished(Task::Ptr task, TaskStepState state) +{ + m_done.insert(task.get(), task); + (state == TaskStepState::Succeeded ? m_succeeded : m_failed).insert(task.get(), task); + + m_doing.remove(task.get()); + + auto task_progress = *m_task_progress.value(task->getUid()); + task_progress.state = state; + m_task_progress.remove(task->getUid()); + + disconnect(task.get(), 0, this, 0); + + emit stepProgress(task_progress); + updateState(); + QMetaObject::invokeMethod(this, &ConcurrentTask::executeNextSubTask, Qt::QueuedConnection); +} + +void ConcurrentTask::subTaskSucceeded(Task::Ptr task) +{ + subTaskFinished(task, TaskStepState::Succeeded); +} + +void ConcurrentTask::subTaskFailed(Task::Ptr task, [[maybe_unused]] const QString& msg) +{ + subTaskFinished(task, TaskStepState::Failed); +} + +void ConcurrentTask::subTaskStatus(Task::Ptr task, const QString& msg) +{ + auto task_progress = m_task_progress.value(task->getUid()); + task_progress->status = msg; + task_progress->state = TaskStepState::Running; + + emit stepProgress(*task_progress); + + if (totalSize() == 1) { + setStatus(msg); + } +} + +void ConcurrentTask::subTaskDetails(Task::Ptr task, const QString& msg) +{ + auto task_progress = m_task_progress.value(task->getUid()); + task_progress->details = msg; + task_progress->state = TaskStepState::Running; + + emit stepProgress(*task_progress); + + if (totalSize() == 1) { + setDetails(msg); + } +} + +void ConcurrentTask::subTaskProgress(Task::Ptr task, qint64 current, qint64 total) +{ + auto task_progress = m_task_progress.value(task->getUid()); + + task_progress->update(current, total); + + emit stepProgress(*task_progress); + updateState(); + + if (totalSize() == 1) { + setProgress(task_progress->current, task_progress->total); + } +} + +void ConcurrentTask::updateState() +{ + if (totalSize() > 1) { + setProgress(m_done.count(), totalSize()); + setStatus(tr("Executing %1 task(s) (%2 out of %3 are done)") + .arg(QString::number(m_doing.count()), QString::number(m_done.count()), QString::number(totalSize()))); + } else { + QString status = tr("Please wait..."); + if (m_queue.size() > 0) { + status = tr("Waiting for a task to start..."); + } else if (m_doing.size() > 0) { + status = tr("Executing 1 task:"); + } else if (m_done.size() > 0) { + status = tr("Task finished."); + } + setStatus(status); + } +} diff --git a/launcher/tasks/ConcurrentTask.h b/launcher/tasks/ConcurrentTask.h new file mode 100644 index 0000000..a65613b --- /dev/null +++ b/launcher/tasks/ConcurrentTask.h @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * Copyright (c) 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +#pragma once + +#include +#include +#include +#include +#include + +#include "tasks/Task.h" + +/*! + * Runs a list of tasks concurrently (according to `max_concurrent` parameter). + * Behaviour is the same as regular Task (e.g. starts using start()) + */ +class ConcurrentTask : public Task { + Q_OBJECT + public: + using Ptr = shared_qobject_ptr; + + explicit ConcurrentTask(QString task_name = "", int max_concurrent = 6); + ~ConcurrentTask() override; + + // safe to call before starting the task + void setMaxConcurrent(int max_concurrent) { m_total_max_size = max_concurrent; } + + bool canAbort() const override { return true; } + + inline auto isMultiStep() const -> bool override { return totalSize() > 1; } + auto getStepProgress() const -> TaskStepProgressList override; + + //! Adds a task to execute in this ConcurrentTask + void addTask(Task::Ptr task); + + public slots: + bool abort() override; + + /** Resets the internal state of the task. + * This allows the same task to be re-used. + */ + void clear(); + + protected slots: + void executeTask() override; + + virtual void executeNextSubTask(); + + void subTaskSucceeded(Task::Ptr); + virtual void subTaskFailed(Task::Ptr, const QString& msg); + void subTaskFinished(Task::Ptr, TaskStepState); + void subTaskStatus(Task::Ptr task, const QString& msg); + void subTaskDetails(Task::Ptr task, const QString& msg); + void subTaskProgress(Task::Ptr task, qint64 current, qint64 total); + + protected: + // NOTE: This is not thread-safe. + unsigned int totalSize() const { return static_cast(m_queue.size() + m_doing.size() + m_done.size()); } + + virtual void updateState(); + + void startSubTask(Task::Ptr task); + + protected: + QQueue m_queue; + + QHash m_doing; + QHash m_done; + QHash m_failed; + QHash m_succeeded; + + QHash> m_task_progress; + + int m_total_max_size; +}; diff --git a/launcher/tasks/MultipleOptionsTask.cpp b/launcher/tasks/MultipleOptionsTask.cpp new file mode 100644 index 0000000..ba0c235 --- /dev/null +++ b/launcher/tasks/MultipleOptionsTask.cpp @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +#include "MultipleOptionsTask.h" + +#include + +MultipleOptionsTask::MultipleOptionsTask(const QString& task_name) : ConcurrentTask(task_name, 1) {} + +void MultipleOptionsTask::executeNextSubTask() +{ + if (m_done.size() != m_failed.size()) { + emitSucceeded(); + return; + } + + if (m_queue.isEmpty()) { + emitFailed(tr("All attempts have failed!")); + qWarning() << "All attempts have failed!"; + return; + } + + ConcurrentTask::executeNextSubTask(); +} + +void MultipleOptionsTask::updateState() +{ + setProgress(m_done.count(), totalSize()); + setStatus(tr("Attempting task %1 out of %2").arg(QString::number(m_doing.count() + m_done.count()), QString::number(totalSize()))); +} diff --git a/launcher/tasks/MultipleOptionsTask.h b/launcher/tasks/MultipleOptionsTask.h new file mode 100644 index 0000000..7a19ed6 --- /dev/null +++ b/launcher/tasks/MultipleOptionsTask.h @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +#pragma once + +#include "ConcurrentTask.h" + +/* This task type will attempt to do run each of it's subtasks in sequence, + * until one of them succeeds. When that happens, the remaining tasks will not run. + * */ +class MultipleOptionsTask : public ConcurrentTask { + Q_OBJECT + public: + explicit MultipleOptionsTask(const QString& task_name = ""); + ~MultipleOptionsTask() override = default; + + private slots: + void executeNextSubTask() override; + void updateState() override; +}; diff --git a/launcher/tasks/SequentialTask.cpp b/launcher/tasks/SequentialTask.cpp new file mode 100644 index 0000000..d1ffe61 --- /dev/null +++ b/launcher/tasks/SequentialTask.cpp @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * Copyright (c) 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +#include "SequentialTask.h" + +#include +#include "tasks/ConcurrentTask.h" + +SequentialTask::SequentialTask(QString task_name) : ConcurrentTask(task_name, 1) {} + +void SequentialTask::subTaskFailed(Task::Ptr task, const QString& msg) +{ + qWarning() << msg; + ConcurrentTask::subTaskFailed(task, msg); + emitFailed(msg); +} + +void SequentialTask::updateState() +{ + setProgress(m_done.count(), totalSize()); + setStatus(tr("Executing task %1 out of %2").arg(QString::number(m_doing.count() + m_done.count()), QString::number(totalSize()))); +} diff --git a/launcher/tasks/SequentialTask.h b/launcher/tasks/SequentialTask.h new file mode 100644 index 0000000..77cd438 --- /dev/null +++ b/launcher/tasks/SequentialTask.h @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * Copyright (c) 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +#pragma once + +#include "ConcurrentTask.h" + +/** A concurrent task that only allows one concurrent task :) + * + * This should be used when there's a need to maintain a strict ordering of task executions, and + * the starting of a task is contingent on the success of the previous one. + * + * See MultipleOptionsTask if that's not the case. + */ +class SequentialTask : public ConcurrentTask { + Q_OBJECT + public: + explicit SequentialTask(QString task_name = ""); + ~SequentialTask() override = default; + + protected slots: + virtual void subTaskFailed(Task::Ptr, const QString& msg) override; + + protected: + void updateState() override; +}; diff --git a/launcher/tasks/Task.cpp b/launcher/tasks/Task.cpp new file mode 100644 index 0000000..a54b4e7 --- /dev/null +++ b/launcher/tasks/Task.cpp @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * Copyright (c) 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "Task.h" + +#include + +#include "AssertHelpers.h" + +Q_LOGGING_CATEGORY(taskLogC, "launcher.task") + +Task::Task(bool show_debug) : m_show_debug(show_debug) +{ + m_uid = QUuid::createUuid(); + setAutoDelete(false); +} + +void Task::setStatus(const QString& new_status) +{ + if (m_status != new_status) { + m_status = new_status; + emit status(m_status); + } +} + +void Task::setDetails(const QString& new_details) +{ + if (m_details != new_details) { + m_details = new_details; + emit details(m_details); + } +} + +void Task::setProgress(qint64 current, qint64 total) +{ + if ((m_progress != current) || (m_progressTotal != total)) { + m_progress = current; + m_progressTotal = total; + + emit progress(m_progress, m_progressTotal); + } +} + +void Task::start() +{ + switch (m_state) { + case State::Inactive: { + if (m_show_debug) + qCDebug(taskLogC) << "Task" << describe() << "starting for the first time"; + break; + } + case State::AbortedByUser: { + if (m_show_debug) + qCDebug(taskLogC) << "Task" << describe() << "restarting for after being aborted by user"; + break; + } + case State::Failed: { + if (m_show_debug) + qCDebug(taskLogC) << "Task" << describe() << "restarting for after failing at first"; + break; + } + case State::Succeeded: { + if (m_show_debug) + qCDebug(taskLogC) << "Task" << describe() << "restarting for after succeeding at first"; + break; + } + case State::Running: { + if (ASSERT_NEVER(isRunning()) && m_show_debug) + qCWarning(taskLogC) << "The launcher tried to start task" << describe() << "while it was already running!"; + return; + } + } + // NOTE: only fall through to here in end states + m_state = State::Running; + emit started(); + executeTask(); +} + +void Task::emitFailed(QString reason) +{ + // Don't fail twice. + if (ASSERT_NEVER(!isRunning())) { + qCCritical(taskLogC) << "Task" << describe() << "failed while not running!!!!:" << reason; + return; + } + m_state = State::Failed; + m_failReason = reason; + qCCritical(taskLogC) << "Task" << describe() << "failed:" << reason; + emit failed(reason); + emit finished(); +} + +void Task::emitAborted() +{ + // Don't abort twice. + if (ASSERT_NEVER(!isRunning())) { + qCCritical(taskLogC) << "Task" << describe() << "aborted while not running!!!!"; + return; + } + m_state = State::AbortedByUser; + m_failReason = tr("Aborted"); + if (m_show_debug) + qCDebug(taskLogC) << "Task" << describe() << "aborted."; + emit aborted(); + emit finished(); +} + +void Task::emitSucceeded() +{ + // Don't succeed twice. + if (ASSERT_NEVER(!isRunning())) { + qCCritical(taskLogC) << "Task" << describe() << "succeeded while not running!!!!"; + return; + } + m_state = State::Succeeded; + if (m_show_debug) + qCDebug(taskLogC) << "Task" << describe() << "succeeded"; + emit succeeded(); + emit finished(); +} + +void Task::propagateStepProgress(TaskStepProgress const& task_progress) +{ + emit stepProgress(task_progress); +} + +QString Task::describe() +{ + QString outStr; + QTextStream out(&outStr); + out << metaObject()->className() << QChar('('); + auto name = objectName(); + if (name.isEmpty()) { + out << QString("0x%1").arg(reinterpret_cast(this), 0, 16); + } else { + out << name; + } + out << " ID: " << m_uid.toString(QUuid::WithoutBraces); + out << QChar(')'); + out.flush(); + return outStr; +} + +bool Task::isRunning() const +{ + return m_state == State::Running; +} + +bool Task::isFinished() const +{ + return m_state != State::Running && m_state != State::Inactive; +} + +bool Task::wasSuccessful() const +{ + return m_state == State::Succeeded; +} + +QString Task::failReason() const +{ + return m_failReason; +} + +void Task::propagateFromOther(Task* other) +{ + Q_ASSERT(other); + connect(other, &Task::status, this, &Task::setStatus); + connect(other, &Task::details, this, &Task::setDetails); + connect(other, &Task::progress, this, &Task::setProgress); + connect(other, &Task::stepProgress, this, &Task::propagateStepProgress); + + setStatus(other->getStatus()); + setDetails(other->getDetails()); + setProgress(other->getProgress(), other->getTotalProgress()); + for (const auto& progress : other->getStepProgress()) { + propagateStepProgress(*progress); + } +} + +void Task::logWarning(const QString& line) +{ + qWarning() << line; + m_Warnings.append(line); + + emit warningLogged(line); +} + +QStringList Task::warnings() const +{ + return m_Warnings; +} diff --git a/launcher/tasks/Task.h b/launcher/tasks/Task.h new file mode 100644 index 0000000..94fb577 --- /dev/null +++ b/launcher/tasks/Task.h @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * PrismLauncher - Minecraft Launcher + * Copyright (c) 2022 flowln + * Copyright (c) 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +#include "QObjectPtr.h" + +Q_DECLARE_LOGGING_CATEGORY(taskLogC) + +enum class TaskStepState { Waiting, Running, Failed, Succeeded }; + +Q_DECLARE_METATYPE(TaskStepState) + +struct TaskStepProgress { + QUuid uid; + qint64 current = 0; + qint64 total = -1; + + qint64 old_current = 0; + qint64 old_total = -1; + + QString status = ""; + QString details = ""; + TaskStepState state = TaskStepState::Waiting; + + TaskStepProgress() { this->uid = QUuid::createUuid(); } + TaskStepProgress(QUuid uid_) : uid(uid_) {} + + bool isDone() const { return (state == TaskStepState::Failed) || (state == TaskStepState::Succeeded); } + void update(qint64 new_current, qint64 new_total) + { + this->old_current = this->current; + this->old_total = this->total; + + this->current = new_current; + this->total = new_total; + this->state = TaskStepState::Running; + } +}; + +Q_DECLARE_METATYPE(TaskStepProgress) + +using TaskStepProgressList = QList>; + +/*! + * Represents a task that has to be done. + * To create a task, you need to subclass this class, implement the executeTask() method and call + * emitSucceeded() or emitFailed() when the task is done. + * the caller needs to call start() to start the task. + */ +class Task : public QObject, public QRunnable { + Q_OBJECT + public: + using Ptr = shared_qobject_ptr; + + enum class State { Inactive, Running, Succeeded, Failed, AbortedByUser }; + + public: + explicit Task(bool show_debug_log = true); + virtual ~Task() = default; + + bool isRunning() const; + bool isFinished() const; + bool wasSuccessful() const; + + /*! + * MultiStep tasks are combinations of multiple tasks into a single logical task. + * The main usage of this is in SequencialTask. + */ + virtual auto isMultiStep() const -> bool { return false; } + + /*! + * Returns the string that was passed to emitFailed as the error message when the task failed. + * If the task hasn't failed, returns an empty string. + */ + QString failReason() const; + + virtual QStringList warnings() const; + + virtual bool canAbort() const { return m_can_abort; } + + auto getState() const -> State { return m_state; } + + QString getStatus() { return m_status; } + QString getDetails() { return m_details; } + + qint64 getProgress() { return m_progress; } + qint64 getTotalProgress() { return m_progressTotal; } + virtual auto getStepProgress() const -> TaskStepProgressList { return {}; } + + QUuid getUid() { return m_uid; } + + // Copies the other task's status, details, progress, and step progress to this task; and sets up connections for future propagation + void propagateFromOther(Task* other); + + protected: + void logWarning(const QString& line); + + private: + QString describe(); + + signals: + void started(); + void progress(qint64 current, qint64 total); + //! called when a task has either succeeded, aborted or failed. + void finished(); + //! called when a task has succeeded + void succeeded(); + //! called when a task has been aborted by calling abort() + void aborted(); + void failed(QString reason); + void status(QString status); + void details(QString details); + void warningLogged(const QString& warning); + void stepProgress(TaskStepProgress const& task_progress); + + //! Emitted when the canAbort() status has changed. */ + void abortStatusChanged(bool can_abort); + + void abortButtonTextChanged(QString text); + + public slots: + // QRunnable's interface + void run() override { start(); } + + //! used by the task caller to start the task + virtual void start(); + //! used by external code to ask the task to abort + virtual bool abort() + { + if (canAbort()) + emitAborted(); + return canAbort(); + } + + void setAbortable(bool can_abort) + { + m_can_abort = can_abort; + emit abortStatusChanged(can_abort); + } + + void setAbortButtonText(QString text) + { + emit abortButtonTextChanged(text); + } + + protected: + //! The task subclass must implement this method. This method is called to start to run the task. + //! The task is not finished when this method returns. the subclass must manually call emitSucceeded() or emitFailed() instead. + virtual void executeTask() = 0; + + protected slots: + //! The Task subclass must call this method when the task has succeeded + virtual void emitSucceeded(); + //! **The Task subclass** must call this method when the task has aborted. External code should call abort() instead. + virtual void emitAborted(); + //! The Task subclass must call this method when the task has failed + virtual void emitFailed(QString reason = ""); + + virtual void propagateStepProgress(TaskStepProgress const& task_progress); + + public slots: + void setStatus(const QString& status); + void setDetails(const QString& details); + void setProgress(qint64 current, qint64 total); + + protected: + State m_state = State::Inactive; + QStringList m_Warnings; + QString m_failReason = ""; + QString m_status; + QString m_details; + int m_progress = 0; + int m_progressTotal = 100; + + // TODO: Nuke in favor of QLoggingCategory + bool m_show_debug = true; + + private: + // Change using setAbortStatus + bool m_can_abort = false; + QUuid m_uid; +}; diff --git a/launcher/tools/BaseExternalTool.cpp b/launcher/tools/BaseExternalTool.cpp new file mode 100644 index 0000000..dd1a683 --- /dev/null +++ b/launcher/tools/BaseExternalTool.cpp @@ -0,0 +1,32 @@ +#include "BaseExternalTool.h" + +#include +#include + +#ifdef Q_OS_WIN +#include +#endif + +#include "BaseInstance.h" + +BaseExternalTool::BaseExternalTool(SettingsObject* settings, BaseInstance* instance, QObject* parent) + : QObject(parent), m_instance(instance), globalSettings(settings) +{} + +BaseExternalTool::~BaseExternalTool() {} + +BaseDetachedTool::BaseDetachedTool(SettingsObject* settings, BaseInstance* instance, QObject* parent) + : BaseExternalTool(settings, instance, parent) +{} + +void BaseDetachedTool::run() +{ + runImpl(); +} + +BaseExternalToolFactory::~BaseExternalToolFactory() {} + +BaseDetachedTool* BaseDetachedToolFactory::createDetachedTool(BaseInstance* instance, QObject* parent) +{ + return qobject_cast(createTool(instance, parent)); +} diff --git a/launcher/tools/BaseExternalTool.h b/launcher/tools/BaseExternalTool.h new file mode 100644 index 0000000..0890c8e --- /dev/null +++ b/launcher/tools/BaseExternalTool.h @@ -0,0 +1,53 @@ +#pragma once + +#include +#include + +class BaseInstance; +class SettingsObject; +class QProcess; + +class BaseExternalTool : public QObject { + Q_OBJECT + public: + explicit BaseExternalTool(SettingsObject* settings, BaseInstance* instance, QObject* parent = 0); + virtual ~BaseExternalTool(); + + protected: + BaseInstance* m_instance; + SettingsObject* globalSettings; +}; + +class BaseDetachedTool : public BaseExternalTool { + Q_OBJECT + public: + explicit BaseDetachedTool(SettingsObject* settings, BaseInstance* instance, QObject* parent = 0); + + public slots: + void run(); + + protected: + virtual void runImpl() = 0; +}; + +class BaseExternalToolFactory { + public: + virtual ~BaseExternalToolFactory(); + + virtual QString name() const = 0; + + virtual void registerSettings(SettingsObject* settings) = 0; + + virtual BaseExternalTool* createTool(BaseInstance* instance, QObject* parent = 0) = 0; + + virtual bool check(QString* error) = 0; + virtual bool check(const QString& path, QString* error) = 0; + + protected: + SettingsObject* globalSettings; +}; + +class BaseDetachedToolFactory : public BaseExternalToolFactory { + public: + virtual BaseDetachedTool* createDetachedTool(BaseInstance* instance, QObject* parent = 0); +}; diff --git a/launcher/tools/BaseProfiler.cpp b/launcher/tools/BaseProfiler.cpp new file mode 100644 index 0000000..f7a30fa --- /dev/null +++ b/launcher/tools/BaseProfiler.cpp @@ -0,0 +1,33 @@ +#include "BaseProfiler.h" +#include "QObjectPtr.h" + +#include + +BaseProfiler::BaseProfiler(SettingsObject* settings, BaseInstance* instance, QObject* parent) : BaseExternalTool(settings, instance, parent) +{} + +void BaseProfiler::beginProfiling(LaunchTask* process) +{ + beginProfilingImpl(process); +} + +void BaseProfiler::abortProfiling() +{ + abortProfilingImpl(); +} + +void BaseProfiler::abortProfilingImpl() +{ + if (!m_profilerProcess) { + return; + } + m_profilerProcess->terminate(); + m_profilerProcess->deleteLater(); + m_profilerProcess = 0; + emit abortLaunch(tr("Profiler aborted")); +} + +BaseProfiler* BaseProfilerFactory::createProfiler(BaseInstance* instance, QObject* parent) +{ + return qobject_cast(createTool(instance, parent)); +} diff --git a/launcher/tools/BaseProfiler.h b/launcher/tools/BaseProfiler.h new file mode 100644 index 0000000..b84a591 --- /dev/null +++ b/launcher/tools/BaseProfiler.h @@ -0,0 +1,34 @@ +#pragma once + +#include "BaseExternalTool.h" +#include "QObjectPtr.h" + +class BaseInstance; +class SettingsObject; +class LaunchTask; +class QProcess; + +class BaseProfiler : public BaseExternalTool { + Q_OBJECT + public: + explicit BaseProfiler(SettingsObject* settings, BaseInstance* instance, QObject* parent = 0); + + public slots: + void beginProfiling(LaunchTask* process); + void abortProfiling(); + + protected: + QProcess* m_profilerProcess; + + virtual void beginProfilingImpl(LaunchTask* process) = 0; + virtual void abortProfilingImpl(); + + signals: + void readyToLaunch(const QString& message); + void abortLaunch(const QString& message); +}; + +class BaseProfilerFactory : public BaseExternalToolFactory { + public: + virtual BaseProfiler* createProfiler(BaseInstance* instance, QObject* parent = 0); +}; diff --git a/launcher/tools/GenericProfiler.cpp b/launcher/tools/GenericProfiler.cpp new file mode 100644 index 0000000..66c63d0 --- /dev/null +++ b/launcher/tools/GenericProfiler.cpp @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +#include "GenericProfiler.h" + +#include "BaseInstance.h" +#include "launch/LaunchTask.h" +#include "settings/SettingsObject.h" + +class GenericProfiler : public BaseProfiler { + Q_OBJECT + public: + GenericProfiler(SettingsObject* settings, BaseInstance* instance, QObject* parent = 0); + + protected: + void beginProfilingImpl(LaunchTask* process); +}; + +GenericProfiler::GenericProfiler(SettingsObject* settings, BaseInstance* instance, QObject* parent) + : BaseProfiler(settings, instance, parent) +{} + +void GenericProfiler::beginProfilingImpl(LaunchTask* process) +{ + emit readyToLaunch(tr("Started process: %1").arg(process->pid())); +} + +BaseExternalTool* GenericProfilerFactory::createTool(BaseInstance* instance, QObject* parent) +{ + return new GenericProfiler(globalSettings, instance, parent); +} +#include "GenericProfiler.moc" diff --git a/launcher/tools/GenericProfiler.h b/launcher/tools/GenericProfiler.h new file mode 100644 index 0000000..49ce727 --- /dev/null +++ b/launcher/tools/GenericProfiler.h @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +#pragma once + +#include "BaseProfiler.h" + +class GenericProfilerFactory : public BaseProfilerFactory { + public: + QString name() const override { return "Generic"; } + void registerSettings([[maybe_unused]] SettingsObject* settings) override {}; + BaseExternalTool* createTool(BaseInstance* instance, QObject* parent = 0) override; + bool check([[maybe_unused]] QString* error) override { return true; }; + bool check([[maybe_unused]] const QString& path, [[maybe_unused]] QString* error) override { return true; }; +}; diff --git a/launcher/tools/JProfiler.cpp b/launcher/tools/JProfiler.cpp new file mode 100644 index 0000000..5d51cde --- /dev/null +++ b/launcher/tools/JProfiler.cpp @@ -0,0 +1,101 @@ +#include "JProfiler.h" + +#include + +#include "BaseInstance.h" +#include "launch/LaunchTask.h" +#include "settings/SettingsObject.h" + +class JProfiler : public BaseProfiler { + Q_OBJECT + public: + JProfiler(SettingsObject* settings, BaseInstance* instance, QObject* parent = 0); + + private slots: + void profilerStarted(); + void profilerFinished(int exit, QProcess::ExitStatus status); + + protected: + void beginProfilingImpl(LaunchTask* process); + + private: + int listeningPort = 0; +}; + +JProfiler::JProfiler(SettingsObject* settings, BaseInstance* instance, QObject* parent) : BaseProfiler(settings, instance, parent) {} + +void JProfiler::profilerStarted() +{ + emit readyToLaunch(tr("Listening on port: %1").arg(listeningPort)); +} + +void JProfiler::profilerFinished([[maybe_unused]] int exit, QProcess::ExitStatus status) +{ + if (status == QProcess::CrashExit) { + emit abortLaunch(tr("Profiler aborted")); + } + if (m_profilerProcess) { + m_profilerProcess->deleteLater(); + m_profilerProcess = 0; + } +} + +void JProfiler::beginProfilingImpl(LaunchTask* process) +{ + listeningPort = globalSettings->get("JProfilerPort").toInt(); + QProcess* profiler = new QProcess(this); + QStringList profilerArgs = { "-d", QString::number(process->pid()), "--gui", "-p", QString::number(listeningPort) }; + auto basePath = globalSettings->get("JProfilerPath").toString(); + +#ifdef Q_OS_WIN + QString profilerProgram = QDir(basePath).absoluteFilePath("bin/jpenable.exe"); +#else + QString profilerProgram = QDir(basePath).absoluteFilePath("bin/jpenable"); +#endif + + profiler->setArguments(profilerArgs); + profiler->setProgram(profilerProgram); + + connect(profiler, &QProcess::started, this, &JProfiler::profilerStarted); + connect(profiler, &QProcess::finished, this, &JProfiler::profilerFinished); + + m_profilerProcess = profiler; + profiler->start(); +} + +void JProfilerFactory::registerSettings(SettingsObject* settings) +{ + settings->registerSetting("JProfilerPath"); + settings->registerSetting("JProfilerPort", 42042); + globalSettings = settings; +} + +BaseExternalTool* JProfilerFactory::createTool(BaseInstance* instance, QObject* parent) +{ + return new JProfiler(globalSettings, instance, parent); +} + +bool JProfilerFactory::check(QString* error) +{ + return check(globalSettings->get("JProfilerPath").toString(), error); +} + +bool JProfilerFactory::check(const QString& path, QString* error) +{ + if (path.isEmpty()) { + *error = QObject::tr("Empty path"); + return false; + } + QDir dir(path); + if (!dir.exists()) { + *error = QObject::tr("Path does not exist"); + return false; + } + if (!dir.exists("bin") || !(dir.exists("bin/jprofiler") || dir.exists("bin/jprofiler.exe")) || !dir.exists("bin/agent.jar")) { + *error = QObject::tr("Invalid JProfiler install"); + return false; + } + return true; +} + +#include "JProfiler.moc" diff --git a/launcher/tools/JProfiler.h b/launcher/tools/JProfiler.h new file mode 100644 index 0000000..4e6975c --- /dev/null +++ b/launcher/tools/JProfiler.h @@ -0,0 +1,12 @@ +#pragma once + +#include "BaseProfiler.h" + +class JProfilerFactory : public BaseProfilerFactory { + public: + QString name() const override { return "JProfiler"; } + void registerSettings(SettingsObject* settings) override; + BaseExternalTool* createTool(BaseInstance* instance, QObject* parent = 0) override; + bool check(QString* error) override; + bool check(const QString& path, QString* error) override; +}; diff --git a/launcher/tools/JVisualVM.cpp b/launcher/tools/JVisualVM.cpp new file mode 100644 index 0000000..9155a18 --- /dev/null +++ b/launcher/tools/JVisualVM.cpp @@ -0,0 +1,91 @@ +#include "JVisualVM.h" + +#include +#include + +#include "BaseInstance.h" +#include "launch/LaunchTask.h" +#include "settings/SettingsObject.h" + +class JVisualVM : public BaseProfiler { + Q_OBJECT + public: + JVisualVM(SettingsObject* settings, BaseInstance* instance, QObject* parent = 0); + + private slots: + void profilerStarted(); + void profilerFinished(int exit, QProcess::ExitStatus status); + + protected: + void beginProfilingImpl(LaunchTask* process); +}; + +JVisualVM::JVisualVM(SettingsObject* settings, BaseInstance* instance, QObject* parent) : BaseProfiler(settings, instance, parent) {} + +void JVisualVM::profilerStarted() +{ + emit readyToLaunch(tr("VisualVM started")); +} + +void JVisualVM::profilerFinished([[maybe_unused]] int exit, QProcess::ExitStatus status) +{ + if (status == QProcess::CrashExit) { + emit abortLaunch(tr("Profiler aborted")); + } + if (m_profilerProcess) { + m_profilerProcess->deleteLater(); + m_profilerProcess = 0; + } +} + +void JVisualVM::beginProfilingImpl(LaunchTask* process) +{ + QProcess* profiler = new QProcess(this); + QStringList profilerArgs = { "--openpid", QString::number(process->pid()) }; + auto programPath = globalSettings->get("JVisualVMPath").toString(); + + profiler->setArguments(profilerArgs); + profiler->setProgram(programPath); + + connect(profiler, &QProcess::started, this, &JVisualVM::profilerStarted); + connect(profiler, &QProcess::finished, this, &JVisualVM::profilerFinished); + + profiler->start(); + m_profilerProcess = profiler; +} + +void JVisualVMFactory::registerSettings(SettingsObject* settings) +{ + QString defaultValue = QStandardPaths::findExecutable("jvisualvm"); + if (defaultValue.isNull()) { + defaultValue = QStandardPaths::findExecutable("visualvm"); + } + settings->registerSetting("JVisualVMPath", defaultValue); + globalSettings = settings; +} + +BaseExternalTool* JVisualVMFactory::createTool(BaseInstance* instance, QObject* parent) +{ + return new JVisualVM(globalSettings, instance, parent); +} + +bool JVisualVMFactory::check(QString* error) +{ + return check(globalSettings->get("JVisualVMPath").toString(), error); +} + +bool JVisualVMFactory::check(const QString& path, QString* error) +{ + if (path.isEmpty()) { + *error = QObject::tr("Empty path"); + return false; + } + QFileInfo finfo(path); + if (!finfo.isExecutable() || !finfo.fileName().contains("visualvm")) { + *error = QObject::tr("Invalid path to VisualVM"); + return false; + } + return true; +} + +#include "JVisualVM.moc" diff --git a/launcher/tools/JVisualVM.h b/launcher/tools/JVisualVM.h new file mode 100644 index 0000000..dfb09ca --- /dev/null +++ b/launcher/tools/JVisualVM.h @@ -0,0 +1,12 @@ +#pragma once + +#include "BaseProfiler.h" + +class JVisualVMFactory : public BaseProfilerFactory { + public: + QString name() const override { return "VisualVM"; } + void registerSettings(SettingsObject* settings) override; + BaseExternalTool* createTool(BaseInstance* instance, QObject* parent = 0) override; + bool check(QString* error) override; + bool check(const QString& path, QString* error) override; +}; diff --git a/launcher/tools/MCEditTool.cpp b/launcher/tools/MCEditTool.cpp new file mode 100644 index 0000000..12db12e --- /dev/null +++ b/launcher/tools/MCEditTool.cpp @@ -0,0 +1,69 @@ +#include "MCEditTool.h" + +#include +#include +#include + +#include "BaseInstance.h" +#include "minecraft/MinecraftInstance.h" +#include "settings/SettingsObject.h" + +MCEditTool::MCEditTool(SettingsObject* settings) +{ + settings->registerSetting("MCEditPath"); + m_settings = settings; +} + +void MCEditTool::setPath(QString& path) +{ + m_settings->set("MCEditPath", path); +} + +QString MCEditTool::path() const +{ + return m_settings->get("MCEditPath").toString(); +} + +bool MCEditTool::check(const QString& toolPath, QString& error) +{ + if (toolPath.isEmpty()) { + error = QObject::tr("Path is empty"); + return false; + } + const QDir dir(toolPath); + if (!dir.exists()) { + error = QObject::tr("Path does not exist"); + return false; + } + if (!dir.exists("mcedit.sh") && !dir.exists("mcedit.py") && !dir.exists("mcedit.exe") && !dir.exists("Contents") && + !dir.exists("mcedit2.exe")) { + error = QObject::tr("Path does not seem to be a MCEdit path"); + return false; + } + return true; +} + +QString MCEditTool::getProgramPath() +{ +#ifdef Q_OS_MACOS + return path(); +#else + const QString mceditPath = path(); + QDir mceditDir(mceditPath); +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) || defined(Q_OS_OPENBSD) + if (mceditDir.exists("mcedit.sh")) { + return mceditDir.absoluteFilePath("mcedit.sh"); + } else if (mceditDir.exists("mcedit.py")) { + return mceditDir.absoluteFilePath("mcedit.py"); + } + return QString(); +#elif defined(Q_OS_WIN32) + if (mceditDir.exists("mcedit.exe")) { + return mceditDir.absoluteFilePath("mcedit.exe"); + } else if (mceditDir.exists("mcedit2.exe")) { + return mceditDir.absoluteFilePath("mcedit2.exe"); + } + return QString(); +#endif +#endif +} diff --git a/launcher/tools/MCEditTool.h b/launcher/tools/MCEditTool.h new file mode 100644 index 0000000..edc9ffa --- /dev/null +++ b/launcher/tools/MCEditTool.h @@ -0,0 +1,16 @@ +#pragma once + +#include +#include "settings/SettingsObject.h" + +class MCEditTool { + public: + MCEditTool(SettingsObject* settings); + void setPath(QString& path); + QString path() const; + bool check(const QString& toolPath, QString& error); + QString getProgramPath(); + + private: + SettingsObject* m_settings; +}; diff --git a/launcher/translations/POTranslator.cpp b/launcher/translations/POTranslator.cpp new file mode 100644 index 0000000..458ebd2 --- /dev/null +++ b/launcher/translations/POTranslator.cpp @@ -0,0 +1,312 @@ +#include "POTranslator.h" + +#include +#include "FileSystem.h" + +struct POEntry { + QString text; + bool fuzzy; +}; + +struct POTranslatorPrivate { + QString filename; + QHash mapping; + QHash mapping_disambiguatrion; + bool loaded = false; + + void reload(); +}; + +class ParserArray : public QByteArray { + public: + ParserArray(const QByteArray& in) : QByteArray(in) {} + bool chomp(const char* data, int length) + { + if (startsWith(data)) { + remove(0, length); + return true; + } + return false; + } + bool chompString(QByteArray& appendHere) + { + QByteArray msg; + bool escape = false; + if (size() < 2) { + qDebug() << "String fragment is too short"; + return false; + } + if (!startsWith('"')) { + qDebug() << "String fragment does not start with \""; + return false; + } + if (!endsWith('"')) { + qDebug() << "String fragment does not end with \", instead, there is" << at(size() - 1); + return false; + } + for (int i = 1; i < size() - 1; i++) { + char c = operator[](i); + if (escape) { + switch (c) { + case 'r': + msg += '\r'; + break; + case 'n': + msg += '\n'; + break; + case 't': + msg += '\t'; + break; + case 'v': + msg += '\v'; + break; + case 'a': + msg += '\a'; + break; + case 'b': + msg += '\b'; + break; + case 'f': + msg += '\f'; + break; + case '"': + msg += '"'; + break; + case '\\': + msg.append('\\'); + break; + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': { + int octal_start = i; + while ((c = operator[](i)) >= '0' && c <= '7') { + i++; + if (i == length() - 1) { + qDebug() << "Something went bad while parsing an octal escape string..."; + return false; + } + } + msg += mid(octal_start, i - octal_start).toUInt(0, 8); + break; + } + case 'x': { + // chomp the 'x' + i++; + int hex_start = i; + while (isxdigit(operator[](i))) { + i++; + if (i == length() - 1) { + qDebug() << "Something went bad while parsing a hex escape string..."; + return false; + } + } + msg += mid(hex_start, i - hex_start).toUInt(0, 16); + break; + } + default: { + qDebug() << "Invalid escape sequence character:" << c; + return false; + } + } + escape = false; + } else if (c == '\\') { + escape = true; + } else { + msg += c; + } + } + if (escape) { + qDebug() << "Unterminated escape sequence..."; + return false; + } + appendHere += msg; + return true; + } +}; + +void POTranslatorPrivate::reload() +{ + QFile file(filename); + if (!file.open(QFile::OpenMode::enum_type::ReadOnly | QFile::OpenMode::enum_type::Text)) { + qDebug() << "Failed to open PO file:" << filename << "error:" << file.errorString(); + return; + } + + QByteArray context; + QByteArray disambiguation; + QByteArray id; + QByteArray str; + bool fuzzy = false; + bool nextFuzzy = false; + + enum class Mode { First, MessageContext, MessageId, MessageString } mode = Mode::First; + + int lineNumber = 0; + QHash newMapping; + QHash newMapping_disambiguation; + auto endEntry = [&]() { + auto strStr = QString::fromUtf8(str); + // NOTE: PO header has empty id. We skip it. + if (!id.isEmpty()) { + auto normalKey = context + "|" + id; + newMapping.insert(normalKey, { strStr, fuzzy }); + if (!disambiguation.isEmpty()) { + auto disambiguationKey = context + "|" + id + "@" + disambiguation; + newMapping_disambiguation.insert(disambiguationKey, { strStr, fuzzy }); + } + } + context.clear(); + disambiguation.clear(); + id.clear(); + str.clear(); + fuzzy = nextFuzzy; + nextFuzzy = false; + }; + while (!file.atEnd()) { + ParserArray line = file.readLine(); + if (line.endsWith('\n')) { + line.resize(line.size() - 1); + } + if (line.endsWith('\r')) { + line.resize(line.size() - 1); + } + + if (!line.size()) { + // NIL + } else if (line[0] == '#') { + if (line.contains(", fuzzy")) { + nextFuzzy = true; + } + } else if (line.startsWith('"')) { + QByteArray temp; + QByteArray* out = &temp; + + switch (mode) { + case Mode::First: + qDebug() << "Unexpected escaped string during initial state... line:" << lineNumber; + return; + case Mode::MessageString: + out = &str; + break; + case Mode::MessageContext: + out = &context; + break; + case Mode::MessageId: + out = &id; + break; + } + if (!line.chompString(*out)) { + qDebug() << "Badly formatted string on line:" << lineNumber; + return; + } + } else if (line.chomp("msgctxt ", 8)) { + switch (mode) { + case Mode::First: + break; + case Mode::MessageString: + endEntry(); + break; + case Mode::MessageContext: + case Mode::MessageId: + qDebug() << "Unexpected msgctxt line:" << lineNumber; + return; + } + if (line.chompString(context)) { + auto parts = context.split('|'); + context = parts[0]; + if (parts.size() > 1 && !parts[1].isEmpty()) { + disambiguation = parts[1]; + } + mode = Mode::MessageContext; + } + } else if (line.chomp("msgid ", 6)) { + switch (mode) { + case Mode::MessageContext: + case Mode::First: + break; + case Mode::MessageString: + endEntry(); + break; + case Mode::MessageId: + qDebug() << "Unexpected msgid line:" << lineNumber; + return; + } + if (line.chompString(id)) { + mode = Mode::MessageId; + } + } else if (line.chomp("msgstr ", 7)) { + switch (mode) { + case Mode::First: + case Mode::MessageString: + case Mode::MessageContext: + qDebug() << "Unexpected msgstr line:" << lineNumber; + return; + case Mode::MessageId: + break; + } + if (line.chompString(str)) { + mode = Mode::MessageString; + } + } else { + qDebug() << "I did not understand line:" << lineNumber << ":" << QString::fromUtf8(line); + } + lineNumber++; + } + endEntry(); + mapping = std::move(newMapping); + mapping_disambiguatrion = std::move(newMapping_disambiguation); + loaded = true; +} + +POTranslator::POTranslator(const QString& filename, QObject* parent) : QTranslator(parent) +{ + d = new POTranslatorPrivate; + d->filename = filename; + d->reload(); +} + +POTranslator::~POTranslator() +{ + delete d; +} + +QString POTranslator::translate(const char* context, const char* sourceText, const char* disambiguation, [[maybe_unused]] int n) const +{ + if (disambiguation) { + auto disambiguationKey = QByteArray(context) + "|" + QByteArray(sourceText) + "@" + QByteArray(disambiguation); + auto iter = d->mapping_disambiguatrion.find(disambiguationKey); + if (iter != d->mapping_disambiguatrion.end()) { + auto& entry = *iter; + if (entry.text.isEmpty()) { + qDebug() << "Translation entry has no content:" << disambiguationKey; + } + if (entry.fuzzy) { + qDebug() << "Translation entry is fuzzy:" << disambiguationKey << "->" << entry.text; + } + return entry.text; + } + } + auto key = QByteArray(context) + "|" + QByteArray(sourceText); + auto iter = d->mapping.find(key); + if (iter != d->mapping.end()) { + auto& entry = *iter; + if (entry.text.isEmpty()) { + qDebug() << "Translation entry has no content:" << key; + } + if (entry.fuzzy) { + qDebug() << "Translation entry is fuzzy:" << key << "->" << entry.text; + } + return entry.text; + } + return QString(); +} + +bool POTranslator::isEmpty() const +{ + return !d->loaded; +} diff --git a/launcher/translations/POTranslator.h b/launcher/translations/POTranslator.h new file mode 100644 index 0000000..58451e4 --- /dev/null +++ b/launcher/translations/POTranslator.h @@ -0,0 +1,17 @@ +#pragma once + +#include + +struct POTranslatorPrivate; + +class POTranslator : public QTranslator { + Q_OBJECT + public: + explicit POTranslator(const QString& filename, QObject* parent = nullptr); + virtual ~POTranslator(); + QString translate(const char* context, const char* sourceText, const char* disambiguation, int n) const override; + bool isEmpty() const override; + + private: + POTranslatorPrivate* d; +}; diff --git a/launcher/translations/TranslationsModel.cpp b/launcher/translations/TranslationsModel.cpp new file mode 100644 index 0000000..dea7241 --- /dev/null +++ b/launcher/translations/TranslationsModel.cpp @@ -0,0 +1,638 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "TranslationsModel.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "BuildConfig.h" +#include "FileSystem.h" +#include "Json.h" +#include "net/ChecksumValidator.h" +#include "net/NetJob.h" + +#include "POTranslator.h" + +#include "Application.h" +#include "settings/SettingsObject.h" + +const static QLatin1String defaultLangCode("en_US"); + +enum class FileType { NONE, QM, PO }; + +struct Language { + Language() { updated = true; } + Language(const QString& _key) + { + key = _key; + locale = QLocale(key); + updated = (key == defaultLangCode); + } + + QString languageName() const + { + QString result; + if (key == "ja_KANJI") { + result = locale.nativeLanguageName() + u8" (漢字)"; + } else if (key == "es_UY") { + result = u8"Español de Latinoamérica"; + } else if (key == "en_NZ") { + result = u8"New Zealand English"; // No idea why qt translates this to just english and not to New Zealand English + } else if (key == "en@pirate") { + result = u8"Tongue of the High Seas"; + } else if (key == "en@uwu") { + result = u8"Cute Engwish"; + } else if (key == "tok") { + result = u8"toki pona"; + } else if (key == "nan") { + result = u8"閩南語"; // Using traditional Chinese script. Not sure if we should use simplified instead? + } else { + result = locale.nativeLanguageName(); + } + + if (result.isEmpty()) { + result = key; + } + return result; + } + + float percentTranslated() const + { + if (total == 0) { + return 100.0f; + } + return 100.0f * float(translated) / float(total); + } + + void setTranslationStats(unsigned _translated, unsigned _untranslated, unsigned _fuzzy) + { + translated = _translated; + untranslated = _untranslated; + fuzzy = _fuzzy; + total = translated + untranslated + fuzzy; + } + + bool isOfSameNameAs(const Language& other) const { return key == other.key; } + + bool isIdenticalTo(const Language& other) const + { + return (key == other.key && file_name == other.file_name && file_size == other.file_size && file_sha1 == other.file_sha1 && + translated == other.translated && fuzzy == other.fuzzy && total == other.fuzzy && localFileType == other.localFileType); + } + + Language& apply(Language& other) + { + if (!isOfSameNameAs(other)) { + return *this; + } + file_name = other.file_name; + file_size = other.file_size; + file_sha1 = other.file_sha1; + translated = other.translated; + fuzzy = other.fuzzy; + total = other.total; + localFileType = other.localFileType; + return *this; + } + + QString key; + QLocale locale; + bool updated; + + QString file_name = QString(); + std::size_t file_size = 0; + QString file_sha1 = QString(); + + unsigned translated = 0; + unsigned untranslated = 0; + unsigned fuzzy = 0; + unsigned total = 0; + + FileType localFileType = FileType::NONE; +}; + +struct TranslationsModel::Private { + QDir m_dir; + + // initial state is just english + QList m_languages = { Language(defaultLangCode) }; + + QString m_selectedLanguage = defaultLangCode; + std::unique_ptr m_qt_translator; + std::unique_ptr m_app_translator; + + Net::Download* m_index_task; + QString m_downloadingTranslation; + NetJob::Ptr m_dl_job; + NetJob::Ptr m_index_job; + QString m_nextDownload; + + std::unique_ptr m_po_translator; + QFileSystemWatcher* watcher; + + const QString m_system_locale = QLocale::system().name(); + const QString m_system_language = m_system_locale.split('_').front(); + + bool no_language_set = false; +}; + +TranslationsModel::TranslationsModel(QString path, QObject* parent) : QAbstractListModel(parent) +{ + d.reset(new Private); + d->m_dir.setPath(path); + FS::ensureFolderPathExists(path); + reloadLocalFiles(); + + d->watcher = new QFileSystemWatcher(this); + connect(d->watcher, &QFileSystemWatcher::directoryChanged, this, &TranslationsModel::translationDirChanged); + d->watcher->addPath(d->m_dir.canonicalPath()); +} + +TranslationsModel::~TranslationsModel() {} + +void TranslationsModel::translationDirChanged(const QString& path) +{ + qDebug() << "Dir changed:" << path; + if (!d->no_language_set) { + reloadLocalFiles(); + } + selectLanguage(selectedLanguage()); +} + +void TranslationsModel::indexReceived() +{ + qDebug() << "Got translations index!"; + d->m_index_job.reset(); + + if (d->no_language_set) { + reloadLocalFiles(); + + auto language = d->m_system_locale; + if (!findLanguageAsOptional(language).has_value()) { + language = d->m_system_language; + } + selectLanguage(language); + if (selectedLanguage() != defaultLangCode) { + updateLanguage(selectedLanguage()); + } + APPLICATION->settings()->set("Language", selectedLanguage()); + d->no_language_set = false; + } + + else if (d->m_selectedLanguage != defaultLangCode) { + downloadTranslation(d->m_selectedLanguage); + } +} + +namespace { +void readIndex(const QString& path, QMap& languages) +{ + QByteArray data; + try { + data = FS::read(path); + } catch ([[maybe_unused]] const Exception& e) { + qCritical() << "Translations Download Failed: index file not readable"; + return; + } + + try { + auto toplevel_doc = Json::requireDocument(data); + auto doc = Json::requireObject(toplevel_doc); + auto file_type = Json::requireString(doc, "file_type"); + if (file_type != "MMC-TRANSLATION-INDEX") { + qCritical() << "Translations Download Failed: index file is of unknown file type" << file_type; + return; + } + auto version = Json::requireInteger(doc, "version"); + if (version > 2) { + qCritical() << "Translations Download Failed: index file is of unknown format version" << file_type; + return; + } + auto langObjs = Json::requireObject(doc, "languages"); + for (auto iter = langObjs.begin(); iter != langObjs.end(); iter++) { + Language lang(iter.key()); + + auto langObj = Json::requireObject(iter.value()); + lang.setTranslationStats(langObj["translated"].toInt(), langObj["untranslated"].toInt(), langObj["fuzzy"].toInt()); + lang.file_name = Json::requireString(langObj, "file"); + lang.file_sha1 = Json::requireString(langObj, "sha1"); + lang.file_size = Json::requireInteger(langObj, "size"); + + languages.insert(lang.key, lang); + } + } catch ([[maybe_unused]] Json::JsonException& e) { + qCritical() << "Translations Download Failed: index file could not be parsed as json"; + } +} +} // namespace + +void TranslationsModel::reloadLocalFiles() +{ + QMap languages = { { defaultLangCode, Language(defaultLangCode) } }; + + readIndex(d->m_dir.absoluteFilePath("index_v2.json"), languages); + auto entries = d->m_dir.entryInfoList({ "mmc_*.qm", "*.po" }, QDir::Files | QDir::NoDotAndDotDot); + for (auto& entry : entries) { + auto completeSuffix = entry.completeSuffix(); + QString langCode; + FileType fileType = FileType::NONE; + if (completeSuffix == "qm") { + langCode = entry.baseName().remove(0, 4); + fileType = FileType::QM; + } else if (completeSuffix == "po") { + langCode = entry.baseName(); + fileType = FileType::PO; + } else { + continue; + } + + auto langIter = languages.find(langCode); + if (langIter != languages.end()) { + auto& language = *langIter; + if (int(fileType) > int(language.localFileType)) { + language.localFileType = fileType; + } + } else { + if (fileType == FileType::PO) { + Language localFound(langCode); + localFound.localFileType = FileType::PO; + languages.insert(langCode, localFound); + } + } + } + + // changed and removed languages + for (auto iter = d->m_languages.begin(); iter != d->m_languages.end();) { + auto& language = *iter; + auto row = iter - d->m_languages.begin(); + + auto updatedLanguageIter = languages.find(language.key); + if (updatedLanguageIter != languages.end()) { + if (language.isIdenticalTo(*updatedLanguageIter)) { + languages.remove(language.key); + } else { + language.apply(*updatedLanguageIter); + emit dataChanged(index(row), index(row)); + languages.remove(language.key); + } + iter++; + } else { + beginRemoveRows(QModelIndex(), row, row); + iter = d->m_languages.erase(iter); + endRemoveRows(); + } + } + // added languages + if (languages.isEmpty()) { + return; + } + beginInsertRows(QModelIndex(), 0, d->m_languages.size() + languages.size() - 1); + for (auto& language : languages) { + d->m_languages.append(language); + } + std::sort(d->m_languages.begin(), d->m_languages.end(), [this](const Language& a, const Language& b) { + if (a.key != b.key) { + if (a.key == d->m_system_locale || a.key == d->m_system_language) { + return true; + } + if (b.key == d->m_system_locale || b.key == d->m_system_language) { + return false; + } + } + return a.languageName().toLower() < b.languageName().toLower(); + }); + endInsertRows(); +} + +namespace { +enum class Column { Language, Completeness }; +} + +QVariant TranslationsModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid()) + return QVariant(); + + int row = index.row(); + auto column = static_cast(index.column()); + + if (row < 0 || row >= d->m_languages.size()) + return QVariant(); + + auto& lang = d->m_languages[row]; + switch (role) { + case Qt::DisplayRole: { + switch (column) { + case Column::Language: { + return lang.languageName(); + } + case Column::Completeness: { + return QString("%1%").arg(lang.percentTranslated(), 3, 'f', 1); + } + } + qWarning("TranslationModel::data not implemented when role is DisplayRole"); + } + case Qt::ToolTipRole: { + return tr("%1:\n%2 translated\n%3 fuzzy\n%4 total") + .arg(lang.key, QString::number(lang.translated), QString::number(lang.fuzzy), QString::number(lang.total)); + } + case Qt::UserRole: + return lang.key; + default: + return QVariant(); + } +} + +QVariant TranslationsModel::headerData(int section, Qt::Orientation orientation, int role) const +{ + auto column = static_cast(section); + if (role == Qt::DisplayRole) { + switch (column) { + case Column::Language: { + return tr("Language"); + } + case Column::Completeness: { + return tr("Completeness"); + } + } + } else if (role == Qt::ToolTipRole) { + switch (column) { + case Column::Language: { + return tr("The native language name."); + } + case Column::Completeness: { + return tr("Completeness is the percentage of fully translated strings, not counting automatically guessed ones."); + } + } + } + return QAbstractListModel::headerData(section, orientation, role); +} + +int TranslationsModel::rowCount([[maybe_unused]] const QModelIndex& parent) const +{ + return d->m_languages.size(); +} + +int TranslationsModel::columnCount([[maybe_unused]] const QModelIndex& parent) const +{ + return 2; +} + +QList::Iterator TranslationsModel::findLanguage(const QString& key) +{ + return std::find_if(d->m_languages.begin(), d->m_languages.end(), [key](Language& lang) { return lang.key == key; }); +} + +std::optional TranslationsModel::findLanguageAsOptional(const QString& key) +{ + auto found = findLanguage(key); + if (found != d->m_languages.end()) + return *found; + return {}; +} + +void TranslationsModel::setUseSystemLocale(bool useSystemLocale) +{ + APPLICATION->settings()->set("UseSystemLocale", useSystemLocale); + QLocale::setDefault(QLocale(useSystemLocale ? QString::fromStdString(std::locale().name()) : defaultLangCode)); +} + +bool TranslationsModel::selectLanguage(QString key) +{ + QString& langCode = key; + auto langPtr = findLanguageAsOptional(key); + + if (langCode.isEmpty()) { + d->no_language_set = true; + } + + if (!langPtr.has_value()) { + qWarning() << "Selected invalid language" << key << ", defaulting to" << defaultLangCode; + langCode = defaultLangCode; + } else { + langCode = langPtr->key; + } + + // uninstall existing translators if there are any + if (d->m_app_translator) { + QCoreApplication::removeTranslator(d->m_app_translator.get()); + d->m_app_translator.reset(); + } + if (d->m_qt_translator) { + QCoreApplication::removeTranslator(d->m_qt_translator.get()); + d->m_qt_translator.reset(); + } + + /* + * FIXME: potential source of crashes: + * In a multithreaded application, the default locale should be set at application startup, before any non-GUI threads are created. + * This function is not reentrant. + */ + QLocale::setDefault( + QLocale(APPLICATION->settings()->get("UseSystemLocale").toBool() ? QString::fromStdString(std::locale().name()) : langCode)); + + // if it's the default UI language, finish + if (langCode == defaultLangCode) { + d->m_selectedLanguage = langCode; + return true; + } + + // otherwise install new translations + bool successful = false; + // FIXME: this is likely never present. FIX IT. + d->m_qt_translator.reset(new QTranslator()); + if (d->m_qt_translator->load("qt_" + langCode, QLibraryInfo::path(QLibraryInfo::TranslationsPath))) { + qDebug() << "Loading Qt Language File for" << langCode.toLocal8Bit().constData() << "..."; + if (!QCoreApplication::installTranslator(d->m_qt_translator.get())) { + qCritical() << "Loading Qt Language File failed."; + d->m_qt_translator.reset(); + } else { + successful = true; + } + } else { + d->m_qt_translator.reset(); + } + + if (langPtr->localFileType == FileType::PO) { + qDebug() << "Loading Application Language File for" << langCode.toLocal8Bit().constData() << "..."; + auto poTranslator = new POTranslator(FS::PathCombine(d->m_dir.path(), langCode + ".po")); + if (!poTranslator->isEmpty()) { + if (!QCoreApplication::installTranslator(poTranslator)) { + delete poTranslator; + qCritical() << "Installing Application Language File failed."; + } else { + d->m_app_translator.reset(poTranslator); + successful = true; + } + } else { + qCritical() << "Loading Application Language File failed."; + d->m_app_translator.reset(); + } + } else if (langPtr->localFileType == FileType::QM) { + d->m_app_translator.reset(new QTranslator()); + if (d->m_app_translator->load("mmc_" + langCode, d->m_dir.path())) { + qDebug() << "Loading Application Language File for" << langCode.toLocal8Bit().constData() << "..."; + if (!QCoreApplication::installTranslator(d->m_app_translator.get())) { + qCritical() << "Installing Application Language File failed."; + d->m_app_translator.reset(); + } else { + successful = true; + } + } else { + d->m_app_translator.reset(); + } + } else { + d->m_app_translator.reset(); + } + d->m_selectedLanguage = langCode; + return successful; +} + +QModelIndex TranslationsModel::selectedIndex() +{ + auto found = findLanguage(d->m_selectedLanguage); + if (found != d->m_languages.end()) { + return index(std::distance(d->m_languages.begin(), found), 0, QModelIndex()); + } + return QModelIndex(); +} + +QString TranslationsModel::selectedLanguage() +{ + return d->m_selectedLanguage; +} + +void TranslationsModel::downloadIndex() +{ + if (d->m_index_job || d->m_dl_job) { + return; + } + qDebug() << "Downloading Translations Index..."; + d->m_index_job.reset(new NetJob("Translations Index", APPLICATION->network())); + MetaEntryPtr entry = APPLICATION->metacache()->resolveEntry("translations", "index_v2.json"); + entry->setStale(true); + auto task = Net::Download::makeCached(QUrl(BuildConfig.TRANSLATION_FILES_URL + "index_v2.json"), entry); + d->m_index_task = task.get(); + d->m_index_job->addNetAction(task); + d->m_index_job->setAskRetry(false); + connect(d->m_index_job.get(), &NetJob::failed, this, &TranslationsModel::indexFailed); + connect(d->m_index_job.get(), &NetJob::succeeded, this, &TranslationsModel::indexReceived); + d->m_index_job->start(); +} + +void TranslationsModel::updateLanguage(QString key) +{ + if (key == defaultLangCode) { + qWarning() << "Cannot update builtin language" << key; + return; + } + auto found = findLanguageAsOptional(key); + if (!found.has_value()) { + qWarning() << "Cannot update invalid language" << key; + return; + } + if (!found->updated) { + downloadTranslation(key); + } +} + +void TranslationsModel::downloadTranslation(QString key) +{ + if (d->m_dl_job) { + d->m_nextDownload = key; + return; + } + auto lang = findLanguageAsOptional(key); + if (!lang.has_value()) { + qWarning() << "Will not download an unknown translation" << key; + return; + } + + d->m_downloadingTranslation = key; + MetaEntryPtr entry = APPLICATION->metacache()->resolveEntry("translations", "mmc_" + key + ".qm"); + entry->setStale(true); + + auto dl = Net::Download::makeCached(QUrl(BuildConfig.TRANSLATION_FILES_URL + lang->file_name), entry); + dl->addValidator(new Net::ChecksumValidator(QCryptographicHash::Sha1, lang->file_sha1)); + dl->setProgress(dl->getProgress(), lang->file_size); + + d->m_dl_job.reset(new NetJob("Translation for " + key, APPLICATION->network())); + d->m_dl_job->addNetAction(dl); + d->m_dl_job->setAskRetry(false); + + connect(d->m_dl_job.get(), &NetJob::succeeded, this, &TranslationsModel::dlGood); + connect(d->m_dl_job.get(), &NetJob::failed, this, &TranslationsModel::dlFailed); + + d->m_dl_job->start(); +} + +void TranslationsModel::downloadNext() +{ + if (!d->m_nextDownload.isEmpty()) { + downloadTranslation(d->m_nextDownload); + d->m_nextDownload.clear(); + } +} + +void TranslationsModel::dlFailed(QString reason) +{ + qCritical() << "Translations Download Failed:" << reason; + d->m_dl_job.reset(); + downloadNext(); +} + +void TranslationsModel::dlGood() +{ + qDebug() << "Got translation:" << d->m_downloadingTranslation; + + if (d->m_downloadingTranslation == d->m_selectedLanguage) { + selectLanguage(d->m_selectedLanguage); + } + d->m_dl_job.reset(); + downloadNext(); +} + +void TranslationsModel::indexFailed(QString reason) +{ + qCritical() << "Translations Index Download Failed:" << reason; + d->m_index_job.reset(); +} diff --git a/launcher/translations/TranslationsModel.h b/launcher/translations/TranslationsModel.h new file mode 100644 index 0000000..945e689 --- /dev/null +++ b/launcher/translations/TranslationsModel.h @@ -0,0 +1,65 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +struct Language; + +class TranslationsModel : public QAbstractListModel { + Q_OBJECT + public: + explicit TranslationsModel(QString path, QObject* parent = 0); + virtual ~TranslationsModel(); + + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QVariant headerData(int section, Qt::Orientation orientation, int role) const override; + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + int columnCount(const QModelIndex& parent) const override; + + bool selectLanguage(QString key); + void updateLanguage(QString key); + QModelIndex selectedIndex(); + QString selectedLanguage(); + + void downloadIndex(); + void setUseSystemLocale(bool useSystemLocale); + + private: + QList::Iterator findLanguage(const QString& key); + std::optional findLanguageAsOptional(const QString& key); + void reloadLocalFiles(); + void downloadTranslation(QString key); + void downloadNext(); + + // hide copy constructor + TranslationsModel(const TranslationsModel&) = delete; + // hide assign op + TranslationsModel& operator=(const TranslationsModel&) = delete; + + private slots: + void indexReceived(); + void indexFailed(QString reason); + void dlFailed(QString reason); + void dlGood(); + void translationDirChanged(const QString& path); + + private: /* data */ + struct Private; + std::unique_ptr d; +}; diff --git a/launcher/ui/GuiUtil.cpp b/launcher/ui/GuiUtil.cpp new file mode 100644 index 0000000..141153b --- /dev/null +++ b/launcher/ui/GuiUtil.cpp @@ -0,0 +1,241 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Lenny McLennington + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2022 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "GuiUtil.h" + +#include +#include +#include +#include +#include + +#include "FileSystem.h" +#include "logs/AnonymizeLog.h" +#include "net/NetJob.h" +#include "net/NetRequest.h" +#include "net/PasteUpload.h" +#include "ui/dialogs/CustomMessageBox.h" +#include "ui/dialogs/ProgressDialog.h" + +#include +#include +#include +#include "Application.h" + +constexpr int MaxMclogsLines = 25000; +constexpr int InitialMclogsLines = 10000; +constexpr int FinalMclogsLines = 14900; + +QString truncateLogForMclogs(const QString& logContent) +{ + QStringList lines = logContent.split("\n"); + if (lines.size() > MaxMclogsLines) { + QString truncatedLog = lines.mid(0, InitialMclogsLines).join("\n"); + truncatedLog += + "\n\n\n\n\n\n\n\n\n\n" + "------------------------------------------------------------\n" + "----------------------- Log truncated ----------------------\n" + "------------------------------------------------------------\n" + "----- Middle portion omitted to fit mclo.gs size limits ----\n" + "------------------------------------------------------------\n" + "\n\n\n\n\n\n\n\n\n\n"; + truncatedLog += lines.mid(lines.size() - FinalMclogsLines - 1).join("\n"); + return truncatedLog; + } + return logContent; +} + +std::optional GuiUtil::uploadPaste(const QString& name, const QFileInfo& filePath, QWidget* parentWidget) +{ + return uploadPaste(name, FS::read(filePath.absoluteFilePath()), parentWidget); +}; + +std::optional GuiUtil::uploadPaste(const QString& name, const QString& text, QWidget* parentWidget) +{ + ProgressDialog dialog(parentWidget); + auto pasteType = static_cast(APPLICATION->settings()->get("PastebinType").toInt()); + auto baseURL = APPLICATION->settings()->get("PastebinCustomAPIBase").toString(); + bool shouldTruncate = false; + + if (baseURL.isEmpty()) + baseURL = PasteUpload::PasteTypes[pasteType].defaultBase; + + if (auto url = QUrl(baseURL); url.isValid()) { + auto response = CustomMessageBox::selectable(parentWidget, QObject::tr("Confirm Upload"), + QObject::tr("You are about to upload \"%1\" to %2.\n" + "You should double-check for personal information.\n\n" + "Are you sure?") + .arg(name, url.host()), + QMessageBox::Warning, QMessageBox::Yes | QMessageBox::No, QMessageBox::No) + ->exec(); + + if (response != QMessageBox::Yes) + return {}; + + if (baseURL == "https://api.mclo.gs" && text.count("\n") > MaxMclogsLines) { + auto truncateResponse = CustomMessageBox::selectable( + parentWidget, QObject::tr("Confirm Truncation"), + QObject::tr("The log has %1 lines, exceeding mclo.gs' limit of %2.\n" + "The launcher can keep the first %3 and last %4 lines, trimming the middle.\n\n" + "If you choose 'No', mclo.gs will only keep the first %2 lines, cutting off " + "potentially useful info like crashes at the end.\n\n" + "Proceed with truncation?") + .arg(text.count("\n")) + .arg(MaxMclogsLines) + .arg(InitialMclogsLines) + .arg(FinalMclogsLines), + QMessageBox::Warning, QMessageBox::Yes | QMessageBox::No | QMessageBox::Cancel, QMessageBox::No) + ->exec(); + + if (truncateResponse == QMessageBox::Cancel) { + return {}; + } + shouldTruncate = truncateResponse == QMessageBox::Yes; + } + } + + QString textToUpload = text; + if (shouldTruncate) { + textToUpload = truncateLogForMclogs(text); + } + + auto job = NetJob::Ptr(new NetJob("Log Upload", APPLICATION->network())); + + auto pasteJob = new PasteUpload(textToUpload, baseURL, pasteType); + job->addNetAction(Net::NetRequest::Ptr(pasteJob)); + QObject::connect(job.get(), &Task::failed, [parentWidget](QString reason) { + CustomMessageBox::selectable(parentWidget, QObject::tr("Failed to upload logs!"), reason, QMessageBox::Critical)->show(); + }); + QObject::connect(job.get(), &Task::aborted, [parentWidget] { + CustomMessageBox::selectable(parentWidget, QObject::tr("Logs upload aborted"), + QObject::tr("The task has been aborted by the user."), QMessageBox::Information) + ->show(); + }); + + if (dialog.execWithTask(job.get()) == QDialog::Accepted) { + if (pasteJob->pasteLink().isEmpty()) { + CustomMessageBox::selectable(parentWidget, QObject::tr("Failed to upload logs!"), "The upload link is empty", + QMessageBox::Critical) + ->show(); + return {}; + } + setClipboardText(pasteJob->pasteLink()); + CustomMessageBox::selectable( + parentWidget, QObject::tr("Upload finished"), + QObject::tr("The link to the uploaded log has been placed in your clipboard.").arg(pasteJob->pasteLink()), + QMessageBox::Information) + ->exec(); + return pasteJob->pasteLink(); + } + return {}; +} + +void GuiUtil::setClipboardText(QString text) +{ + anonymizeLog(text); + QApplication::clipboard()->setText(text); +} + +static QStringList BrowseForFileInternal(QString context, + QString caption, + QString filter, + QString defaultPath, + QWidget* parentWidget, + bool single) +{ + static QMap savedPaths; + + QFileDialog w(parentWidget, caption); + QSet locations; + auto f = [&locations](QStandardPaths::StandardLocation l) { + QString location = QStandardPaths::writableLocation(l); + QFileInfo finfo(location); + if (!finfo.exists()) { + return; + } + locations.insert(location); + }; + f(QStandardPaths::DesktopLocation); + f(QStandardPaths::DocumentsLocation); + f(QStandardPaths::DownloadLocation); + f(QStandardPaths::HomeLocation); + QList urls; + for (auto location : locations) { + urls.append(QUrl::fromLocalFile(location)); + } + urls.append(QUrl::fromLocalFile(defaultPath)); + + w.setFileMode(single ? QFileDialog::ExistingFile : QFileDialog::ExistingFiles); + w.setAcceptMode(QFileDialog::AcceptOpen); + w.setNameFilter(filter); + + QString pathToOpen; + if (savedPaths.contains(context)) { + pathToOpen = savedPaths[context]; + } else { + pathToOpen = defaultPath; + } + if (!pathToOpen.isEmpty()) { + QFileInfo finfo(pathToOpen); + if (finfo.exists() && finfo.isDir()) { + w.setDirectory(finfo.absoluteFilePath()); + } + } + + w.setSidebarUrls(urls); + + if (w.exec()) { + savedPaths[context] = w.directory().absolutePath(); + return w.selectedFiles(); + } + savedPaths[context] = w.directory().absolutePath(); + return {}; +} + +QString GuiUtil::BrowseForFile(QString context, QString caption, QString filter, QString defaultPath, QWidget* parentWidget) +{ + auto resultList = BrowseForFileInternal(context, caption, filter, defaultPath, parentWidget, true); + if (resultList.size()) { + return resultList[0]; + } + return QString(); +} + +QStringList GuiUtil::BrowseForFiles(QString context, QString caption, QString filter, QString defaultPath, QWidget* parentWidget) +{ + return BrowseForFileInternal(context, caption, filter, defaultPath, parentWidget, false); +} diff --git a/launcher/ui/GuiUtil.h b/launcher/ui/GuiUtil.h new file mode 100644 index 0000000..c3ba01f --- /dev/null +++ b/launcher/ui/GuiUtil.h @@ -0,0 +1,13 @@ +#pragma once + +#include +#include +#include + +namespace GuiUtil { +std::optional uploadPaste(const QString& name, const QFileInfo& filePath, QWidget* parentWidget); +std::optional uploadPaste(const QString& name, const QString& data, QWidget* parentWidget); +void setClipboardText(QString text); +QStringList BrowseForFiles(QString context, QString caption, QString filter, QString defaultPath, QWidget* parentWidget); +QString BrowseForFile(QString context, QString caption, QString filter, QString defaultPath, QWidget* parentWidget); +} // namespace GuiUtil diff --git a/launcher/ui/InstanceWindow.cpp b/launcher/ui/InstanceWindow.cpp new file mode 100644 index 0000000..a164351 --- /dev/null +++ b/launcher/ui/InstanceWindow.cpp @@ -0,0 +1,230 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "InstanceWindow.h" +#include "Application.h" + +#include +#include +#include +#include +#include + +#include "ui/widgets/PageContainer.h" + +#include "InstancePageProvider.h" + +#include "icons/IconList.h" + +InstanceWindow::InstanceWindow(BaseInstance* instance, QWidget* parent) : QMainWindow(parent), m_instance(instance) +{ + setAttribute(Qt::WA_DeleteOnClose); + + auto icon = APPLICATION->icons()->getIcon(m_instance->iconKey()); + QString windowTitle = tr("Console window for ") + m_instance->name(); + + // Set window properties + { + setWindowIcon(icon); + setWindowTitle(windowTitle); + } + + // Add page container + { + auto provider = std::make_shared(m_instance); + m_container = new PageContainer(provider.get(), "console", this); + m_container->setParentContainer(this); + setCentralWidget(m_container); + setContentsMargins(0, 0, 0, 0); + } + + // Add custom buttons to the page container layout. + { + auto horizontalLayout = new QHBoxLayout(this); + horizontalLayout->setObjectName(QStringLiteral("horizontalLayout")); + horizontalLayout->setContentsMargins(0, 0, 6, 6); + + auto btnHelp = new QPushButton(this); + btnHelp->setText(tr("Help")); + horizontalLayout->addWidget(btnHelp); + connect(btnHelp, &QPushButton::clicked, m_container, &PageContainer::help); + + auto spacer = new QSpacerItem(40, 20, QSizePolicy::Expanding, QSizePolicy::Minimum); + horizontalLayout->addSpacerItem(spacer); + + m_launchButton = new QToolButton(this); + m_launchButton->setText(tr("&Launch")); + m_launchButton->setToolTip(tr("Launch the instance")); + m_launchButton->setPopupMode(QToolButton::MenuButtonPopup); + m_launchButton->setMinimumWidth(80); // HACK!! + horizontalLayout->addWidget(m_launchButton); + connect(m_launchButton, &QPushButton::clicked, this, [this] { APPLICATION->launch(m_instance); }); + + m_killButton = new QPushButton(this); + m_killButton->setText(tr("&Kill")); + m_killButton->setToolTip(tr("Kill the running instance")); + m_killButton->setShortcut(QKeySequence(tr("Ctrl+K"))); + horizontalLayout->addWidget(m_killButton); + connect(m_killButton, &QPushButton::clicked, this, [this] { APPLICATION->kill(m_instance); }); + + updateButtons(); + + m_closeButton = new QPushButton(this); + m_closeButton->setText(tr("Close")); + horizontalLayout->addWidget(m_closeButton); + connect(m_closeButton, &QPushButton::clicked, this, &QMainWindow::close); + + m_container->addButtons(horizontalLayout); + + connect(m_instance, &BaseInstance::profilerChanged, this, &InstanceWindow::updateButtons); + connect(APPLICATION, &Application::globalSettingsApplied, this, &InstanceWindow::updateButtons); + } + + // restore window state + { + auto base64State = APPLICATION->settings()->get("ConsoleWindowState").toString().toUtf8(); + restoreState(QByteArray::fromBase64(base64State)); + auto base64Geometry = APPLICATION->settings()->get("ConsoleWindowGeometry").toString().toUtf8(); + restoreGeometry(QByteArray::fromBase64(base64Geometry)); + } + + // set up instance and launch process recognition + { + auto launchTask = m_instance->getLaunchTask(); + instanceLaunchTaskChanged(launchTask); + connect(m_instance, &BaseInstance::launchTaskChanged, this, &InstanceWindow::instanceLaunchTaskChanged); + connect(m_instance, &BaseInstance::runningStatusChanged, this, &InstanceWindow::runningStateChanged); + } + + // set up instance destruction detection + { + connect(m_instance, &BaseInstance::statusChanged, this, &InstanceWindow::on_instanceStatusChanged); + } + + // add ourself as the modpack page's instance window + { + static_cast(m_container->getPage("managed_pack"))->setInstanceWindow(this); + } + + show(); +} + +void InstanceWindow::on_instanceStatusChanged(BaseInstance::Status, BaseInstance::Status newStatus) +{ + if (newStatus == BaseInstance::Status::Gone) { + m_doNotSave = true; + close(); + } +} + +void InstanceWindow::updateButtons() +{ + m_launchButton->setEnabled(m_instance->canLaunch()); + m_killButton->setEnabled(m_instance->isRunning()); + + QMenu* launchMenu = m_launchButton->menu(); + if (launchMenu) + launchMenu->clear(); + else + launchMenu = new QMenu(this); + m_instance->populateLaunchMenu(launchMenu); + m_launchButton->setMenu(launchMenu); +} + +void InstanceWindow::instanceLaunchTaskChanged(LaunchTask* proc) +{ + m_proc = proc; +} + +void InstanceWindow::runningStateChanged(bool running) +{ + updateButtons(); + m_container->refreshContainer(); + if (running) { + selectPage("log"); + } +} + +void InstanceWindow::closeEvent(QCloseEvent* event) +{ + bool proceed = true; + if (!m_doNotSave) { + proceed &= m_container->prepareToClose(); + } + + if (!proceed) { + return; + } + + APPLICATION->settings()->set("ConsoleWindowState", QString::fromUtf8(saveState().toBase64())); + APPLICATION->settings()->set("ConsoleWindowGeometry", QString::fromUtf8(saveGeometry().toBase64())); + emit isClosing(); + event->accept(); +} + +bool InstanceWindow::saveAll() +{ + return m_container->saveAll(); +} + +QString InstanceWindow::instanceId() +{ + return m_instance->id(); +} + +bool InstanceWindow::selectPage(QString pageId) +{ + return m_container->selectPage(pageId); +} + +void InstanceWindow::refreshContainer() +{ + m_container->refreshContainer(); +} + +BasePage* InstanceWindow::selectedPage() const +{ + return m_container->selectedPage(); +} + +bool InstanceWindow::requestClose() +{ + if (m_container->prepareToClose()) { + close(); + return true; + } + return false; +} diff --git a/launcher/ui/InstanceWindow.h b/launcher/ui/InstanceWindow.h new file mode 100644 index 0000000..7f66a8b --- /dev/null +++ b/launcher/ui/InstanceWindow.h @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +#include "LaunchController.h" +#include "launch/LaunchTask.h" + +#include "ui/pages/BasePageContainer.h" + +#include "QObjectPtr.h" + +class QPushButton; +class PageContainer; +class InstanceWindow : public QMainWindow, public BasePageContainer { + Q_OBJECT + + public: + explicit InstanceWindow(BaseInstance* proc, QWidget* parent = 0); + virtual ~InstanceWindow() = default; + + bool selectPage(QString pageId) override; + BasePage* selectedPage() const override; + void refreshContainer() override; + + QString instanceId(); + + // save all settings and changes (prepare for launch) + bool saveAll(); + + // request closing the window (from a page) + bool requestClose() override; + + signals: + void isClosing(); + + private slots: + void instanceLaunchTaskChanged(LaunchTask* proc); + void runningStateChanged(bool running); + void on_instanceStatusChanged(BaseInstance::Status, BaseInstance::Status newStatus); + + protected: + void closeEvent(QCloseEvent*) override; + + private: + void updateButtons(); + + private: + LaunchTask* m_proc; + BaseInstance* m_instance; + bool m_doNotSave = false; + PageContainer* m_container = nullptr; + QPushButton* m_closeButton = nullptr; + QToolButton* m_launchButton = nullptr; + QPushButton* m_killButton = nullptr; +}; diff --git a/launcher/ui/MainWindow.cpp b/launcher/ui/MainWindow.cpp new file mode 100644 index 0000000..f009f3c --- /dev/null +++ b/launcher/ui/MainWindow.cpp @@ -0,0 +1,1779 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Authors: Andrew Okin + * Peterix + * Orochimarufan + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "Application.h" +#include "BuildConfig.h" +#include "FileSystem.h" + +#include "MainWindow.h" +#include "ui_MainWindow.h" + +#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 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "InstanceWindow.h" + +#include "ui/GuiUtil.h" +#include "ui/ViewLogWindow.h" +#include "ui/dialogs/AboutDialog.h" +#include "ui/dialogs/ChooseOfflineNameDialog.h" +#include "ui/dialogs/CopyInstanceDialog.h" +#include "ui/dialogs/CreateShortcutDialog.h" +#include "ui/dialogs/CustomMessageBox.h" +#include "ui/dialogs/ExportInstanceDialog.h" +#include "ui/dialogs/ExportPackDialog.h" +#include "ui/dialogs/IconPickerDialog.h" +#include "ui/dialogs/ImportResourceDialog.h" +#include "ui/dialogs/NewInstanceDialog.h" +#include "ui/dialogs/NewsDialog.h" +#include "ui/dialogs/ProgressDialog.h" +#include "ui/instanceview/InstanceDelegate.h" +#include "ui/instanceview/InstanceProxyModel.h" +#include "ui/instanceview/InstanceView.h" +#include "ui/themes/ITheme.h" +#include "ui/themes/ThemeManager.h" +#include "ui/widgets/LabeledToolButton.h" + +#include "minecraft/PackProfile.h" +#include "minecraft/VersionFile.h" +#include "minecraft/WorldList.h" +#include "minecraft/mod/ModFolderModel.h" +#include "minecraft/mod/ResourcePackFolderModel.h" +#include "minecraft/mod/ShaderPackFolderModel.h" +#include "minecraft/mod/TexturePackFolderModel.h" +#include "minecraft/mod/tasks/LocalResourceParse.h" + +#include "modplatform/ModIndex.h" +#include "modplatform/flame/FlameAPI.h" +#include "modplatform/flame/FlameModIndex.h" + +#include "KonamiCode.h" + +#include "InstanceCopyTask.h" +#include "InstanceDirUpdate.h" + +#include "Json.h" + +#include "MMCTime.h" + +namespace { +QString profileInUseFilter(const QString& profile, bool used) +{ + if (used) { + return QObject::tr("%1 (in use)").arg(profile); + } else { + return profile; + } +} +} // namespace + +MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent), ui(new Ui::MainWindow) +{ + ui->setupUi(this); + + setWindowIcon(APPLICATION->logo()); + setWindowTitle(APPLICATION->applicationDisplayName()); +#ifndef QT_NO_ACCESSIBILITY + setAccessibleName(BuildConfig.LAUNCHER_DISPLAYNAME); +#endif + + // instance toolbar stuff + { + // Qt doesn't like vertical moving toolbars, so we have to force them... + // See https://github.com/PolyMC/PolyMC/issues/493 + connect(ui->instanceToolBar, &QToolBar::orientationChanged, + [this](Qt::Orientation) { ui->instanceToolBar->setOrientation(Qt::Vertical); }); + + // if you try to add a widget to a toolbar in a .ui file + // qt designer will delete it when you save the file >:( + changeIconButton = new LabeledToolButton(this); + changeIconButton->setObjectName(QStringLiteral("changeIconButton")); + changeIconButton->setIcon(QIcon::fromTheme("news")); + changeIconButton->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); + connect(changeIconButton, &QToolButton::clicked, this, &MainWindow::on_actionChangeInstIcon_triggered); + ui->instanceToolBar->insertWidgetBefore(ui->actionLaunchInstance, changeIconButton); + + renameButton = new LabeledToolButton(this); + renameButton->setObjectName(QStringLiteral("renameButton")); + renameButton->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); + connect(renameButton, &QToolButton::clicked, this, &MainWindow::on_actionRenameInstance_triggered); + ui->instanceToolBar->insertWidgetBefore(ui->actionLaunchInstance, renameButton); + + ui->instanceToolBar->insertSeparator(ui->actionLaunchInstance); + + // restore the instance toolbar settings + auto const setting_name = QString("WideBarVisibility_%1").arg(ui->instanceToolBar->objectName()); + instanceToolbarSetting = APPLICATION->settings()->getOrRegisterSetting(setting_name); + + ui->instanceToolBar->setVisibilityState(QByteArray::fromBase64(instanceToolbarSetting->get().toString().toUtf8())); + + ui->instanceToolBar->addContextMenuAction(ui->newsToolBar->toggleViewAction()); + ui->instanceToolBar->addContextMenuAction(ui->instanceToolBar->toggleViewAction()); + ui->instanceToolBar->addContextMenuAction(ui->actionToggleStatusBar); + ui->instanceToolBar->addContextMenuAction(ui->actionLockToolbars); + } + + // set the menu for the folders help, accounts, and export tool buttons + { + auto foldersMenuButton = dynamic_cast(ui->mainToolBar->widgetForAction(ui->actionFoldersButton)); + ui->actionFoldersButton->setMenu(ui->foldersMenu); + foldersMenuButton->setPopupMode(QToolButton::InstantPopup); + + helpMenuButton = dynamic_cast(ui->mainToolBar->widgetForAction(ui->actionHelpButton)); + ui->actionHelpButton->setMenu(new QMenu(this)); + ui->actionHelpButton->menu()->addActions(ui->helpMenu->actions()); + ui->actionHelpButton->menu()->removeAction(ui->actionCheckUpdate); + helpMenuButton->setPopupMode(QToolButton::InstantPopup); + + auto accountMenuButton = dynamic_cast(ui->mainToolBar->widgetForAction(ui->actionAccountsButton)); + accountMenuButton->setPopupMode(QToolButton::InstantPopup); + + auto exportInstanceMenu = new QMenu(this); + exportInstanceMenu->addAction(ui->actionExportInstanceZip); + exportInstanceMenu->addAction(ui->actionExportInstanceMrPack); + exportInstanceMenu->addAction(ui->actionExportInstanceFlamePack); + ui->actionExportInstance->setMenu(exportInstanceMenu); + } + + // hide, disable and show stuff + { + ui->actionReportBug->setVisible(!BuildConfig.BUG_TRACKER_URL.isEmpty()); + + ui->actionCheckUpdate->setVisible(APPLICATION->updaterEnabled()); + +#ifndef Q_OS_MAC + ui->actionAddToPATH->setVisible(false); +#endif + + // disabled until we have an instance selected + ui->instanceToolBar->setEnabled(false); + setInstanceActionsEnabled(false); + + // add a close button at the end of the main toolbar when running on gamescope / steam deck + // this is only needed on gamescope because it defaults to an X11/XWayland session and + // does not implement decorations + if (qgetenv("XDG_CURRENT_DESKTOP") == "gamescope") { + ui->mainToolBar->addAction(ui->actionCloseWindow); + } + + ui->actionViewJavaFolder->setEnabled(BuildConfig.JAVA_DOWNLOADER_ENABLED); + } + + { // logs viewing + connect(ui->actionViewLog, &QAction::triggered, this, [] { APPLICATION->showLogWindow(); }); + } + + // add the toolbar toggles to the view menu + ui->viewMenu->addAction(ui->instanceToolBar->toggleViewAction()); + ui->viewMenu->addAction(ui->newsToolBar->toggleViewAction()); + + updateThemeMenu(); + updateMainToolBar(); + // OSX magic. + setUnifiedTitleAndToolBarOnMac(true); + + // Global shortcuts + { + // you can't set QKeySequence::StandardKey shortcuts in qt designer >:( + ui->actionAddInstance->setShortcut(QKeySequence::New); + ui->actionSettings->setShortcut(QKeySequence::Preferences); + ui->actionUndoTrashInstance->setShortcut(QKeySequence::Undo); + ui->actionDeleteInstance->setShortcuts({ QKeySequence(tr("Backspace")), QKeySequence::Delete }); + ui->actionCloseWindow->setShortcut(QKeySequence::Close); + connect(ui->actionCloseWindow, &QAction::triggered, APPLICATION, &Application::closeCurrentWindow); + + // FIXME: This is kinda weird. and bad. We need some kind of managed shutdown. + auto q = new QShortcut(QKeySequence::Quit, this); + connect(q, &QShortcut::activated, APPLICATION, &Application::quit); + } + + // Konami Code + { + secretEventFilter = new KonamiCode(this); + connect(secretEventFilter, &KonamiCode::triggered, this, &MainWindow::konamiTriggered); + } + + // Add the news label to the news toolbar. + { + m_newsChecker.reset(new NewsChecker(APPLICATION->network(), BuildConfig.NEWS_RSS_URL)); + newsLabel = new QToolButton(); + newsLabel->setIcon(QIcon::fromTheme("news")); + newsLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); + newsLabel->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + newsLabel->setFocusPolicy(Qt::NoFocus); + ui->newsToolBar->insertWidget(ui->actionMoreNews, newsLabel); + + connect(newsLabel, &QAbstractButton::clicked, this, &MainWindow::newsButtonClicked); + connect(m_newsChecker.get(), &NewsChecker::newsLoaded, this, &MainWindow::updateNewsLabel); + updateNewsLabel(); + + ui->newsToolBar->hide(); + } + + // Create the instance list widget + { + view = new InstanceView(ui->centralWidget); + + view->setSelectionMode(QAbstractItemView::SingleSelection); + // FIXME: leaks ListViewDelegate + auto delegate = new ListViewDelegate(this); + view->setItemDelegate(delegate); + view->setFrameShape(QFrame::NoFrame); + // do not show ugly blue border on the mac + view->setAttribute(Qt::WA_MacShowFocusRect, false); + connect(delegate, &ListViewDelegate::textChanged, this, [this](QString before, QString after) { + if (auto newRoot = askToUpdateInstanceDirName(m_selectedInstance, before, after, this); !newRoot.isEmpty()) { + auto oldID = m_selectedInstance->id(); + auto newID = QFileInfo(newRoot).fileName(); + QString origGroup(APPLICATION->instances()->getInstanceGroup(oldID)); + bool syncGroup = origGroup != GroupId() && oldID != newID; + if (syncGroup) + APPLICATION->instances()->setInstanceGroup(oldID, GroupId()); + + refreshInstances(); + setSelectedInstanceById(newID); + + if (syncGroup) + APPLICATION->instances()->setInstanceGroup(newID, origGroup); + } + }); + + view->installEventFilter(this); + view->setContextMenuPolicy(Qt::CustomContextMenu); + connect(view, &QWidget::customContextMenuRequested, this, &MainWindow::showInstanceContextMenu); + connect(view, &InstanceView::droppedURLs, this, &MainWindow::processURLs, Qt::QueuedConnection); + + proxymodel = new InstanceProxyModel(this); + proxymodel->setSourceModel(APPLICATION->instances()); + proxymodel->sort(0); + connect(proxymodel, &InstanceProxyModel::dataChanged, this, &MainWindow::instanceDataChanged); + + view->setModel(proxymodel); + view->setSourceOfGroupCollapseStatus( + [](const QString& groupName) -> bool { return APPLICATION->instances()->isGroupCollapsed(groupName); }); + connect(view, &InstanceView::groupStateChanged, APPLICATION->instances(), &InstanceList::on_GroupStateChanged); + ui->horizontalLayout->addWidget(view); + } + // The cat background + { + // set the cat action priority here so you can still see the action in qt designer + ui->actionCAT->setPriority(QAction::LowPriority); + bool cat_enable = APPLICATION->settings()->get("TheCat").toBool(); + ui->actionCAT->setChecked(cat_enable); + connect(ui->actionCAT, &QAction::toggled, this, &MainWindow::onCatToggled); + connect(APPLICATION, &Application::currentCatChanged, this, &MainWindow::onCatChanged); + setCatBackground(cat_enable); + } + + // Togglable status bar + { + bool statusBarVisible = APPLICATION->settings()->get("StatusBarVisible").toBool(); + ui->actionToggleStatusBar->setChecked(statusBarVisible); + connect(ui->actionToggleStatusBar, &QAction::toggled, this, &MainWindow::setStatusBarVisibility); + setStatusBarVisibility(statusBarVisible); + } + + // Lock toolbars + { + bool toolbarsLocked = APPLICATION->settings()->get("ToolbarsLocked").toBool(); + ui->actionLockToolbars->setChecked(toolbarsLocked); + connect(ui->actionLockToolbars, &QAction::toggled, this, &MainWindow::lockToolbars); + lockToolbars(toolbarsLocked); + } + // start instance when double-clicked + connect(view, &InstanceView::activated, this, &MainWindow::instanceActivated); + + // track the selection -- update the instance toolbar + connect(view->selectionModel(), &QItemSelectionModel::currentChanged, this, &MainWindow::instanceChanged); + + // track icon changes and update the toolbar! + connect(APPLICATION->icons(), &IconList::iconUpdated, this, &MainWindow::iconUpdated); + + // model reset -> selection is invalid. All the instance pointers are wrong. + connect(APPLICATION->instances(), &InstanceList::dataIsInvalid, this, &MainWindow::selectionBad); + + // handle newly added instances + connect(APPLICATION->instances(), &InstanceList::instanceSelectRequest, this, &MainWindow::instanceSelectRequest); + + // When the global settings page closes, we want to know about it and update our state + connect(APPLICATION, &Application::globalSettingsApplied, this, &MainWindow::globalSettingsClosed); + + m_statusLeft = new QLabel(tr("No instance selected"), this); + m_statusCenter = new QLabel(tr("Total: 0 min"), this); + statusBar()->addPermanentWidget(m_statusLeft, 1); + statusBar()->addPermanentWidget(m_statusCenter, 0); + + // Add "manage accounts" button, right align + QWidget* spacer = new QWidget(); + spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + ui->mainToolBar->insertWidget(ui->actionAccountsButton, spacer); + + // Use undocumented property... https://stackoverflow.com/questions/7121718/create-a-scrollbar-in-a-submenu-qt + ui->accountsMenu->setStyleSheet("QMenu { menu-scrollable: 1; }"); + + repopulateAccountsMenu(); + + // Update the menu when the active account changes. + // Shouldn't have to use lambdas here like this, but if I don't, the compiler throws a fit. + // Template hell sucks... + connect(APPLICATION->accounts(), &AccountList::defaultAccountChanged, [this] { defaultAccountChanged(); }); + connect(APPLICATION->accounts(), &AccountList::listChanged, [this] { defaultAccountChanged(); }); + + // Show initial account + defaultAccountChanged(); + + // TODO: refresh accounts here? + // auto accounts = APPLICATION->accounts(); + + // load the news + { + m_newsChecker->reloadNews(); + updateNewsLabel(); + } + + if (APPLICATION->updaterEnabled()) { + bool updatesAllowed = APPLICATION->updatesAreAllowed(); + updatesAllowedChanged(updatesAllowed); + + connect(ui->actionCheckUpdate, &QAction::triggered, this, &MainWindow::checkForUpdates); + + // set up the updater object. + auto updater = APPLICATION->updater(); + + if (updater) { + connect(updater, &ExternalUpdater::canCheckForUpdatesChanged, this, &MainWindow::updatesAllowedChanged); + } + } + + connect(ui->actionUndoTrashInstance, &QAction::triggered, this, &MainWindow::undoTrashInstance); + + setSelectedInstanceById(APPLICATION->settings()->get("SelectedInstance").toString()); + + // removing this looks stupid + view->setFocus(); + + retranslateUi(); + + if (APPLICATION->accounts()->count() == 0) { + QTimer::singleShot(0, this, [this] { + ChooseOfflineNameDialog dialog(QString(), this); + if (dialog.exec() == QDialog::Accepted) { + if (auto account = MinecraftAccount::createOffline(dialog.getUsername())) { + account->login()->start(); + APPLICATION->accounts()->addAccount(account); + APPLICATION->accounts()->setDefaultAccount(account); + } + } + }); + } +} + +// macOS always has a native menu bar, so these fixes are not applicable +// Other systems may or may not have a native menu bar (most do not - it seems like only Ubuntu Unity does) +#ifndef Q_OS_MAC +void MainWindow::keyReleaseEvent(QKeyEvent* event) +{ + if (event->key() == Qt::Key_Alt && !APPLICATION->settings()->get("MenuBarInsteadOfToolBar").toBool()) + ui->menuBar->setVisible(!ui->menuBar->isVisible()); + else + QMainWindow::keyReleaseEvent(event); +} +#endif + +void MainWindow::retranslateUi() +{ + if (m_selectedInstance) { + m_statusLeft->setText(m_selectedInstance->getStatusbarDescription()); + } else { + m_statusLeft->setText(tr("No instance selected")); + } + + ui->retranslateUi(this); + + MinecraftAccountPtr defaultAccount = APPLICATION->accounts()->defaultAccount(); + if (defaultAccount) { + auto profileLabel = profileInUseFilter(defaultAccount->displayName(), defaultAccount->isInUse()); + ui->actionAccountsButton->setText(profileLabel); + } + + changeIconButton->setToolTip(ui->actionChangeInstIcon->toolTip()); + renameButton->setToolTip(ui->actionRenameInstance->toolTip()); + + // replace the %1 with the launcher display name in some actions + if (helpMenuButton->toolTip().contains("%1")) + helpMenuButton->setToolTip(helpMenuButton->toolTip().arg(BuildConfig.LAUNCHER_DISPLAYNAME)); + + for (auto action : ui->helpMenu->actions()) { + if (action->text().contains("%1")) + action->setText(action->text().arg(BuildConfig.LAUNCHER_DISPLAYNAME)); + if (action->toolTip().contains("%1")) + action->setToolTip(action->toolTip().arg(BuildConfig.LAUNCHER_DISPLAYNAME)); + } +} + +MainWindow::~MainWindow() {} + +QMenu* MainWindow::createPopupMenu() +{ + QMenu* filteredMenu = QMainWindow::createPopupMenu(); + filteredMenu->removeAction(ui->mainToolBar->toggleViewAction()); + + filteredMenu->addAction(ui->actionToggleStatusBar); + filteredMenu->addAction(ui->actionLockToolbars); + + return filteredMenu; +} +void MainWindow::setStatusBarVisibility(bool state) +{ + statusBar()->setVisible(state); + APPLICATION->settings()->set("StatusBarVisible", state); +} +void MainWindow::lockToolbars(bool state) +{ + ui->mainToolBar->setMovable(!state); + ui->instanceToolBar->setMovable(!state); + ui->newsToolBar->setMovable(!state); + APPLICATION->settings()->set("ToolbarsLocked", state); +} + +void MainWindow::konamiTriggered() +{ + QString gradient = + " stop:0 rgba(125, 0, 0, 255), stop:0.166 rgba(125, 125, 0, 255), stop:0.333 rgba(0, 125, 0, 255), stop:0.5 rgba(0, 125, 125, " + "255), stop:0.666 rgba(0, 0, 125, 255), stop:0.833 rgba(125, 0, 125, 255), stop:1 rgba(125, 0, 0, 255));"; + QString stylesheet = "background-color: qlineargradient(spread:pad, x1:0, y1:0, x2:1, y2:0," + gradient; + if (ui->mainToolBar->styleSheet() == stylesheet) { + ui->mainToolBar->setStyleSheet(""); + ui->instanceToolBar->setStyleSheet(""); + ui->centralWidget->setStyleSheet(""); + ui->newsToolBar->setStyleSheet(""); + ui->statusBar->setStyleSheet(""); + qDebug() << "Super Secret Mode DEACTIVATED!"; + } else { + ui->mainToolBar->setStyleSheet(stylesheet); + ui->instanceToolBar->setStyleSheet("background-color: qlineargradient(spread:pad, x1:0, y1:0, x2:0, y2:1," + gradient); + ui->centralWidget->setStyleSheet("background-color: qlineargradient(spread:pad, x1:0, y1:0, x2:1, y2:1," + gradient); + ui->newsToolBar->setStyleSheet(stylesheet); + ui->statusBar->setStyleSheet(stylesheet); + qDebug() << "Super Secret Mode ACTIVATED!"; + } +} + +void MainWindow::showInstanceContextMenu(const QPoint& pos) +{ + QList actions; + + QAction* actionSep = new QAction("", this); + actionSep->setSeparator(true); + + bool onInstance = view->indexAt(pos).isValid(); + if (onInstance) { + // reuse the file menu actions + actions = ui->fileMenu->actions(); + + // remove the add instance action, launcher settings action and close action + actions.removeFirst(); + actions.removeLast(); + actions.removeLast(); + + actions.prepend(ui->actionChangeInstIcon); + actions.prepend(ui->actionRenameInstance); + + // add header + actions.prepend(actionSep); + QAction* actionVoid = new QAction(m_selectedInstance->name(), this); + actionVoid->setEnabled(false); + actions.prepend(actionVoid); + } else { + auto group = view->groupNameAt(pos); + + QAction* actionVoid = new QAction(group.isNull() ? BuildConfig.LAUNCHER_DISPLAYNAME : group, this); + actionVoid->setEnabled(false); + + QAction* actionCreateInstance = new QAction(tr("&Create instance"), this); + actionCreateInstance->setToolTip(ui->actionAddInstance->toolTip()); + if (!group.isNull()) { + QVariantMap instance_action_data; + instance_action_data["group"] = group; + actionCreateInstance->setData(instance_action_data); + } + + connect(actionCreateInstance, &QAction::triggered, this, &MainWindow::on_actionAddInstance_triggered); + + actions.prepend(actionSep); + actions.prepend(actionVoid); + actions.append(actionCreateInstance); + if (!group.isNull()) { + QAction* actionDeleteGroup = new QAction(tr("&Delete group"), this); + connect(actionDeleteGroup, &QAction::triggered, this, [this, group] { deleteGroup(group); }); + actions.append(actionDeleteGroup); + + QAction* actionRenameGroup = new QAction(tr("&Rename group"), this); + connect(actionRenameGroup, &QAction::triggered, this, [this, group] { renameGroup(group); }); + actions.append(actionRenameGroup); + } + } + QMenu myMenu; + myMenu.addActions(actions); + /* + if (onInstance) + myMenu.setEnabled(m_selectedInstance->canLaunch()); + */ + myMenu.exec(view->mapToGlobal(pos)); +} + +void MainWindow::updateMainToolBar() +{ + ui->menuBar->setVisible(APPLICATION->settings()->get("MenuBarInsteadOfToolBar").toBool()); + ui->mainToolBar->setVisible(ui->menuBar->isNativeMenuBar() || !APPLICATION->settings()->get("MenuBarInsteadOfToolBar").toBool()); +} + +void MainWindow::updateLaunchButton() +{ + QMenu* launchMenu = ui->actionLaunchInstance->menu(); + if (launchMenu) + launchMenu->clear(); + else + launchMenu = new QMenu(this); + if (m_selectedInstance) + m_selectedInstance->populateLaunchMenu(launchMenu); + ui->actionLaunchInstance->setMenu(launchMenu); +} + +void MainWindow::updateThemeMenu() +{ + QMenu* themeMenu = ui->actionChangeTheme->menu(); + + if (themeMenu) { + themeMenu->clear(); + } else { + themeMenu = new QMenu(this); + } + + auto themes = APPLICATION->themeManager()->getValidApplicationThemes(); + + QActionGroup* themesGroup = new QActionGroup(this); + + for (auto* theme : themes) { + QAction* themeAction = themeMenu->addAction(theme->name()); + + themeAction->setCheckable(true); + if (APPLICATION->settings()->get("ApplicationTheme").toString() == theme->id()) { + themeAction->setChecked(true); + } + themeAction->setActionGroup(themesGroup); + + connect(themeAction, &QAction::triggered, [theme]() { + APPLICATION->themeManager()->setApplicationTheme(theme->id()); + APPLICATION->settings()->set("ApplicationTheme", theme->id()); + }); + } + + ui->actionChangeTheme->setMenu(themeMenu); +} + +void MainWindow::repopulateAccountsMenu() +{ + ui->accountsMenu->clear(); + + // NOTE: this is done so the accounts button text is not set to the accounts menu title + QMenu* accountsButtonMenu = ui->actionAccountsButton->menu(); + if (accountsButtonMenu) { + accountsButtonMenu->clear(); + } else { + accountsButtonMenu = new QMenu(this); + ui->actionAccountsButton->setMenu(accountsButtonMenu); + } + + auto accounts = APPLICATION->accounts(); + MinecraftAccountPtr defaultAccount = accounts->defaultAccount(); + + QString active_profileId = ""; + if (defaultAccount) { + // this can be called before accountMenuButton exists + if (ui->actionAccountsButton) { + auto profileLabel = profileInUseFilter(defaultAccount->displayName(), defaultAccount->isInUse()); + ui->actionAccountsButton->setText(profileLabel); + } + } + + QActionGroup* accountsGroup = new QActionGroup(this); + + if (accounts->count() <= 0) { + ui->actionNoAccountsAdded->setEnabled(false); + ui->accountsMenu->addAction(ui->actionNoAccountsAdded); + } else { + // TODO: Nicer way to iterate? + for (int i = 0; i < accounts->count(); i++) { + MinecraftAccountPtr account = accounts->at(i); + auto profileLabel = profileInUseFilter(account->displayName(), account->isInUse()); + QAction* action = new QAction(profileLabel, this); + action->setData(i); + action->setCheckable(true); + action->setActionGroup(accountsGroup); + if (defaultAccount == account) { + action->setChecked(true); + } + + auto face = account->getFace(); + if (!face.isNull()) { + action->setIcon(face); + } else { + action->setIcon(QIcon::fromTheme("noaccount")); + } + + const int highestNumberKey = 9; + if (i < highestNumberKey) { + action->setShortcut(QKeySequence(tr("Ctrl+%1").arg(i + 1))); + } + + ui->accountsMenu->addAction(action); + connect(action, &QAction::triggered, this, &MainWindow::changeActiveAccount); + } + } + + ui->accountsMenu->addSeparator(); + + ui->actionNoDefaultAccount->setData(-1); + ui->actionNoDefaultAccount->setChecked(!defaultAccount); + ui->actionNoDefaultAccount->setActionGroup(accountsGroup); + + ui->accountsMenu->addAction(ui->actionNoDefaultAccount); + + connect(ui->actionNoDefaultAccount, &QAction::triggered, this, &MainWindow::changeActiveAccount); + + ui->accountsMenu->addSeparator(); + ui->accountsMenu->addAction(ui->actionManageAccounts); + + accountsButtonMenu->addActions(ui->accountsMenu->actions()); +} + +void MainWindow::updatesAllowedChanged(bool allowed) +{ + if (!APPLICATION->updaterEnabled()) { + return; + } + ui->actionCheckUpdate->setEnabled(allowed); +} + +/* + * Assumes the sender is a QAction + */ +void MainWindow::changeActiveAccount() +{ + QAction* sAction = (QAction*)sender(); + + // Profile's associated Mojang username + if (sAction->data().typeId() != QMetaType::Int) + return; + + QVariant action_data = sAction->data(); + bool valid = false; + int index = action_data.toInt(&valid); + if (!valid) { + index = -1; + } + auto accounts = APPLICATION->accounts(); + accounts->setDefaultAccount(index == -1 ? nullptr : accounts->at(index)); + defaultAccountChanged(); +} + +void MainWindow::defaultAccountChanged() +{ + repopulateAccountsMenu(); + + MinecraftAccountPtr account = APPLICATION->accounts()->defaultAccount(); + + // FIXME: this needs adjustment for MSA + if (account && account->profileName() != "") { + auto profileLabel = profileInUseFilter(account->displayName(), account->isInUse()); + ui->actionAccountsButton->setText(profileLabel); + auto face = account->getFace(); + if (face.isNull()) { + ui->actionAccountsButton->setIcon(QIcon::fromTheme("noaccount")); + } else { + ui->actionAccountsButton->setIcon(face); + } + return; + } + + // Set the icon to the "no account" icon. + ui->actionAccountsButton->setIcon(QIcon::fromTheme("noaccount")); + ui->actionAccountsButton->setText(tr("Accounts")); +} + +bool MainWindow::eventFilter(QObject* obj, QEvent* ev) +{ + if (obj == view) { + if (ev->type() == QEvent::KeyPress) { + secretEventFilter->input(ev); + QKeyEvent* keyEvent = static_cast(ev); + switch (keyEvent->key()) { + /* + case Qt::Key_Enter: + case Qt::Key_Return: + activateInstance(m_selectedInstance); + return true; + */ + case Qt::Key_Delete: + on_actionDeleteInstance_triggered(); + return true; + case Qt::Key_F5: + refreshInstances(); + return true; + case Qt::Key_F2: + on_actionRenameInstance_triggered(); + return true; + default: + break; + } + } + } + return QMainWindow::eventFilter(obj, ev); +} + +void MainWindow::updateNewsLabel() +{ + if (m_newsChecker->isLoadingNews()) { + newsLabel->setText(tr("Loading news...")); + newsLabel->setEnabled(false); + ui->actionMoreNews->setVisible(false); + } else { + QList entries = m_newsChecker->getNewsEntries(); + if (entries.length() > 0) { + newsLabel->setText(entries[0]->title); + newsLabel->setEnabled(true); + ui->actionMoreNews->setVisible(true); + } else { + newsLabel->setText(tr("No news available.")); + newsLabel->setEnabled(false); + ui->actionMoreNews->setVisible(false); + } + } +} + +QList stringToIntList(const QString& string) +{ + QStringList split = string.split(',', Qt::SkipEmptyParts); + QList out; + for (int i = 0; i < split.size(); ++i) { + out.append(split.at(i).toInt()); + } + return out; +} +QString intListToString(const QList& list) +{ + QStringList slist; + for (int i = 0; i < list.size(); ++i) { + slist.append(QString::number(list.at(i))); + } + return slist.join(','); +} + +void MainWindow::onCatToggled(bool state) +{ + setCatBackground(state); + APPLICATION->settings()->set("TheCat", state); +} + +void MainWindow::setCatBackground(bool enabled) +{ + view->setPaintCat(enabled); + view->viewport()->repaint(); +} + +void MainWindow::runModalTask(Task* task) +{ + connect(task, &Task::failed, + [this](QString reason) { CustomMessageBox::selectable(this, tr("Error"), reason, QMessageBox::Critical)->show(); }); + connect(task, &Task::succeeded, [this, task]() { + QStringList warnings = task->warnings(); + if (warnings.count()) { + CustomMessageBox::selectable(this, tr("Warnings"), warnings.join('\n'), QMessageBox::Warning)->show(); + } + }); + connect(task, &Task::aborted, [this] { + CustomMessageBox::selectable(this, tr("Task aborted"), tr("The task has been aborted by the user."), QMessageBox::Information) + ->show(); + }); + ProgressDialog loadDialog(this); + loadDialog.setSkipButton(true, tr("Abort")); + loadDialog.execWithTask(task); +} + +void MainWindow::instanceFromInstanceTask(InstanceTask* rawTask) +{ + unique_qobject_ptr task(APPLICATION->instances()->wrapInstanceTask(rawTask)); + runModalTask(task.get()); +} + +void MainWindow::on_actionCopyInstance_triggered() +{ + if (!m_selectedInstance) + return; + + CopyInstanceDialog copyInstDlg(m_selectedInstance, this); + if (!copyInstDlg.exec()) + return; + + auto copyTask = new InstanceCopyTask(m_selectedInstance, copyInstDlg.getChosenOptions()); + copyTask->setName(copyInstDlg.instName()); + copyTask->setGroup(copyInstDlg.instGroup()); + copyTask->setIcon(copyInstDlg.iconKey()); + unique_qobject_ptr task(APPLICATION->instances()->wrapInstanceTask(copyTask)); + runModalTask(task.get()); +} + +void MainWindow::addInstance(const QString& url, const QMap& extra_info) +{ + QString groupName; + do { + QObject* obj = sender(); + if (!obj) + break; + QAction* action = qobject_cast(obj); + if (!action) + break; + auto map = action->data().toMap(); + if (!map.contains("group")) + break; + groupName = map["group"].toString(); + } while (0); + + if (groupName.isEmpty()) { + groupName = APPLICATION->settings()->get("LastUsedGroupForNewInstance").toString(); + } + + NewInstanceDialog newInstDlg(groupName, url, extra_info, this); + if (!newInstDlg.exec()) + return; + + APPLICATION->settings()->set("LastUsedGroupForNewInstance", newInstDlg.instGroup()); + + InstanceTask* creationTask = newInstDlg.extractTask(); + if (creationTask) { + instanceFromInstanceTask(creationTask); + } +} + +void MainWindow::on_actionAddInstance_triggered() +{ + addInstance(); +} + +void MainWindow::processURLs(QList urls) +{ + // NOTE: This loop only processes one dropped file! + for (auto& url : urls) { + qDebug() << "Processing" << url; + + // The isLocalFile() check below doesn't work as intended without an explicit scheme. + if (url.scheme().isEmpty()) + url.setScheme("file"); + + ModPlatform::IndexedVersion version; + QMap extra_info; + QUrl local_url; + if (!url.isLocalFile()) { // download the remote resource and identify + + const bool isExternalURLImport = + (url.host().toLower() == "import") || + (url.path().startsWith("/import", Qt::CaseInsensitive)); + + QUrl dl_url; + if (url.scheme() == "curseforge" || (url.scheme() == BuildConfig.LAUNCHER_APP_BINARY_NAME && url.host() == "install")) { + // need to find the download link for the modpack / resource + // format of url curseforge://install?addonId=IDHERE&fileId=IDHERE + // format of url binaryname://install?platform=curseforge&addonId=IDHERE&fileId=IDHERE + QUrlQuery query(url); + + // check if this is a binaryname:// url + if (url.scheme() == BuildConfig.LAUNCHER_APP_BINARY_NAME) { + // check this is an curseforge platform request + if (query.queryItemValue("platform").toLower() != "curseforge") { + qDebug() << "Invalid mod distribution platform:" << query.queryItemValue("platform"); + continue; + } + } + + if (query.allQueryItemValues("addonId").isEmpty() || query.allQueryItemValues("fileId").isEmpty()) { + qDebug() << "Invalid curseforge link:" << url; + continue; + } + + auto addonId = query.allQueryItemValues("addonId")[0]; + auto fileId = query.allQueryItemValues("fileId")[0]; + + extra_info.insert("pack_id", addonId); + extra_info.insert("pack_version_id", fileId); + + auto api = FlameAPI(); + auto [job, array] = api.getFile(addonId, fileId); + + connect(job.get(), &Task::failed, this, + [this](QString reason) { CustomMessageBox::selectable(this, tr("Error"), reason, QMessageBox::Critical)->show(); }); + connect(job.get(), &Task::succeeded, this, [this, array, addonId, fileId, &dl_url, &version] { + qDebug() << "Returned CFURL Json:\n" << array->toStdString().c_str(); + auto doc = Json::requireDocument(*array); + auto data = doc.object()["data"].toObject(); + // No way to find out if it's a mod or a modpack before here + // And also we need to check if it ends with .zip, instead of any better way + version = FlameMod::loadIndexedPackVersion(data); + auto fileName = version.fileName; + + // Have to use ensureString then use QUrl to get proper url encoding + dl_url = QUrl(version.downloadUrl); + if (!dl_url.isValid()) { + CustomMessageBox::selectable( + this, tr("Error"), + tr("The modpack, mod, or resource %1 is blocked for third-parties! Please download it manually.").arg(fileName), + QMessageBox::Critical) + ->show(); + return; + } + + QFileInfo dl_file(dl_url.fileName()); + }); + + { // drop stack + ProgressDialog dlUrlDialod(this); + dlUrlDialod.setSkipButton(true, tr("Abort")); + dlUrlDialod.execWithTask(job.get()); + } + + } else if (url.scheme() == BuildConfig.LAUNCHER_APP_BINARY_NAME && !isExternalURLImport) { + QVariantMap receivedData; + const QUrlQuery query(url.query()); + const auto items = query.queryItems(); + for (auto it = items.begin(), end = items.end(); it != end; ++it) + receivedData.insert(it->first, it->second); + emit APPLICATION->oauthReplyRecieved(receivedData); + continue; + } else if ((url.scheme() == "prismlauncher" || url.scheme() == BuildConfig.LAUNCHER_APP_BINARY_NAME) + && isExternalURLImport) { + // PrismLauncher URL protocol modpack import + // works for any prism fork + // preferred import format: prismlauncher://import?url=ENCODED + const auto host = url.host().toLower(); + const auto path = url.path(); + + QString encodedTarget; + + { + QUrlQuery query(url); + const auto values = query.allQueryItemValues("url"); + if (!values.isEmpty()) { + encodedTarget = values.first(); + } + } + + // alternative import format: prismlauncher://import/ENCODED + if (encodedTarget.isEmpty()) { + + QString p = path; + + if (p.startsWith("/import/", Qt::CaseInsensitive)) { + p = p.mid(QString("/import/").size()); + } else if (host == "import" && p.startsWith("/")) { + p = p.mid(1); + } + + if (!p.isEmpty() && p != "/import") { + encodedTarget = p; + } + } + + if (encodedTarget.isEmpty()) { + CustomMessageBox::selectable( + this, + tr("Error"), + tr("Invalid import link: missing 'url' parameter."), + QMessageBox::Critical + )->show(); + continue; + } + + const QString decodedStr = QUrl::fromPercentEncoding(encodedTarget.toUtf8()).trimmed(); + + QUrl target = QUrl::fromUserInput(decodedStr); + + // Validate: only allow http(s) + if (!target.isValid() || (target.scheme() != "https" && target.scheme() != "http")) { + CustomMessageBox::selectable( + this, + tr("Error"), + tr("Invalid import link: URL must be http(s)."), + QMessageBox::Critical + )->show(); + continue; + } + + const auto res = QMessageBox::question( + this, + tr("Install modpack"), + tr("Do you want to download and import a modpack from:\n%1\n\nURL:\n%2") + .arg(target.host(), target.toString()), + QMessageBox::Yes | QMessageBox::No, + QMessageBox::Yes + ); + if (res != QMessageBox::Yes) { + continue; + } + + dl_url = target; + } else { + dl_url = url; + } + + if (!dl_url.isValid()) { + continue; // no valid url to download this resource + } + + const QString path = dl_url.host() + '/' + dl_url.path(); + auto entry = APPLICATION->metacache()->resolveEntry("general", path); + entry->setStale(true); + auto dl_job = unique_qobject_ptr(new NetJob(tr("Modpack download"), APPLICATION->network())); + dl_job->addNetAction(Net::ApiDownload::makeCached(dl_url, entry)); + auto archivePath = entry->getFullPath(); + + bool dl_success = false; + connect(dl_job.get(), &Task::failed, this, + [this](QString reason) { CustomMessageBox::selectable(this, tr("Error"), reason, QMessageBox::Critical)->show(); }); + connect(dl_job.get(), &Task::succeeded, this, [&dl_success] { dl_success = true; }); + + { // drop stack + ProgressDialog dlUrlDialod(this); + dlUrlDialod.setSkipButton(true, tr("Abort")); + dlUrlDialod.execWithTask(dl_job.get()); + } + + if (!dl_success) { + continue; // no local file to identify + } + local_url = QUrl::fromLocalFile(archivePath); + + } else { + local_url = url; + } + + auto localFileName = QDir::toNativeSeparators(local_url.toLocalFile()); + QFileInfo localFileInfo(localFileName); + + auto type = ResourceUtils::identify(localFileInfo); + + if (ModPlatform::ResourceTypeUtils::VALID_RESOURCES.count(type) == 0) { // probably instance/modpack + addInstance(localFileName, extra_info); + continue; + } + + if (APPLICATION->instances()->count() <= 0) { + CustomMessageBox::selectable(this, tr("No instance!"), + tr("No instance available to add the resource to.\nPlease create a new instance before " + "attempting to install this resource again."), + QMessageBox::Critical) + ->show(); + continue; + } + ImportResourceDialog dlg(localFileName, type, this); + + if (dlg.exec() != QDialog::Accepted) + continue; + + qDebug() << "Adding resource" << localFileName << "to" << dlg.selectedInstanceKey; + + auto inst = APPLICATION->instances()->getInstanceById(dlg.selectedInstanceKey); + auto minecraftInst = dynamic_cast(inst); + + switch (type) { + case ModPlatform::ResourceType::ResourcePack: + minecraftInst->resourcePackList()->installResourceWithFlameMetadata(localFileName, version); + break; + case ModPlatform::ResourceType::TexturePack: + minecraftInst->texturePackList()->installResourceWithFlameMetadata(localFileName, version); + break; + case ModPlatform::ResourceType::DataPack: + qWarning() << "Importing of Data Packs not supported at this time. Ignoring" << localFileName; + break; + case ModPlatform::ResourceType::Mod: + minecraftInst->loaderModList()->installResourceWithFlameMetadata(localFileName, version); + break; + case ModPlatform::ResourceType::ShaderPack: + minecraftInst->shaderPackList()->installResourceWithFlameMetadata(localFileName, version); + break; + case ModPlatform::ResourceType::World: + minecraftInst->worldList()->installWorld(localFileInfo); + break; + case ModPlatform::ResourceType::Unknown: + default: + qDebug() << "Can't Identify" << localFileName << "Ignoring it."; + break; + } + } +} + +void MainWindow::on_actionWebsite_triggered() +{ + DesktopServices::openUrl(QUrl("https://racked.ru")); +} + +void MainWindow::on_actionChangeInstIcon_triggered() +{ + if (!m_selectedInstance) + return; + + IconPickerDialog dlg(this); + dlg.execWithSelection(m_selectedInstance->iconKey()); + if (dlg.result() == QDialog::Accepted) { + m_selectedInstance->setIconKey(dlg.selectedIconKey); + auto icon = APPLICATION->icons()->getIcon(dlg.selectedIconKey); + ui->actionChangeInstIcon->setIcon(icon); + changeIconButton->setIcon(icon); + } +} + +void MainWindow::iconUpdated(QString icon) +{ + if (icon == m_currentInstIcon) { + auto new_icon = APPLICATION->icons()->getIcon(m_currentInstIcon); + ui->actionChangeInstIcon->setIcon(new_icon); + changeIconButton->setIcon(new_icon); + } +} + +void MainWindow::updateInstanceToolIcon(QString new_icon) +{ + m_currentInstIcon = new_icon; + auto icon = APPLICATION->icons()->getIcon(m_currentInstIcon); + ui->actionChangeInstIcon->setIcon(icon); + changeIconButton->setIcon(icon); +} + +void MainWindow::setSelectedInstanceById(const QString& id) +{ + if (id.isNull()) + return; + const QModelIndex index = APPLICATION->instances()->getInstanceIndexById(id); + if (index.isValid()) { + QModelIndex selectionIndex = proxymodel->mapFromSource(index); + view->selectionModel()->setCurrentIndex(selectionIndex, QItemSelectionModel::ClearAndSelect); + updateStatusCenter(); + } +} + +void MainWindow::on_actionChangeInstGroup_triggered() +{ + if (!m_selectedInstance) + return; + + InstanceId instId = m_selectedInstance->id(); + QString src(APPLICATION->instances()->getInstanceGroup(instId)); + + QStringList groups = APPLICATION->instances()->getGroups(); + groups.prepend(""); + int index = groups.indexOf(src); + bool ok = false; + QString dst = QInputDialog::getItem(this, tr("Group name"), tr("Enter a new group name."), groups, index, true, &ok); + dst = dst.simplified(); + + if (ok) { + APPLICATION->instances()->setInstanceGroup(instId, dst); + } +} + +void MainWindow::deleteGroup(QString group) +{ + Q_ASSERT(!group.isEmpty()); + + const int reply = QMessageBox::question(this, tr("Delete group"), tr("Are you sure you want to delete the group '%1'?").arg(group), + QMessageBox::Yes | QMessageBox::No); + if (reply == QMessageBox::Yes) + APPLICATION->instances()->deleteGroup(group); +} + +void MainWindow::renameGroup(QString group) +{ + Q_ASSERT(!group.isEmpty()); + + QString name = QInputDialog::getText(this, tr("Rename group"), tr("Enter a new group name."), QLineEdit::Normal, group); + name = name.simplified(); + if (name.isNull() || name == group) + return; + + const bool empty = name.isEmpty(); + const bool duplicate = APPLICATION->instances()->getGroups().contains(name, Qt::CaseInsensitive) && group.toLower() != name.toLower(); + + if (empty || duplicate) { + QMessageBox::warning(this, tr("Cannot rename group"), empty ? tr("Cannot set empty name.") : tr("Group already exists. :/")); + return; + } + + APPLICATION->instances()->renameGroup(group, name); +} + +void MainWindow::undoTrashInstance() +{ + if (!APPLICATION->instances()->undoTrashInstance()) + QMessageBox::warning( + this, tr("Failed to undo trashing instance"), + tr("Some instances and shortcuts could not be restored.\nPlease check your trashbin to manually restore them.")); + ui->actionUndoTrashInstance->setEnabled(APPLICATION->instances()->trashedSomething()); +} + +void MainWindow::on_actionViewLauncherRootFolder_triggered() +{ + DesktopServices::openPath("."); +} + +void MainWindow::on_actionViewInstanceFolder_triggered() +{ + QString str = APPLICATION->settings()->get("InstanceDir").toString(); + DesktopServices::openPath(str); +} + +void MainWindow::on_actionViewCentralModsFolder_triggered() +{ + DesktopServices::openPath(APPLICATION->settings()->get("CentralModsDir").toString(), true); +} + +void MainWindow::on_actionViewSkinsFolder_triggered() +{ + DesktopServices::openPath(APPLICATION->settings()->get("SkinsDir").toString(), true); +} + +void MainWindow::on_actionViewIconThemeFolder_triggered() +{ + DesktopServices::openPath(APPLICATION->themeManager()->getIconThemesFolder().path(), true); +} + +void MainWindow::on_actionViewWidgetThemeFolder_triggered() +{ + DesktopServices::openPath(APPLICATION->themeManager()->getApplicationThemesFolder().path(), true); +} + +void MainWindow::on_actionViewCatPackFolder_triggered() +{ + DesktopServices::openPath(APPLICATION->themeManager()->getCatPacksFolder().path(), true); +} + +void MainWindow::on_actionViewIconsFolder_triggered() +{ + DesktopServices::openPath(APPLICATION->icons()->getDirectory(), true); +} + +void MainWindow::on_actionViewLogsFolder_triggered() +{ + DesktopServices::openPath("logs", true); +} + +void MainWindow::on_actionViewJavaFolder_triggered() +{ + DesktopServices::openPath(APPLICATION->javaPath(), true); +} + +void MainWindow::refreshInstances() +{ + APPLICATION->instances()->loadList(); +} + +void MainWindow::checkForUpdates() +{ + if (APPLICATION->updaterEnabled()) { + APPLICATION->triggerUpdateCheck(); + } else { + qWarning() << "Updater not set up. Cannot check for updates."; + } +} + +void MainWindow::on_actionSettings_triggered() +{ + APPLICATION->ShowGlobalSettings(this, "global-settings"); +} + +void MainWindow::globalSettingsClosed() +{ + // FIXME: quick HACK to make this work. improve, optimize. + APPLICATION->instances()->loadList(); + proxymodel->invalidate(); + proxymodel->sort(0); + updateMainToolBar(); + updateLaunchButton(); + updateThemeMenu(); + updateStatusCenter(); + // This needs to be done to prevent UI elements disappearing in the event the config is changed + // but Prism Launcher exits abnormally, causing the window state to never be saved: + APPLICATION->settings()->set("MainWindowState", QString::fromUtf8(saveState().toBase64())); + update(); +} + +void MainWindow::on_actionEditInstance_triggered() +{ + if (!m_selectedInstance) + return; + + if (m_selectedInstance->canEdit()) { + APPLICATION->showInstanceWindow(m_selectedInstance); + } else { + CustomMessageBox::selectable(this, tr("Instance not editable"), + tr("This instance is not editable. It may be broken, invalid, or too old. Check logs for details."), + QMessageBox::Critical) + ->show(); + } +} + +void MainWindow::on_actionManageAccounts_triggered() +{ + APPLICATION->ShowGlobalSettings(this, "accounts"); +} + +void MainWindow::on_actionReportBug_triggered() +{ + DesktopServices::openUrl(QUrl(BuildConfig.BUG_TRACKER_URL)); +} + +void MainWindow::on_actionClearMetadata_triggered() +{ + // This if contains side effects! + if (!APPLICATION->metacache()->evictAll()) { + CustomMessageBox::selectable(this, tr("Error"), + tr("Metadata cache clear Failed!\nTo clear the metadata cache manually, press Folders -> View " + "Launcher Root Folder, and after closing the launcher delete the folder named \"meta\"\n"), + QMessageBox::Warning) + ->show(); + } + + APPLICATION->metacache()->SaveNow(); +} + +#ifdef Q_OS_MAC +void MainWindow::on_actionAddToPATH_triggered() +{ + auto binaryPath = APPLICATION->applicationFilePath(); + auto targetPath = QString("/usr/local/bin/%1").arg(BuildConfig.LAUNCHER_APP_BINARY_NAME); + qDebug() << "Symlinking" << binaryPath << "to" << targetPath; + + QStringList args; + args << "-e"; + args << QString("do shell script \"mkdir -p /usr/local/bin && ln -sf '%1' '%2'\" with administrator privileges") + .arg(binaryPath, targetPath); + auto outcome = QProcess::execute("/usr/bin/osascript", args); + if (!outcome) { + QMessageBox::information(this, tr("Successfully added %1 to PATH").arg(BuildConfig.LAUNCHER_DISPLAYNAME), + tr("%1 was successfully added to your PATH. You can now start it by running `%2`.") + .arg(BuildConfig.LAUNCHER_DISPLAYNAME, BuildConfig.LAUNCHER_APP_BINARY_NAME)); + } else { + QMessageBox::critical(this, tr("Failed to add %1 to PATH").arg(BuildConfig.LAUNCHER_DISPLAYNAME), + tr("An error occurred while trying to add %1 to PATH").arg(BuildConfig.LAUNCHER_DISPLAYNAME)); + } +} +#endif + +void MainWindow::on_actionOpenWiki_triggered() +{ + DesktopServices::openUrl(QUrl(BuildConfig.WIKI_URL)); +} + +void MainWindow::on_actionMoreNews_triggered() +{ + auto entries = m_newsChecker->getNewsEntries(); + NewsDialog news_dialog(entries, this); + news_dialog.exec(); +} + +void MainWindow::newsButtonClicked() +{ + auto entries = m_newsChecker->getNewsEntries(); + NewsDialog news_dialog(entries, this); + news_dialog.toggleArticleList(); + news_dialog.exec(); +} + +void MainWindow::onCatChanged(int) +{ + setCatBackground(APPLICATION->settings()->get("TheCat").toBool()); +} + +void MainWindow::on_actionAbout_triggered() +{ + AboutDialog dialog(this); + dialog.exec(); +} + +void MainWindow::on_actionDeleteInstance_triggered() +{ + if (!m_selectedInstance) { + return; + } + + if (m_selectedInstance->isRunning()) { + CustomMessageBox::selectable(this, tr("Cannot Delete Running Instance"), + tr("The selected instance is currently running and cannot be deleted. Please stop the instance before " + "attempting to delete it."), + QMessageBox::Warning, QMessageBox::Ok) + ->exec(); + return; + } + auto id = m_selectedInstance->id(); + + QString shortcutStr; + auto shortcuts = m_selectedInstance->shortcuts(); + if (!shortcuts.isEmpty()) + shortcutStr = tr(" and its %n registered shortcut(s)", "", shortcuts.size()); + auto response = CustomMessageBox::selectable(this, tr("Confirm Deletion"), + tr("You are about to delete \"%1\"%2.\n" + "This may be permanent and will completely delete the instance.\n\n" + "Are you sure?") + .arg(m_selectedInstance->name(), shortcutStr), + QMessageBox::Warning, QMessageBox::Yes | QMessageBox::No, QMessageBox::No) + ->exec(); + + if (response != QMessageBox::Yes) + return; + + if (!checkLinkedInstances(id, this, tr("Deleting"))) + return; + + if (APPLICATION->instances()->trashInstance(id)) { + ui->actionUndoTrashInstance->setEnabled(APPLICATION->instances()->trashedSomething()); + } else { + APPLICATION->instances()->deleteInstance(id); + } + APPLICATION->settings()->set("SelectedInstance", QString()); + selectionBad(); +} + +void MainWindow::on_actionExportInstanceZip_triggered() +{ + if (m_selectedInstance) { + ExportInstanceDialog dlg(m_selectedInstance, this); + dlg.exec(); + } +} + +void MainWindow::on_actionExportInstanceMrPack_triggered() +{ + if (m_selectedInstance) { + auto instance = dynamic_cast(m_selectedInstance); + if (instance != nullptr) { + ExportPackDialog dlg(instance, this); + dlg.exec(); + } + } +} + +void MainWindow::on_actionExportInstanceFlamePack_triggered() +{ + if (m_selectedInstance) { + auto instance = dynamic_cast(m_selectedInstance); + if (instance) { + if (auto cmp = instance->getPackProfile()->getComponent("net.minecraft"); + cmp && cmp->getVersionFile() && cmp->getVersionFile()->type == "snapshot") { + QMessageBox msgBox(this); + msgBox.setText("Snapshots are currently not supported by CurseForge modpacks."); + msgBox.exec(); + return; + } + ExportPackDialog dlg(instance, this, ModPlatform::ResourceProvider::FLAME); + dlg.exec(); + } + } +} + +void MainWindow::on_actionRenameInstance_triggered() +{ + if (m_selectedInstance) { + view->edit(view->currentIndex()); + } +} + +void MainWindow::on_actionViewSelectedInstFolder_triggered() +{ + if (m_selectedInstance) { + QString str = m_selectedInstance->instanceRoot(); + DesktopServices::openPath(QFileInfo(str)); + } +} + +void MainWindow::closeEvent(QCloseEvent* event) +{ + // Save the window state and geometry. + APPLICATION->settings()->set("MainWindowState", QString::fromUtf8(saveState().toBase64())); + APPLICATION->settings()->set("MainWindowGeometry", QString::fromUtf8(saveGeometry().toBase64())); + instanceToolbarSetting->set(QString::fromUtf8(ui->instanceToolBar->getVisibilityState().toBase64())); + event->accept(); + emit isClosing(); +} + +void MainWindow::changeEvent(QEvent* event) +{ + if (event->type() == QEvent::LanguageChange) { + retranslateUi(); + } + QMainWindow::changeEvent(event); +} + +void MainWindow::instanceActivated(QModelIndex index) +{ + if (!index.isValid()) + return; + QString id = index.data(InstanceList::InstanceIDRole).toString(); + BaseInstance* inst = APPLICATION->instances()->getInstanceById(id); + if (!inst) + return; + + activateInstance(inst); +} + +void MainWindow::on_actionLaunchInstance_triggered() +{ + if (m_selectedInstance && !m_selectedInstance->isRunning()) { + APPLICATION->launch(m_selectedInstance); + } +} + +void MainWindow::activateInstance(BaseInstance* instance) +{ + APPLICATION->launch(instance); +} + +void MainWindow::on_actionKillInstance_triggered() +{ + if (m_selectedInstance && m_selectedInstance->isRunning()) { + APPLICATION->kill(m_selectedInstance); + } +} + +void MainWindow::on_actionCreateInstanceShortcut_triggered() +{ + if (!m_selectedInstance) + return; + + CreateShortcutDialog shortcutDlg(m_selectedInstance, this); + if (!shortcutDlg.exec()) + return; + shortcutDlg.createShortcut(); +} + +void MainWindow::taskEnd() +{ + QObject* sender = QObject::sender(); + if (sender == m_versionLoadTask) + m_versionLoadTask = NULL; + + sender->deleteLater(); +} + +void MainWindow::startTask(Task* task) +{ + connect(task, &Task::succeeded, this, &MainWindow::taskEnd); + connect(task, &Task::failed, this, &MainWindow::taskEnd); + task->start(); +} + +void MainWindow::instanceChanged(const QModelIndex& current, [[maybe_unused]] const QModelIndex& previous) +{ + if (!current.isValid()) { + APPLICATION->settings()->set("SelectedInstance", QString()); + selectionBad(); + return; + } + if (m_selectedInstance) { + disconnect(m_selectedInstance, &BaseInstance::runningStatusChanged, this, &MainWindow::refreshCurrentInstance); + disconnect(m_selectedInstance, &BaseInstance::profilerChanged, this, &MainWindow::refreshCurrentInstance); + } + QString id = current.data(InstanceList::InstanceIDRole).toString(); + m_selectedInstance = APPLICATION->instances()->getInstanceById(id); + if (m_selectedInstance) { + ui->instanceToolBar->setEnabled(true); + setInstanceActionsEnabled(true); + ui->actionLaunchInstance->setEnabled(m_selectedInstance->canLaunch()); + + ui->actionKillInstance->setEnabled(m_selectedInstance->isRunning()); + ui->actionExportInstance->setEnabled(m_selectedInstance->canExport()); + renameButton->setText(m_selectedInstance->name()); + m_statusLeft->setText(m_selectedInstance->getStatusbarDescription()); + updateStatusCenter(); + updateInstanceToolIcon(m_selectedInstance->iconKey()); + + updateLaunchButton(); + + APPLICATION->settings()->set("SelectedInstance", m_selectedInstance->id()); + + connect(m_selectedInstance, &BaseInstance::runningStatusChanged, this, &MainWindow::refreshCurrentInstance); + connect(m_selectedInstance, &BaseInstance::profilerChanged, this, &MainWindow::refreshCurrentInstance); + } else { + APPLICATION->settings()->set("SelectedInstance", QString()); + selectionBad(); + return; + } +} + +void MainWindow::instanceSelectRequest(QString id) +{ + setSelectedInstanceById(id); +} + +void MainWindow::instanceDataChanged(const QModelIndex& topLeft, const QModelIndex& bottomRight) +{ + auto current = view->selectionModel()->currentIndex(); + QItemSelection test(topLeft, bottomRight); + if (test.contains(current)) { + instanceChanged(current, current); + } +} + +void MainWindow::selectionBad() +{ + // start by reseting everything... + m_selectedInstance = nullptr; + m_statusLeft->setText(tr("No instance selected")); + + statusBar()->clearMessage(); + ui->instanceToolBar->setEnabled(false); + setInstanceActionsEnabled(false); + updateLaunchButton(); + renameButton->setText(tr("Rename Instance")); + updateInstanceToolIcon("grass"); + + // ...and then see if we can enable the previously selected instance + setSelectedInstanceById(APPLICATION->settings()->get("SelectedInstance").toString()); +} + +void MainWindow::checkInstancePathForProblems() +{ + QString instanceFolder = APPLICATION->settings()->get("InstanceDir").toString(); + if (FS::checkProblemticPathJava(QDir(instanceFolder))) { + QMessageBox warning(this); + warning.setText(tr("Your instance folder contains \'!\' and this is known to cause Java problems!")); + warning.setInformativeText(tr("You have now two options:
    " + " - change the instance folder in the settings
    " + " - move this installation of %1 to a different folder") + .arg(BuildConfig.LAUNCHER_DISPLAYNAME)); + warning.setDefaultButton(QMessageBox::Ok); + warning.exec(); + } + auto tempFolderText = + tr("This is a problem:
    " + " - The launcher will likely be deleted without warning by the operating system
    " + " - close the launcher now and extract it to a real location, not a temporary folder"); + QString pathfoldername = QDir(instanceFolder).absolutePath(); + if (pathfoldername.contains("Rar$", Qt::CaseInsensitive)) { + QMessageBox warning(this); + warning.setText(tr("Your instance folder contains \'Rar$\' - that means you haven't extracted the launcher archive!")); + warning.setInformativeText(tempFolderText); + warning.setDefaultButton(QMessageBox::Ok); + warning.exec(); + } else if (pathfoldername.startsWith(QDir::tempPath()) || pathfoldername.contains("/TempState/")) { + QMessageBox warning(this); + warning.setText(tr("Your instance folder is in a temporary folder: \'%1\'!").arg(QDir::tempPath())); + warning.setInformativeText(tempFolderText); + warning.setDefaultButton(QMessageBox::Ok); + warning.exec(); + } +} + +void MainWindow::updateStatusCenter() +{ + m_statusCenter->setVisible(APPLICATION->settings()->get("ShowGlobalGameTime").toBool()); + + int timePlayed = APPLICATION->instances()->getTotalPlayTime(); + m_statusCenter->setText(tr("Total: %1 min").arg(timePlayed / 60)); +} +// "Instance actions" are actions that require an instance to be selected (i.e. "new instance" is not here) +// Actions that also require other conditions (e.g. a running instance) won't be changed. +void MainWindow::setInstanceActionsEnabled(bool enabled) +{ + ui->actionEditInstance->setEnabled(enabled); + ui->actionChangeInstGroup->setEnabled(enabled); + ui->actionViewSelectedInstFolder->setEnabled(enabled); + ui->actionExportInstance->setEnabled(enabled); + ui->actionDeleteInstance->setEnabled(enabled); + ui->actionCopyInstance->setEnabled(enabled); + ui->actionCreateInstanceShortcut->setEnabled(enabled); +} + +void MainWindow::refreshCurrentInstance() +{ + auto current = view->selectionModel()->currentIndex(); + instanceChanged(current, current); +} diff --git a/launcher/ui/MainWindow.h b/launcher/ui/MainWindow.h new file mode 100644 index 0000000..6218e32 --- /dev/null +++ b/launcher/ui/MainWindow.h @@ -0,0 +1,251 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Authors: Andrew Okin + * Peterix + * Orochimarufan + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +#include +#include +#include + +#include "BaseInstance.h" +#include "minecraft/auth/MinecraftAccount.h" + +class LaunchController; +class NewsChecker; +class QToolButton; +class InstanceProxyModel; +class LabeledToolButton; +class QLabel; +class MinecraftLauncher; +class BaseProfilerFactory; +class InstanceView; +class KonamiCode; +class InstanceTask; +class LabeledToolButton; + +namespace Ui { +class MainWindow; +} +class MainWindow : public QMainWindow { + Q_OBJECT + + public: + explicit MainWindow(QWidget* parent = 0); + ~MainWindow(); + + bool eventFilter(QObject* obj, QEvent* ev) override; + void closeEvent(QCloseEvent* event) override; + void changeEvent(QEvent* event) override; + + void checkInstancePathForProblems(); + + void updatesAllowedChanged(bool allowed); + + void processURLs(QList urls); + signals: + void isClosing(); + + protected: + QMenu* createPopupMenu() override; + + private slots: + void onCatToggled(bool); + + void onCatChanged(int); + + void on_actionAbout_triggered(); + + void on_actionAddInstance_triggered(); + + void on_actionWebsite_triggered(); + + void on_actionCopyInstance_triggered(); + + void on_actionChangeInstGroup_triggered(); + + void on_actionChangeInstIcon_triggered(); + + void on_actionViewLauncherRootFolder_triggered(); + + void on_actionViewInstanceFolder_triggered(); + void on_actionViewCentralModsFolder_triggered(); + + void on_actionViewIconThemeFolder_triggered(); + void on_actionViewWidgetThemeFolder_triggered(); + void on_actionViewCatPackFolder_triggered(); + void on_actionViewIconsFolder_triggered(); + void on_actionViewLogsFolder_triggered(); + void on_actionViewJavaFolder_triggered(); + + void on_actionViewSkinsFolder_triggered(); + + void on_actionViewSelectedInstFolder_triggered(); + + void refreshInstances(); + + void checkForUpdates(); + + void on_actionSettings_triggered(); + + void on_actionManageAccounts_triggered(); + + void on_actionReportBug_triggered(); + + void on_actionClearMetadata_triggered(); + +#ifdef Q_OS_MAC + void on_actionAddToPATH_triggered(); +#endif + + void on_actionOpenWiki_triggered(); + + void on_actionMoreNews_triggered(); + + void newsButtonClicked(); + + void on_actionLaunchInstance_triggered(); + + void on_actionKillInstance_triggered(); + + void on_actionDeleteInstance_triggered(); + + void deleteGroup(QString group); + void renameGroup(QString group); + void undoTrashInstance(); + + inline void on_actionExportInstance_triggered() { on_actionExportInstanceZip_triggered(); } + void on_actionExportInstanceZip_triggered(); + void on_actionExportInstanceMrPack_triggered(); + void on_actionExportInstanceFlamePack_triggered(); + + void on_actionRenameInstance_triggered(); + + void on_actionEditInstance_triggered(); + + void on_actionCreateInstanceShortcut_triggered(); + + void taskEnd(); + + /** + * called when an icon is changed in the icon model. + */ + void iconUpdated(QString); + + void showInstanceContextMenu(const QPoint&); + + void updateMainToolBar(); + + void updateLaunchButton(); + + void updateThemeMenu(); + + void instanceActivated(QModelIndex); + + void instanceChanged(const QModelIndex& current, const QModelIndex& previous); + + void instanceSelectRequest(QString id); + + void instanceDataChanged(const QModelIndex& topLeft, const QModelIndex& bottomRight); + + void selectionBad(); + + void startTask(Task* task); + + void defaultAccountChanged(); + + void changeActiveAccount(); + + void repopulateAccountsMenu(); + + void updateNewsLabel(); + + void konamiTriggered(); + + void globalSettingsClosed(); + + void setStatusBarVisibility(bool); + + void lockToolbars(bool); + +#ifndef Q_OS_MAC + void keyReleaseEvent(QKeyEvent* event) override; +#endif + + void refreshCurrentInstance(); + + private: + void retranslateUi(); + + void addInstance(const QString& url = QString(), const QMap& extra_info = {}); + void activateInstance(BaseInstance* instance); + void setCatBackground(bool enabled); + void updateInstanceToolIcon(QString new_icon); + void setSelectedInstanceById(const QString& id); + void updateStatusCenter(); + void setInstanceActionsEnabled(bool enabled); + + void runModalTask(Task* task); + void instanceFromInstanceTask(InstanceTask* task); + + private: + Ui::MainWindow* ui; + // these are managed by Qt's memory management model! + InstanceView* view = nullptr; + InstanceProxyModel* proxymodel = nullptr; + QToolButton* newsLabel = nullptr; + QLabel* m_statusLeft = nullptr; + QLabel* m_statusCenter = nullptr; + LabeledToolButton* changeIconButton = nullptr; + LabeledToolButton* renameButton = nullptr; + QToolButton* helpMenuButton = nullptr; + KonamiCode* secretEventFilter = nullptr; + + std::shared_ptr instanceToolbarSetting = nullptr; + + unique_qobject_ptr m_newsChecker; + + BaseInstance* m_selectedInstance = nullptr; + QString m_currentInstIcon; + + // managed by the application object + Task* m_versionLoadTask = nullptr; +}; diff --git a/launcher/ui/MainWindow.ui b/launcher/ui/MainWindow.ui new file mode 100644 index 0000000..693ecd9 --- /dev/null +++ b/launcher/ui/MainWindow.ui @@ -0,0 +1,760 @@ + + + MainWindow + + + + 0 + 0 + 800 + 600 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + Main Toolbar + + + Qt::BottomToolBarArea|Qt::TopToolBarArea + + + Qt::ToolButtonTextBesideIcon + + + false + + + TopToolBarArea + + + false + + + + + + + + + + + + + + News Toolbar + + + Qt::BottomToolBarArea|Qt::TopToolBarArea + + + + 16 + 16 + + + + Qt::ToolButtonTextBesideIcon + + + false + + + BottomToolBarArea + + + false + + + + + + Instance Toolbar + + + Qt::LeftToolBarArea|Qt::RightToolBarArea + + + + 16 + 16 + + + + Qt::ToolButtonTextBesideIcon + + + false + + + true + + + RightToolBarArea + + + false + + + + + + + + + + + + + + + + 0 + 0 + 800 + 22 + + + + + &File + + + true + + + + + + + + + + + + + + + + + + + + &Edit + + + true + + + + + + &View + + + true + + + + + + + + + + + F&olders + + + true + + + + + + + + + + + + + + + + + + &Accounts + + + + + &Help + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + More News... + + + Open the development blog to read more news about %1. + + + + + true + + + + + + &Meow + + + It's a fluffy kitty :3 + + + + + true + + + Status Bar + + + + + true + + + Lock Toolbars + + + + + false + + + &Undo Last Instance Deletion + + + + + + + + Add Instanc&e... + + + Add a new instance. + + + + + + + + &Update... + + + Check for new updates for %1. + + + QAction::ApplicationSpecificRole + + + + + + + + Setti&ngs... + + + Change settings. + + + QAction::PreferencesRole + + + + + + + + &Manage Accounts... + + + + + + + + &Launch + + + Launch the selected instance. + + + + + + + + &Kill + + + Kill the running instance. + + + Ctrl+K + + + + + + + + Rename + + + Rename the selected instance. + + + + + + + + &Change Group... + + + Change the selected instance's group. + + + Ctrl+G + + + + + Change Icon + + + Change the selected instance's icon. + + + + + + + + &Edit... + + + Change the instance settings, mods and versions. + + + Ctrl+I + + + + + + + + &Folder + + + Open the selected instance's root folder in a file browser. + + + + + + + + Dele&te + + + Delete the selected instance. + + + false + + + + + + + + Cop&y... + + + Copy the selected instance. + + + Ctrl+D + + + + + + + + E&xport... + + + Export the selected instance to supported formats. + + + + + + + + Prism Launcher (zip) + + + + + + + + Modrinth (mrpack) + + + + + + + + CurseForge (zip) + + + + + + + + Create Shortcut + + + Creates a shortcut on a selected folder to launch the selected instance. + + + + + + + + No accounts added! + + + + + true + + + + + + No Default Account + + + Ctrl+0 + + + + + + + + Close &Window + + + Close the current window + + + QAction::QuitRole + + + + + + + + &Instances + + + Open the instances folder in a file browser. + + + + + + + + Launcher &Root + + + Open the launcher's root folder in a file browser. + + + + + + + + &Central Mods + + + Open the central mods folder in a file browser. + + + + + + + + &Skins + + + Open the skins folder in a file browser. + + + + + + + + Instance Icons + + + Open the instance icons folder in a file browser. + + + + + + + + Logs + + + Open the logs folder in a file browser. + + + + + Themes + + + + + + + + Report a Bug or Suggest a Feature + + + Open the bug tracker to report a bug with %1. + + + + + + + + &Website + + + Open racked.ru + + + + + + + + &About %1 + + + View information about %1. + + + QAction::AboutRole + + + + + + + + &Clear Metadata Cache + + + Clear cached metadata + + + + + + .. + + + View logs + + + View current and previous launcher logs + + + + + + + + Install to &PATH + + + Install a %1 symlink to /usr/local/bin + + + + + + + + Folders + + + Open one of the folders shared between instances. + + + + + + + + Help + + + Get help with %1 or Minecraft. + + + + + + + + Accounts + + + + + + + + %1 &Wiki + + + Open the %1 wiki + + + + + + + + &Widget Themes + + + Open the widget themes folder in a file browser. + + + + + + + + I&con Theme + + + Open the icon theme folder in a file browser. + + + + + + + + Cat Packs + + + Open the cat packs folder in a file browser. + + + + + + + + Java + + + Open the Java folder in a file browser. Only available if the built-in Java downloader is used. + + + + + + WideBar + QToolBar +
    ui/widgets/WideBar.h
    +
    +
    + + +
    diff --git a/launcher/ui/ToolTipFilter.cpp b/launcher/ui/ToolTipFilter.cpp new file mode 100644 index 0000000..367c392 --- /dev/null +++ b/launcher/ui/ToolTipFilter.cpp @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2026 Mark Deneen + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "ToolTipFilter.h" + +bool ToolTipFilter::eventFilter(QObject* obj, QEvent* ev) +{ + if (ev->type() == QEvent::ToolTip) { + return true; + } else { + return QObject::eventFilter(obj, ev); + } +} diff --git a/launcher/ui/ToolTipFilter.h b/launcher/ui/ToolTipFilter.h new file mode 100644 index 0000000..c5ab662 --- /dev/null +++ b/launcher/ui/ToolTipFilter.h @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2026 Mark Deneen + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include + +class ToolTipFilter : public QObject { + Q_OBJECT + protected: + bool eventFilter(QObject* obj, QEvent* event); +}; diff --git a/launcher/ui/ViewLogWindow.cpp b/launcher/ui/ViewLogWindow.cpp new file mode 100644 index 0000000..9d7d782 --- /dev/null +++ b/launcher/ui/ViewLogWindow.cpp @@ -0,0 +1,26 @@ +#include + +#include "ViewLogWindow.h" + +#include "ui/pages/instance/OtherLogsPage.h" + +ViewLogWindow::ViewLogWindow(QWidget* parent) + : QMainWindow(parent), m_page(new OtherLogsPage("launcher-logs", tr("Launcher Logs"), "Launcher-Logs", nullptr, parent)) +{ + setAttribute(Qt::WA_DeleteOnClose); + setWindowIcon(QIcon::fromTheme("log")); + setWindowTitle(tr("View Launcher Logs")); + setCentralWidget(m_page); + setMinimumSize(m_page->size()); + setContentsMargins(6, 6, 0, 6); // the "Other Logs" instance page has 6px padding on the right, + // to have equal padding in all directions in the dialog we add it to all other sides. + m_page->opened(); + show(); +} + +void ViewLogWindow::closeEvent(QCloseEvent* event) +{ + m_page->closed(); + emit isClosing(); + event->accept(); +} diff --git a/launcher/ui/ViewLogWindow.h b/launcher/ui/ViewLogWindow.h new file mode 100644 index 0000000..bb10683 --- /dev/null +++ b/launcher/ui/ViewLogWindow.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +#include "Application.h" + +class OtherLogsPage; + +class ViewLogWindow : public QMainWindow { + Q_OBJECT + + public: + explicit ViewLogWindow(QWidget* parent = nullptr); + + signals: + void isClosing(); + + protected: + void closeEvent(QCloseEvent*) override; + + private: + OtherLogsPage* m_page; +}; diff --git a/launcher/ui/dialogs/AboutDialog.cpp b/launcher/ui/dialogs/AboutDialog.cpp new file mode 100644 index 0000000..da42ae2 --- /dev/null +++ b/launcher/ui/dialogs/AboutDialog.cpp @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "AboutDialog.h" +#include +#include "Application.h" +#include "BuildConfig.h" +#include "Markdown.h" +#include "StringUtils.h" +#include "ui_AboutDialog.h" + +#include + +namespace { +QString getCreditsHtml() +{ + QFile dataFile(":/documents/credits.html"); + if (!dataFile.open(QIODevice::ReadOnly)) { + qWarning() << "Failed to open file" << dataFile.fileName() << "for reading:" << dataFile.errorString(); + return {}; + } + QString fileContent = QString::fromUtf8(dataFile.readAll()); + dataFile.close(); + + return fileContent.arg(QObject::tr("%1 Developers").arg(BuildConfig.LAUNCHER_DISPLAYNAME), QObject::tr("MultiMC Developers"), + QObject::tr("With special thanks to")); +} + +QString getLicenseHtml() +{ + QFile dataFile(":/documents/COPYING.md"); + if (dataFile.open(QIODevice::ReadOnly)) { + QString output = markdownToHTML(dataFile.readAll()); + dataFile.close(); + return output; + } else { + qWarning() << "Failed to open file" << dataFile.fileName() << "for reading:" << dataFile.errorString(); + return QString(); + } +} + +} // namespace + +AboutDialog::AboutDialog(QWidget* parent) : QDialog(parent), ui(new Ui::AboutDialog) +{ + ui->setupUi(this); + + QString launcherName = BuildConfig.LAUNCHER_DISPLAYNAME; + + setWindowTitle(tr("About %1").arg(launcherName)); + + QString chtml = getCreditsHtml(); + ui->creditsText->setHtml(StringUtils::htmlListPatch(chtml)); + + QString lhtml = getLicenseHtml(); + ui->licenseText->setHtml(StringUtils::htmlListPatch(lhtml)); + + ui->urlLabel->setOpenExternalLinks(true); + + ui->icon->setPixmap(APPLICATION->logo().pixmap(64)); + ui->title->setText(launcherName); + + ui->versionLabel->setText(BuildConfig.printableVersionString()); + + if (!BuildConfig.BUILD_PLATFORM.isEmpty()) + ui->platformLabel->setText(tr("Platform") + ": " + BuildConfig.BUILD_PLATFORM); + else + ui->platformLabel->setVisible(false); + + if (!BuildConfig.GIT_COMMIT.isEmpty()) + ui->commitLabel->setText(tr("Commit: %1").arg(BuildConfig.GIT_COMMIT)); + else + ui->commitLabel->setVisible(false); + + if (!BuildConfig.BUILD_DATE.isEmpty()) + ui->buildDateLabel->setText(tr("Build date: %1").arg(BuildConfig.BUILD_DATE)); + else + ui->buildDateLabel->setVisible(false); + + if (!BuildConfig.VERSION_CHANNEL.isEmpty()) + ui->channelLabel->setText(tr("Channel") + ": " + BuildConfig.VERSION_CHANNEL); + else + ui->channelLabel->setVisible(false); + + QString urlText("

    %1

    "); + ui->urlLabel->setText(urlText.arg(BuildConfig.LAUNCHER_GIT)); + + ui->copyLabel->setText(BuildConfig.LAUNCHER_COPYRIGHT); + + connect(ui->closeButton, &QPushButton::clicked, this, &AboutDialog::close); + + connect(ui->aboutQt, &QPushButton::clicked, &QApplication::aboutQt); +} + +AboutDialog::~AboutDialog() +{ + delete ui; +} diff --git a/launcher/ui/dialogs/AboutDialog.h b/launcher/ui/dialogs/AboutDialog.h new file mode 100644 index 0000000..5da686b --- /dev/null +++ b/launcher/ui/dialogs/AboutDialog.h @@ -0,0 +1,33 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +namespace Ui { +class AboutDialog; +} + +class AboutDialog : public QDialog { + Q_OBJECT + + public: + explicit AboutDialog(QWidget* parent = 0); + ~AboutDialog(); + + private: + Ui::AboutDialog* ui; +}; diff --git a/launcher/ui/dialogs/AboutDialog.ui b/launcher/ui/dialogs/AboutDialog.ui new file mode 100644 index 0000000..4a9eef0 --- /dev/null +++ b/launcher/ui/dialogs/AboutDialog.ui @@ -0,0 +1,335 @@ + + + AboutDialog + + + + 0 + 0 + 573 + 600 + + + + + 450 + 400 + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 0 + 0 + + + + + 64 + 64 + + + + + 64 + 64 + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + 15 + + + + Launcher + + + Qt::AlignCenter + + + + + + + IBeamCursor + + + Qt::TextSelectableByMouse + + + + + + + 0 + + + + About + + + + + + true + + + <html><head/><body><p>A custom launcher that makes managing Minecraft easier by allowing you to have multiple instances of Minecraft at once.</p></body></html> + + + Qt::AlignCenter + + + true + + + + + + + + 10 + + + + GIT URL + + + Qt::AlignCenter + + + Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse + + + + + + + + 8 + true + + + + COPYRIGHT + + + Qt::AlignCenter + + + + + + + Qt::Horizontal + + + + + + + IBeamCursor + + + Platform: + + + Qt::AlignCenter + + + Qt::TextSelectableByMouse + + + + + + + IBeamCursor + + + Build Date: + + + Qt::AlignCenter + + + Qt::TextSelectableByMouse + + + + + + + IBeamCursor + + + Commit: + + + Qt::AlignCenter + + + Qt::TextSelectableByMouse + + + + + + + IBeamCursor + + + Channel: + + + Qt::AlignCenter + + + Qt::TextSelectableByMouse + + + + + + + Qt::Vertical + + + + 20 + 212 + + + + + + + + + Credits + + + + + + true + + + + + + + + License + + + + + + + 0 + 0 + + + + + DejaVu Sans Mono + + + + true + + + Qt::TextBrowserInteraction + + + + + + + + + + + + + false + + + About Qt + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Close + + + + + + + + + tabWidget + creditsText + licenseText + aboutQt + closeButton + + + + diff --git a/launcher/ui/dialogs/BlockedModsDialog.cpp b/launcher/ui/dialogs/BlockedModsDialog.cpp new file mode 100644 index 0000000..4abaf6e --- /dev/null +++ b/launcher/ui/dialogs/BlockedModsDialog.cpp @@ -0,0 +1,448 @@ +// SPDX-FileCopyrightText: 2022 Sefa Eyeoglu +// SPDX-FileCopyrightText: 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> +// SPDX-FileCopyrightText: 2022 kumquat-ir <66188216+kumquat-ir@users.noreply.github.com> +// +// SPDX-License-Identifier: GPL-3.0-only + +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> + * Copyright (C) 2022 kumquat-ir <66188216+kumquat-ir@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "BlockedModsDialog.h" +#include "ui_BlockedModsDialog.h" + +#include "Application.h" +#include "settings/SettingsObject.h" +#include "modplatform/helpers/HashUtils.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +BlockedModsDialog::BlockedModsDialog(QWidget* parent, const QString& title, const QString& text, QList& mods, QString hash_type) + : QDialog(parent), ui(new Ui::BlockedModsDialog), m_mods(mods), m_hashType(hash_type) +{ + m_hashingTask = shared_qobject_ptr( + new ConcurrentTask("MakeHashesTask", APPLICATION->settings()->get("NumberOfConcurrentTasks").toInt())); + connect(m_hashingTask.get(), &Task::finished, this, &BlockedModsDialog::hashTaskFinished); + + ui->setupUi(this); + + ui->buttonBox->button(QDialogButtonBox::Cancel)->setText(tr("Cancel")); + ui->buttonBox->button(QDialogButtonBox::Ok)->setText(tr("OK")); + connect(ui->openMissingButton, &QPushButton::clicked, this, [this]() { openAll(true); }); + connect(ui->downloadFolderButton, &QPushButton::clicked, this, &BlockedModsDialog::addDownloadFolder); + + connect(&m_watcher, &QFileSystemWatcher::directoryChanged, this, &BlockedModsDialog::directoryChanged); + + qDebug() << "[Blocked Mods Dialog] Mods List:" << mods; + + // defer setup of file system watchers until after the dialog is shown + // this allows OS (namely macOS) permission prompts to show after the relevant dialog appears + QTimer::singleShot(0, this, [this] { + setupWatch(); + scanPaths(); + update(); + }); + + this->setWindowTitle(title); + ui->labelDescription->setText(text); + + // force all URL handling as external + connect(ui->textBrowserWatched, &QTextBrowser::anchorClicked, this, [](const QUrl url) { QDesktopServices::openUrl(url); }); + + setAcceptDrops(true); + + update(); +} + +BlockedModsDialog::~BlockedModsDialog() +{ + delete ui; +} + +void BlockedModsDialog::dragEnterEvent(QDragEnterEvent* e) +{ + if (e->mimeData()->hasUrls()) { + e->acceptProposedAction(); + } +} + +void BlockedModsDialog::dropEvent(QDropEvent* e) +{ + for (QUrl& url : e->mimeData()->urls()) { + if (url.scheme().isEmpty()) { // ensure isLocalFile() works correctly + url.setScheme("file"); + } + + if (!url.isLocalFile()) { // can't drop external files here. + continue; + } + + QString filePath = url.toLocalFile(); + qDebug() << "[Blocked Mods Dialog] Dropped file:" << filePath; + addHashTask(filePath); + + // watch for changes + QFileInfo file = QFileInfo(filePath); + QString path = file.dir().absolutePath(); + qDebug() << "[Blocked Mods Dialog] Adding watch path:" << path; + m_watcher.addPath(path); + } + scanPaths(); + update(); +} + +void BlockedModsDialog::done(int r) +{ + QDialog::done(r); + disconnect(&m_watcher, &QFileSystemWatcher::directoryChanged, this, &BlockedModsDialog::directoryChanged); +} + +void BlockedModsDialog::openAll(bool missingOnly) +{ + for (auto& mod : m_mods) { + if (!missingOnly || !mod.matched) { + QDesktopServices::openUrl(mod.websiteUrl); + } + } +} + +void BlockedModsDialog::addDownloadFolder() +{ + QString dir = + QFileDialog::getExistingDirectory(this, tr("Select directory where you downloaded the mods"), + QStandardPaths::writableLocation(QStandardPaths::DownloadLocation), QFileDialog::ShowDirsOnly); + qDebug() << "[Blocked Mods Dialog] Adding watch path:" << dir; + m_watcher.addPath(dir); + scanPath(dir, true); + update(); +} + +/// @brief update UI with current status of the blocked mod detection +void BlockedModsDialog::update() +{ + QString text; + QString span; + + for (auto& mod : m_mods) { + if (mod.matched) { + // ✔ -> html for HEAVY CHECK MARK : ✔ + span = QString(tr(" ✔ Found at %1 ")).arg(mod.localPath); + } else { + // ✘ -> html for HEAVY BALLOT X : ✘ + span = QString(tr(" ✘ Not Found ")); + } + text += QString(tr("%1: %2

    Hash: %3 %4


    ")).arg(mod.name, mod.websiteUrl, mod.hash, span); + } + + ui->textBrowserModsListing->setText(text); + + QString watching; + for (auto& dir : m_watcher.directories()) { + QUrl fileURL = QUrl::fromLocalFile(dir); + watching += QString("%2
    ").arg(fileURL.toString(), dir); + } + + ui->textBrowserWatched->setText(watching); + + if (allModsMatched()) { + ui->labelModsFound->setText("" + tr("All mods found")); + ui->openMissingButton->setDisabled(true); + ui->buttonBox->button(QDialogButtonBox::Ok)->setText(tr("OK")); + } else { + ui->labelModsFound->setText(tr("Please download the missing mods.")); + ui->openMissingButton->setDisabled(false); + ui->buttonBox->button(QDialogButtonBox::Ok)->setText(tr("Skip")); + } +} + +/// @brief Signal fired when a watched directory has changed +/// @param path the path to the changed directory +void BlockedModsDialog::directoryChanged(QString path) +{ + qDebug() << "[Blocked Mods Dialog] Directory changed:" << path; + validateMatchedMods(); + scanPath(path, true); +} + +/// @brief add the user downloads folder and the global mods folder to the filesystem watcher +void BlockedModsDialog::setupWatch() +{ + const QString downloadsFolder = APPLICATION->settings()->get("DownloadsDir").toString(); + const QString modsFolder = APPLICATION->settings()->get("CentralModsDir").toString(); + const bool downloadsFolderWatchRecursive = APPLICATION->settings()->get("DownloadsDirWatchRecursive").toBool(); + watchPath(downloadsFolder, downloadsFolderWatchRecursive); + watchPath(modsFolder, true); +} + +void BlockedModsDialog::watchPath(QString path, bool watch_recursive) +{ + auto to_watch = QFileInfo(path); + if (!to_watch.isReadable()) { + qWarning() << "[Blocked Mods Dialog] Failed to add Watch Path (unable to read):" << path; + return; + } + auto to_watch_path = to_watch.canonicalFilePath(); + if (m_watcher.directories().contains(to_watch_path)) + return; // don't watch the same path twice (no loops!) + + qDebug() << "[Blocked Mods Dialog] Adding Watch Path:" << path; + m_watcher.addPath(to_watch_path); + + if (!to_watch.isDir() || !watch_recursive) + return; + + QDirIterator it(to_watch_path, QDir::Filter::Dirs | QDir::Filter::NoDotAndDotDot, QDirIterator::NoIteratorFlags); + while (it.hasNext()) { + QString watch_dir = QDir(it.next()).canonicalPath(); // resolve symlinks and relative paths + watchPath(watch_dir, watch_recursive); + } +} + +/// @brief scan all watched folder +void BlockedModsDialog::scanPaths() +{ + for (auto& dir : m_watcher.directories()) { + scanPath(dir, false); + } + runHashTask(); +} + +/// @brief Scan the directory at path, skip paths that do not contain a file name +/// of a blocked mod we are looking for +/// @param path the directory to scan +void BlockedModsDialog::scanPath(QString path, bool start_task) +{ + QDir scan_dir(path); + QDirIterator scan_it(path, QDir::Filter::Files | QDir::Filter::Hidden, QDirIterator::NoIteratorFlags); + while (scan_it.hasNext()) { + QString file = scan_it.next(); + + if (!checkValidPath(file)) { + continue; + } + + addHashTask(file); + } + + if (start_task) { + runHashTask(); + } +} + +/// @brief add a hashing task for the file located at path, add the path to the pending set if the hashing task is already running +/// @param path the path to the local file being hashed +void BlockedModsDialog::addHashTask(QString path) +{ + qDebug() << "[Blocked Mods Dialog] adding a Hash task for" << path << "to the pending set."; + m_pendingHashPaths.insert(path); +} + +/// @brief add a hashing task for the file located at path and connect it to check that hash against +/// our blocked mods list +/// @param path the path to the local file being hashed +void BlockedModsDialog::buildHashTask(QString path) +{ + auto hash_task = Hashing::createHasher(path, m_hashType); + + qDebug() << "[Blocked Mods Dialog] Creating Hash task for path:" << path; + + connect(hash_task.get(), &Task::succeeded, this, [this, hash_task, path] { checkMatchHash(hash_task->getResult(), path); }); + connect(hash_task.get(), &Task::failed, this, [path] { qDebug() << "Failed to hash path:" << path; }); + + m_hashingTask->addTask(hash_task); +} + +/// @brief check if the computed hash for the provided path matches a blocked +/// mod we are looking for +/// @param hash the computed hash for the provided path +/// @param path the path to the local file being compared +void BlockedModsDialog::checkMatchHash(QString hash, QString path) +{ + bool match = false; + + qDebug() << "[Blocked Mods Dialog] Checking for match on hash:" << hash << "| From path:" << path; + + auto downloadDir = QFileInfo(APPLICATION->settings()->get("DownloadsDir").toString()).absoluteFilePath(); + auto moveFiles = APPLICATION->settings()->get("MoveModsFromDownloadsDir").toBool(); + for (auto& mod : m_mods) { + if (mod.matched) { + continue; + } + if (mod.hash.compare(hash, Qt::CaseInsensitive) == 0) { + mod.matched = true; + mod.localPath = path; + if (moveFiles) { + mod.move = QFileInfo(path).absoluteFilePath().startsWith(downloadDir); + } + match = true; + + qDebug() << "[Blocked Mods Dialog] Hash match found:" << mod.name << hash << "| From path:" << path; + + break; + } + } + + if (match) { + update(); + } +} + +/// @brief Check if the name of the file at path matches the name of a blocked mod we are searching for +/// @param path the path to check +/// @return boolean: did the path match the name of a blocked mod? +bool BlockedModsDialog::checkValidPath(QString path) +{ + const QFileInfo file = QFileInfo(path); + const QString filename = file.fileName(); + + auto compare = [](QString fsFilename, QString metadataFilename) { + return metadataFilename.compare(fsFilename, Qt::CaseInsensitive) == 0; + }; + + // super lax compare (but not fuzzy) + // convert to lowercase + // convert all speratores to whitespace + // simplify sequence of internal whitespace to a single space + // efectivly compare two strings ignoring all separators and case + auto laxCompare = [](QString fsfilename, QString metadataFilename) { + // allowed character seperators + QList allowedSeperators = { '-', '+', '.', '_' }; + + // copy in lowercase + auto fsName = fsfilename.toLower(); + auto metaName = metadataFilename.toLower(); + + // replace all potential allowed seperatores with whitespace + for (auto sep : allowedSeperators) { + fsName = fsName.replace(sep, ' '); + metaName = metaName.replace(sep, ' '); + } + + // remove extraneous whitespace + fsName = fsName.simplified(); + metaName = metaName.simplified(); + + return fsName.compare(metaName) == 0; + }; + + auto downloadDir = QFileInfo(APPLICATION->settings()->get("DownloadsDir").toString()).absoluteFilePath(); + auto moveFiles = APPLICATION->settings()->get("MoveModsFromDownloadsDir").toBool(); + for (auto& mod : m_mods) { + if (compare(filename, mod.name)) { + // if the mod is not yet matched and doesn't have a hash then + // just match it with the file that has the exact same name + if (!mod.matched && mod.hash.isEmpty()) { + mod.matched = true; + mod.localPath = path; + if (moveFiles) { + mod.move = QFileInfo(path).absoluteFilePath().startsWith(downloadDir); + } + return false; + } + qDebug() << "[Blocked Mods Dialog] Name match found:" << mod.name << "| From path:" << path; + return true; + } + if (laxCompare(filename, mod.name)) { + qDebug() << "[Blocked Mods Dialog] Lax name match found:" << mod.name << "| From path:" << path; + return true; + } + } + + return false; +} + +bool BlockedModsDialog::allModsMatched() +{ + return std::all_of(m_mods.begin(), m_mods.end(), [](auto const& mod) { return mod.matched; }); +} + +/// @brief ensure matched file paths still exist +void BlockedModsDialog::validateMatchedMods() +{ + bool changed = false; + for (auto& mod : m_mods) { + if (mod.matched) { + QFileInfo file = QFileInfo(mod.localPath); + if (!file.exists() || !file.isFile()) { + qDebug() << "[Blocked Mods Dialog] File" << mod.localPath << "for mod" << mod.name + << "has vanshed! marking as not matched."; + mod.localPath = ""; + mod.matched = false; + changed = true; + } + } + } + if (changed) { + update(); + } +} + +/// @brief run hash task or mark a pending run if it is already running +void BlockedModsDialog::runHashTask() +{ + if (!m_hashingTask->isRunning()) { + m_rehashPending = false; + + if (!m_pendingHashPaths.isEmpty()) { + qDebug() << "[Blocked Mods Dialog] there are pending hash tasks, building and running tasks"; + + auto path = m_pendingHashPaths.begin(); + while (path != m_pendingHashPaths.end()) { + buildHashTask(*path); + path = m_pendingHashPaths.erase(path); + } + + m_hashingTask->start(); + } + } else { + qDebug() << "[Blocked Mods Dialog] queueing another run of the hashing task"; + qDebug() << "[Blocked Mods Dialog] pending hash tasks:" << m_pendingHashPaths; + m_rehashPending = true; + } +} + +void BlockedModsDialog::hashTaskFinished() +{ + qDebug() << "[Blocked Mods Dialog] All hash tasks finished"; + if (m_rehashPending) { + qDebug() << "[Blocked Mods Dialog] task finished with a rehash pending, rerunning"; + runHashTask(); + } +} + +/// qDebug print support for the BlockedMod struct +QDebug operator<<(QDebug debug, const BlockedMod& m) +{ + QDebugStateSaver saver(debug); + + debug.nospace() << "{ name: " << m.name << ", websiteUrl: " << m.websiteUrl << ", hash: " << m.hash << ", matched: " << m.matched + << ", localPath: " << m.localPath << "}"; + + return debug; +} diff --git a/launcher/ui/dialogs/BlockedModsDialog.h b/launcher/ui/dialogs/BlockedModsDialog.h new file mode 100644 index 0000000..15d4d47 --- /dev/null +++ b/launcher/ui/dialogs/BlockedModsDialog.h @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: 2022 Sefa Eyeoglu +// SPDX-FileCopyrightText: 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> +// SPDX-FileCopyrightText: 2022 kumquat-ir <66188216+kumquat-ir@users.noreply.github.com> +// +// SPDX-License-Identifier: GPL-3.0-only + +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> + * Copyright (C) 2022 kumquat-ir <66188216+kumquat-ir@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include +#include + +#include + +#include "tasks/ConcurrentTask.h" + +class QPushButton; + +struct BlockedMod { + QString name; + QString websiteUrl; + QString hash; + bool matched; + QString localPath; + QString targetFolder; + bool disabled = false; + bool move = false; +}; + +QT_BEGIN_NAMESPACE +namespace Ui { +class BlockedModsDialog; +} +QT_END_NAMESPACE + +class BlockedModsDialog : public QDialog { + Q_OBJECT + + public: + BlockedModsDialog(QWidget* parent, const QString& title, const QString& text, QList& mods, QString hash_type = "sha1"); + + ~BlockedModsDialog() override; + + protected: + void dragEnterEvent(QDragEnterEvent* event) override; + void dropEvent(QDropEvent* event) override; + + protected slots: + void done(int r) override; + + private: + Ui::BlockedModsDialog* ui; + QList& m_mods; + QFileSystemWatcher m_watcher; + shared_qobject_ptr m_hashingTask; + QSet m_pendingHashPaths; + bool m_rehashPending; + QString m_hashType; + + void openAll(bool missingOnly); + void addDownloadFolder(); + void update(); + void directoryChanged(QString path); + void setupWatch(); + void watchPath(QString path, bool watch_recursive = false); + void scanPaths(); + void scanPath(QString path, bool start_task); + void addHashTask(QString path); + void buildHashTask(QString path); + void checkMatchHash(QString hash, QString path); + void validateMatchedMods(); + void runHashTask(); + void hashTaskFinished(); + + bool checkValidPath(QString path); + bool allModsMatched(); +}; + +QDebug operator<<(QDebug debug, const BlockedMod& m); diff --git a/launcher/ui/dialogs/BlockedModsDialog.ui b/launcher/ui/dialogs/BlockedModsDialog.ui new file mode 100644 index 0000000..850ad71 --- /dev/null +++ b/launcher/ui/dialogs/BlockedModsDialog.ui @@ -0,0 +1,205 @@ + + + BlockedModsDialog + + + + 0 + 0 + 800 + 500 + + + + + 2 + 1 + + + + + 700 + 350 + + + + BlockedModsDialog + + + + + + Placeholder description + + + Qt::RichText + + + true + + + + + + + <html><head/><body><p>Your configured global mods folder and default downloads folder are automatically checked for the downloaded mods and they will be copied to the instance if found.</p><p>Optionally, you may drag and drop the downloaded mods onto this dialog or add a folder to watch if you did not download the mods to a default location.</p><p><span style=" font-weight:600;">Click 'Open Missing' to open all the download links in the browser. </span></p></body></html> + + + true + + + + + + + 0 + + + + Blocked Mods + + + + + + true + + + true + + + + + + + + + Open Missing + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + Watched Folders + + + + + + + 0 + 12 + + + + true + + + false + + + + + + + + + Add Download Folder + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + + + buttonBox + accepted() + BlockedModsDialog + accept() + + + 199 + 425 + + + 199 + 227 + + + + + buttonBox + rejected() + BlockedModsDialog + reject() + + + 199 + 425 + + + 199 + 227 + + + + + diff --git a/launcher/ui/dialogs/ChooseOfflineNameDialog.cpp b/launcher/ui/dialogs/ChooseOfflineNameDialog.cpp new file mode 100644 index 0000000..6826804 --- /dev/null +++ b/launcher/ui/dialogs/ChooseOfflineNameDialog.cpp @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2025 Octol1ttle + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "ChooseOfflineNameDialog.h" + +#include +#include + +#include "ui_ChooseOfflineNameDialog.h" + +ChooseOfflineNameDialog::ChooseOfflineNameDialog(const QString& message, QWidget* parent) + : QDialog(parent), ui(new Ui::ChooseOfflineNameDialog) +{ + ui->setupUi(this); + Q_UNUSED(message); + + ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false); + ui->buttonBox->button(QDialogButtonBox::Cancel)->setText(tr("Cancel")); + ui->buttonBox->button(QDialogButtonBox::Ok)->setText(tr("OK")); + + const QRegularExpression usernameRegExp("^[A-Za-z0-9_]{3,16}$"); + m_usernameValidator = new QRegularExpressionValidator(usernameRegExp, this); + ui->usernameTextBox->setValidator(m_usernameValidator); + + connect(ui->buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(ui->buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); +} + +ChooseOfflineNameDialog::~ChooseOfflineNameDialog() +{ + delete ui; +} + +QString ChooseOfflineNameDialog::getUsername() const +{ + return ui->usernameTextBox->text(); +} + +void ChooseOfflineNameDialog::setUsername(const QString& username) const +{ + ui->usernameTextBox->setText(username); + updateAcceptAllowed(username); +} + +void ChooseOfflineNameDialog::updateAcceptAllowed(const QString& username) const +{ + Q_UNUSED(username); + ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(ui->usernameTextBox->hasAcceptableInput()); +} + +void ChooseOfflineNameDialog::on_usernameTextBox_textEdited(const QString& newText) const +{ + updateAcceptAllowed(newText); +} diff --git a/launcher/ui/dialogs/ChooseOfflineNameDialog.h b/launcher/ui/dialogs/ChooseOfflineNameDialog.h new file mode 100644 index 0000000..4296cdb --- /dev/null +++ b/launcher/ui/dialogs/ChooseOfflineNameDialog.h @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2025 Octol1ttle + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include + +QT_BEGIN_NAMESPACE +namespace Ui { +class ChooseOfflineNameDialog; +} +QT_END_NAMESPACE + +class ChooseOfflineNameDialog final : public QDialog { + Q_OBJECT + + public: + explicit ChooseOfflineNameDialog(const QString& message, QWidget* parent = nullptr); + ~ChooseOfflineNameDialog() override; + + QString getUsername() const; + void setUsername(const QString& username) const; + + private: + void updateAcceptAllowed(const QString& username) const; + + protected slots: + void on_usernameTextBox_textEdited(const QString& newText) const; + + private: + Ui::ChooseOfflineNameDialog* ui; + QRegularExpressionValidator* m_usernameValidator; +}; diff --git a/launcher/ui/dialogs/ChooseOfflineNameDialog.ui b/launcher/ui/dialogs/ChooseOfflineNameDialog.ui new file mode 100644 index 0000000..6d5c017 --- /dev/null +++ b/launcher/ui/dialogs/ChooseOfflineNameDialog.ui @@ -0,0 +1,35 @@ + + + ChooseOfflineNameDialog + + + + 0 + 0 + 400 + 110 + + + + Pick a username + + + + + + Username + + + + + + + QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok + + + + + + + + diff --git a/launcher/ui/dialogs/ChooseProviderDialog.cpp b/launcher/ui/dialogs/ChooseProviderDialog.cpp new file mode 100644 index 0000000..6845780 --- /dev/null +++ b/launcher/ui/dialogs/ChooseProviderDialog.cpp @@ -0,0 +1,94 @@ +#include "ChooseProviderDialog.h" +#include "ui_ChooseProviderDialog.h" + +#include +#include + +#include "modplatform/ModIndex.h" + +ChooseProviderDialog::ChooseProviderDialog(QWidget* parent, bool single_choice, bool allow_skipping) + : QDialog(parent), ui(new Ui::ChooseProviderDialog) +{ + ui->setupUi(this); + + addProviders(); + m_providers.button(0)->click(); + + connect(ui->skipOneButton, &QPushButton::clicked, this, &ChooseProviderDialog::skipOne); + connect(ui->skipAllButton, &QPushButton::clicked, this, &ChooseProviderDialog::skipAll); + + connect(ui->confirmOneButton, &QPushButton::clicked, this, &ChooseProviderDialog::confirmOne); + connect(ui->confirmAllButton, &QPushButton::clicked, this, &ChooseProviderDialog::confirmAll); + + if (single_choice) { + ui->providersLayout->removeWidget(ui->skipAllButton); + ui->providersLayout->removeWidget(ui->confirmAllButton); + } + + if (!allow_skipping) { + ui->providersLayout->removeWidget(ui->skipOneButton); + ui->providersLayout->removeWidget(ui->skipAllButton); + } +} + +ChooseProviderDialog::~ChooseProviderDialog() +{ + delete ui; +} + +void ChooseProviderDialog::setDescription(QString desc) +{ + ui->explanationLabel->setText(desc); +} + +void ChooseProviderDialog::skipOne() +{ + reject(); +} +void ChooseProviderDialog::skipAll() +{ + m_response.skip_all = true; + reject(); +} + +void ChooseProviderDialog::confirmOne() +{ + m_response.chosen = getSelectedProvider(); + m_response.try_others = ui->tryOthersCheckbox->isChecked(); + accept(); +} +void ChooseProviderDialog::confirmAll() +{ + m_response.chosen = getSelectedProvider(); + m_response.confirm_all = true; + m_response.try_others = ui->tryOthersCheckbox->isChecked(); + accept(); +} + +auto ChooseProviderDialog::getSelectedProvider() const -> ModPlatform::ResourceProvider +{ + return ModPlatform::ResourceProvider(m_providers.checkedId()); +} + +void ChooseProviderDialog::addProviders() +{ + int btn_index = 0; + QRadioButton* btn; + + for (auto& provider : { ModPlatform::ResourceProvider::MODRINTH, ModPlatform::ResourceProvider::FLAME }) { + btn = new QRadioButton(ModPlatform::ProviderCapabilities::readableName(provider), this); + m_providers.addButton(btn, btn_index++); + ui->providersLayout->addWidget(btn); + } +} + +void ChooseProviderDialog::disableInput() +{ + for (auto& btn : m_providers.buttons()) + btn->setEnabled(false); + + ui->skipOneButton->setEnabled(false); + ui->skipAllButton->setEnabled(false); + ui->confirmOneButton->setEnabled(false); + ui->confirmAllButton->setEnabled(false); +} diff --git a/launcher/ui/dialogs/ChooseProviderDialog.h b/launcher/ui/dialogs/ChooseProviderDialog.h new file mode 100644 index 0000000..3d602de --- /dev/null +++ b/launcher/ui/dialogs/ChooseProviderDialog.h @@ -0,0 +1,56 @@ +#pragma once + +#include +#include +#include + +namespace Ui { +class ChooseProviderDialog; +} + +namespace ModPlatform { +enum class ResourceProvider : std::uint8_t; +} + +class Mod; +class NetJob; + +class ChooseProviderDialog : public QDialog { + Q_OBJECT + + struct Response { + bool skip_all = false; + bool confirm_all = false; + + bool try_others = false; + + ModPlatform::ResourceProvider chosen; + }; + + public: + explicit ChooseProviderDialog(QWidget* parent, bool single_choice = false, bool allow_skipping = true); + ~ChooseProviderDialog(); + + auto getResponse() const -> Response { return m_response; } + + void setDescription(QString desc); + + private slots: + void skipOne(); + void skipAll(); + void confirmOne(); + void confirmAll(); + + private: + void addProviders(); + void disableInput(); + + auto getSelectedProvider() const -> ModPlatform::ResourceProvider; + + private: + Ui::ChooseProviderDialog* ui; + + QButtonGroup m_providers; + + Response m_response; +}; diff --git a/launcher/ui/dialogs/ChooseProviderDialog.ui b/launcher/ui/dialogs/ChooseProviderDialog.ui new file mode 100644 index 0000000..78cd961 --- /dev/null +++ b/launcher/ui/dialogs/ChooseProviderDialog.ui @@ -0,0 +1,89 @@ + + + ChooseProviderDialog + + + + 0 + 0 + 453 + 197 + + + + Choose a mod provider + + + + + + Qt::AlignJustify|Qt::AlignTop + + + true + + + -1 + + + + + + + Qt::AlignHCenter|Qt::AlignTop + + + Qt::AlignHCenter|Qt::AlignTop + + + + + + + + + Skip this mod + + + + + + + Skip all + + + + + + + Confirm for all + + + + + + + Confirm + + + true + + + + + + + + + Try to automatically use other providers if the chosen one fails + + + true + + + + + + + + diff --git a/launcher/ui/dialogs/CopyInstanceDialog.cpp b/launcher/ui/dialogs/CopyInstanceDialog.cpp new file mode 100644 index 0000000..74fab34 --- /dev/null +++ b/launcher/ui/dialogs/CopyInstanceDialog.cpp @@ -0,0 +1,313 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include + +#include "Application.h" +#include "BuildConfig.h" +#include "CopyInstanceDialog.h" +#include "ui_CopyInstanceDialog.h" + +#include "ui/dialogs/IconPickerDialog.h" + +#include "BaseInstance.h" +#include "BaseVersion.h" +#include "DesktopServices.h" +#include "FileSystem.h" +#include "InstanceList.h" +#include "icons/IconList.h" + +CopyInstanceDialog::CopyInstanceDialog(BaseInstance* original, QWidget* parent) + : QDialog(parent), ui(new Ui::CopyInstanceDialog), m_original(original) +{ + ui->setupUi(this); + resize(minimumSizeHint()); + layout()->setSizeConstraint(QLayout::SetFixedSize); + + InstIconKey = original->iconKey(); + ui->iconButton->setIcon(APPLICATION->icons()->getIcon(InstIconKey)); + ui->instNameTextBox->setText(original->name()); + ui->instNameTextBox->setFocus(); + + QStringList groups = APPLICATION->instances()->getGroups(); + groups.prepend(""); + ui->groupBox->addItems(groups); + int index = groups.indexOf(APPLICATION->instances()->getInstanceGroup(m_original->id())); + if (index == -1) + index = 0; + + ui->groupBox->setCurrentIndex(index); + ui->groupBox->lineEdit()->setPlaceholderText(tr("No group")); + ui->copySavesCheckbox->setChecked(m_selectedOptions.isCopySavesEnabled()); + ui->keepPlaytimeCheckbox->setChecked(m_selectedOptions.isKeepPlaytimeEnabled()); + ui->copyGameOptionsCheckbox->setChecked(m_selectedOptions.isCopyGameOptionsEnabled()); + ui->copyResPacksCheckbox->setChecked(m_selectedOptions.isCopyResourcePacksEnabled()); + ui->copyShaderPacksCheckbox->setChecked(m_selectedOptions.isCopyShaderPacksEnabled()); + ui->copyServersCheckbox->setChecked(m_selectedOptions.isCopyServersEnabled()); + ui->copyModsCheckbox->setChecked(m_selectedOptions.isCopyModsEnabled()); + ui->copyScreenshotsCheckbox->setChecked(m_selectedOptions.isCopyScreenshotsEnabled()); + + ui->symbolicLinksCheckbox->setChecked(m_selectedOptions.isUseSymLinksEnabled()); + ui->hardLinksCheckbox->setChecked(m_selectedOptions.isUseHardLinksEnabled()); + + ui->recursiveLinkCheckbox->setChecked(m_selectedOptions.isLinkRecursivelyEnabled()); + ui->dontLinkSavesCheckbox->setChecked(m_selectedOptions.isDontLinkSavesEnabled()); + + auto detectedFS = FS::statFS(m_original->instanceRoot()).fsType; + + m_cloneSupported = FS::canCloneOnFS(detectedFS); + m_linkSupported = FS::canLinkOnFS(detectedFS); + + if (m_cloneSupported) { + ui->cloneSupportedLabel->setText(tr("Reflinks are supported on %1").arg(FS::getFilesystemTypeName(detectedFS))); + } else { + ui->cloneSupportedLabel->setText(tr("Reflinks aren't supported on %1").arg(FS::getFilesystemTypeName(detectedFS))); + } + +#if defined(Q_OS_WIN) + ui->symbolicLinksCheckbox->setIcon(style()->standardIcon(QStyle::SP_VistaShield)); + ui->symbolicLinksCheckbox->setToolTip(tr("Use symbolic links instead of copying files.") + "\n" + + tr("On Windows, symbolic links may require admin permission to create.")); +#endif + + updateLinkOptions(); + updateUseCloneCheckbox(); + + auto HelpButton = ui->buttonBox->button(QDialogButtonBox::Help); + connect(HelpButton, &QPushButton::clicked, this, &CopyInstanceDialog::help); + HelpButton->setText(tr("Help")); + ui->buttonBox->button(QDialogButtonBox::Cancel)->setText(tr("Cancel")); + ui->buttonBox->button(QDialogButtonBox::Ok)->setText(tr("OK")); +} + +CopyInstanceDialog::~CopyInstanceDialog() +{ + delete ui; +} + +void CopyInstanceDialog::updateDialogState() +{ + auto allowOK = !instName().isEmpty(); + auto OkButton = ui->buttonBox->button(QDialogButtonBox::Ok); + if (OkButton->isEnabled() != allowOK) { + OkButton->setEnabled(allowOK); + } +} + +QString CopyInstanceDialog::instName() const +{ + auto result = ui->instNameTextBox->text().trimmed(); + if (result.size()) { + return result; + } + return QString(); +} + +QString CopyInstanceDialog::iconKey() const +{ + return InstIconKey; +} + +QString CopyInstanceDialog::instGroup() const +{ + return ui->groupBox->currentText(); +} + +const InstanceCopyPrefs& CopyInstanceDialog::getChosenOptions() const +{ + return m_selectedOptions; +} + +void CopyInstanceDialog::help() +{ + DesktopServices::openUrl(QUrl(BuildConfig.HELP_URL.arg("instance-copy"))); +} + +void CopyInstanceDialog::checkAllCheckboxes(const bool& b) +{ + ui->keepPlaytimeCheckbox->setChecked(b); + ui->copySavesCheckbox->setChecked(b); + ui->copyGameOptionsCheckbox->setChecked(b); + ui->copyResPacksCheckbox->setChecked(b); + ui->copyShaderPacksCheckbox->setChecked(b); + ui->copyServersCheckbox->setChecked(b); + ui->copyModsCheckbox->setChecked(b); + ui->copyScreenshotsCheckbox->setChecked(b); +} + +// Check the "Select all" checkbox if all options are already selected: +void CopyInstanceDialog::updateSelectAllCheckbox() +{ + ui->selectAllCheckbox->blockSignals(true); + ui->selectAllCheckbox->setChecked(m_selectedOptions.allTrue()); + ui->selectAllCheckbox->blockSignals(false); +} + +void CopyInstanceDialog::updateUseCloneCheckbox() +{ + ui->useCloneCheckbox->setEnabled(m_cloneSupported && !ui->symbolicLinksCheckbox->isChecked() && !ui->hardLinksCheckbox->isChecked()); + ui->useCloneCheckbox->setChecked(m_cloneSupported && m_selectedOptions.isUseCloneEnabled() && !ui->symbolicLinksCheckbox->isChecked() && + !ui->hardLinksCheckbox->isChecked()); +} + +void CopyInstanceDialog::updateLinkOptions() +{ + ui->symbolicLinksCheckbox->setEnabled(m_linkSupported && !ui->hardLinksCheckbox->isChecked() && !ui->useCloneCheckbox->isChecked()); + ui->hardLinksCheckbox->setEnabled(m_linkSupported && !ui->symbolicLinksCheckbox->isChecked() && !ui->useCloneCheckbox->isChecked()); + + ui->symbolicLinksCheckbox->setChecked(m_linkSupported && m_selectedOptions.isUseSymLinksEnabled() && + !ui->useCloneCheckbox->isChecked()); + ui->hardLinksCheckbox->setChecked(m_linkSupported && m_selectedOptions.isUseHardLinksEnabled() && !ui->useCloneCheckbox->isChecked()); + + bool linksInUse = (ui->symbolicLinksCheckbox->isChecked() || ui->hardLinksCheckbox->isChecked()); + ui->recursiveLinkCheckbox->setEnabled(m_linkSupported && linksInUse && !ui->hardLinksCheckbox->isChecked()); + ui->dontLinkSavesCheckbox->setEnabled(m_linkSupported && linksInUse); + ui->recursiveLinkCheckbox->setChecked(m_linkSupported && linksInUse && m_selectedOptions.isLinkRecursivelyEnabled()); + ui->dontLinkSavesCheckbox->setChecked(m_linkSupported && linksInUse && m_selectedOptions.isDontLinkSavesEnabled()); + +#if defined(Q_OS_WIN) + auto OkButton = ui->buttonBox->button(QDialogButtonBox::Ok); + OkButton->setIcon(m_selectedOptions.isUseSymLinksEnabled() ? style()->standardIcon(QStyle::SP_VistaShield) : QIcon()); +#endif +} + +void CopyInstanceDialog::on_iconButton_clicked() +{ + IconPickerDialog dlg(this); + dlg.execWithSelection(InstIconKey); + + if (dlg.result() == QDialog::Accepted) { + InstIconKey = dlg.selectedIconKey; + ui->iconButton->setIcon(APPLICATION->icons()->getIcon(InstIconKey)); + } +} + +void CopyInstanceDialog::on_instNameTextBox_textChanged([[maybe_unused]] const QString& arg1) +{ + updateDialogState(); +} + +void CopyInstanceDialog::on_selectAllCheckbox_stateChanged(int state) +{ + bool checked; + checked = (state == Qt::Checked); + checkAllCheckboxes(checked); +} + +void CopyInstanceDialog::on_copySavesCheckbox_stateChanged(int state) +{ + m_selectedOptions.enableCopySaves(state == Qt::Checked); + ui->dontLinkSavesCheckbox->setChecked((state == Qt::Checked) && ui->dontLinkSavesCheckbox->isChecked()); + updateSelectAllCheckbox(); +} + +void CopyInstanceDialog::on_keepPlaytimeCheckbox_stateChanged(int state) +{ + m_selectedOptions.enableKeepPlaytime(state == Qt::Checked); + updateSelectAllCheckbox(); +} + +void CopyInstanceDialog::on_copyGameOptionsCheckbox_stateChanged(int state) +{ + m_selectedOptions.enableCopyGameOptions(state == Qt::Checked); + updateSelectAllCheckbox(); +} + +void CopyInstanceDialog::on_copyResPacksCheckbox_stateChanged(int state) +{ + m_selectedOptions.enableCopyResourcePacks(state == Qt::Checked); + updateSelectAllCheckbox(); +} + +void CopyInstanceDialog::on_copyShaderPacksCheckbox_stateChanged(int state) +{ + m_selectedOptions.enableCopyShaderPacks(state == Qt::Checked); + updateSelectAllCheckbox(); +} + +void CopyInstanceDialog::on_copyServersCheckbox_stateChanged(int state) +{ + m_selectedOptions.enableCopyServers(state == Qt::Checked); + updateSelectAllCheckbox(); +} + +void CopyInstanceDialog::on_copyModsCheckbox_stateChanged(int state) +{ + m_selectedOptions.enableCopyMods(state == Qt::Checked); + updateSelectAllCheckbox(); +} + +void CopyInstanceDialog::on_copyScreenshotsCheckbox_stateChanged(int state) +{ + m_selectedOptions.enableCopyScreenshots(state == Qt::Checked); + updateSelectAllCheckbox(); +} + +void CopyInstanceDialog::on_symbolicLinksCheckbox_stateChanged(int state) +{ + m_selectedOptions.enableUseSymLinks(state == Qt::Checked); + updateUseCloneCheckbox(); + updateLinkOptions(); +} + +void CopyInstanceDialog::on_hardLinksCheckbox_stateChanged(int state) +{ + m_selectedOptions.enableUseHardLinks(state == Qt::Checked); + if (state == Qt::Checked && !ui->recursiveLinkCheckbox->isChecked()) { + ui->recursiveLinkCheckbox->setChecked(true); + } + updateUseCloneCheckbox(); + updateLinkOptions(); +} + +void CopyInstanceDialog::on_recursiveLinkCheckbox_stateChanged(int state) +{ + m_selectedOptions.enableLinkRecursively(state == Qt::Checked); + updateLinkOptions(); +} + +void CopyInstanceDialog::on_dontLinkSavesCheckbox_stateChanged(int state) +{ + m_selectedOptions.enableDontLinkSaves(state == Qt::Checked); +} + +void CopyInstanceDialog::on_useCloneCheckbox_stateChanged(int state) +{ + m_selectedOptions.enableUseClone(m_cloneSupported && (state == Qt::Checked)); + updateUseCloneCheckbox(); + updateLinkOptions(); +} diff --git a/launcher/ui/dialogs/CopyInstanceDialog.h b/launcher/ui/dialogs/CopyInstanceDialog.h new file mode 100644 index 0000000..5f150cf --- /dev/null +++ b/launcher/ui/dialogs/CopyInstanceDialog.h @@ -0,0 +1,78 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include "BaseInstance.h" +#include "BaseVersion.h" +#include "InstanceCopyPrefs.h" + +class BaseInstance; + +namespace Ui { +class CopyInstanceDialog; +} + +class CopyInstanceDialog : public QDialog { + Q_OBJECT + + public: + explicit CopyInstanceDialog(BaseInstance* original, QWidget* parent = 0); + ~CopyInstanceDialog(); + + void updateDialogState(); + + QString instName() const; + QString instGroup() const; + QString iconKey() const; + const InstanceCopyPrefs& getChosenOptions() const; + + public slots: + void help(); + + private slots: + void on_iconButton_clicked(); + void on_instNameTextBox_textChanged(const QString& arg1); + // Checkboxes + void on_selectAllCheckbox_stateChanged(int state); + void on_copySavesCheckbox_stateChanged(int state); + void on_keepPlaytimeCheckbox_stateChanged(int state); + void on_copyGameOptionsCheckbox_stateChanged(int state); + void on_copyResPacksCheckbox_stateChanged(int state); + void on_copyShaderPacksCheckbox_stateChanged(int state); + void on_copyServersCheckbox_stateChanged(int state); + void on_copyModsCheckbox_stateChanged(int state); + void on_copyScreenshotsCheckbox_stateChanged(int state); + void on_symbolicLinksCheckbox_stateChanged(int state); + void on_hardLinksCheckbox_stateChanged(int state); + void on_recursiveLinkCheckbox_stateChanged(int state); + void on_dontLinkSavesCheckbox_stateChanged(int state); + void on_useCloneCheckbox_stateChanged(int state); + + private: + void checkAllCheckboxes(const bool& b); + void updateSelectAllCheckbox(); + void updateUseCloneCheckbox(); + void updateLinkOptions(); + + /* data */ + Ui::CopyInstanceDialog* ui; + QString InstIconKey; + BaseInstance* m_original; + InstanceCopyPrefs m_selectedOptions; + bool m_cloneSupported = false; + bool m_linkSupported = false; +}; diff --git a/launcher/ui/dialogs/CopyInstanceDialog.ui b/launcher/ui/dialogs/CopyInstanceDialog.ui new file mode 100644 index 0000000..5060deb --- /dev/null +++ b/launcher/ui/dialogs/CopyInstanceDialog.ui @@ -0,0 +1,447 @@ + + + CopyInstanceDialog + + + Qt::ApplicationModal + + + + 0 + 0 + 575 + 695 + + + + Copy Instance + + + + :/icons/toolbar/copy:/icons/toolbar/copy + + + true + + + + + + + + Qt::Horizontal + + + + 60 + 20 + + + + + + + + + :/icons/instances/grass:/icons/instances/grass + + + + 80 + 80 + + + + + + + + Qt::Horizontal + + + + 60 + 20 + + + + + + + + + + Name + + + + + + + Qt::Horizontal + + + + + + + 6 + + + + + &Group + + + groupBox + + + + + + + + 0 + 0 + + + + true + + + + + + + + + Instance Copy Options + + + + + + Keep play time + + + + + + + Disabling this will still keep the mod loader (ex: Fabric, Quilt, etc.) but erase the mods folder and their configs. + + + Copy mods + + + + + + + true + + + Copy resource packs + + + + + + + Copy the in-game options like FOV, max framerate, etc. + + + Copy game options + + + + + + + Copy shader packs + + + + + + + Copy servers + + + + + + + Copy saves + + + + + + + Copy screenshots + + + + + + + + 0 + 0 + + + + Qt::LeftToRight + + + Select all + + + false + + + + + + + + + + Qt::Horizontal + + + + + + + Advanced Copy Options + + + Qt::AlignCenter + + + + + + + + + Use symbolic or hard links instead of copying files. + + + Symbolic and Hard Link Options + + + false + + + false + + + false + + + + + + Links are supported on most filesystems except FAT + + + Qt::AlignCenter + + + + + + + 6 + + + 6 + + + 6 + + + 6 + + + + + false + + + Link each resource individually instead of linking whole folders at once + + + Link files recursively + + + + + + + false + + + If "copy saves" is selected world save data will be copied instead of linked and thus not shared between instances. + + + Don't link saves + + + false + + + + + + + true + + + Use hard links instead of copying files. + + + Use hard links + + + + + + + Use symbolic links instead of copying files. + + + Use symbolic links + + + + + + + + + + + + CoW (Copy-on-Write) Options + + + + + + false + + + Files cloned with reflinks take up no extra space until they are modified. + + + Clone instead of copying + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 1 + 0 + + + + Your filesystem and/or OS doesn't support reflinks + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + 4 + + + + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Help|QDialogButtonBox::Ok + + + + + + + iconButton + instNameTextBox + groupBox + keepPlaytimeCheckbox + copyScreenshotsCheckbox + copySavesCheckbox + copyShaderPacksCheckbox + copyGameOptionsCheckbox + copyServersCheckbox + copyResPacksCheckbox + copyModsCheckbox + symbolicLinksCheckbox + recursiveLinkCheckbox + hardLinksCheckbox + dontLinkSavesCheckbox + useCloneCheckbox + + + + + buttonBox + accepted() + CopyInstanceDialog + accept() + + + 269 + 692 + + + 157 + 274 + + + + + buttonBox + rejected() + CopyInstanceDialog + reject() + + + 337 + 692 + + + 286 + 274 + + + + + diff --git a/launcher/ui/dialogs/CreateShortcutDialog.cpp b/launcher/ui/dialogs/CreateShortcutDialog.cpp new file mode 100644 index 0000000..7d4199f --- /dev/null +++ b/launcher/ui/dialogs/CreateShortcutDialog.cpp @@ -0,0 +1,222 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2023 TheKodeToad + * Copyright (C) 2025 Yihe Li + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include + +#include "BuildConfig.h" +#include "CreateShortcutDialog.h" +#include "ui_CreateShortcutDialog.h" + +#include "ui/dialogs/IconPickerDialog.h" + +#include "BaseInstance.h" +#include "DesktopServices.h" +#include "FileSystem.h" +#include "InstanceList.h" +#include "icons/IconList.h" + +#include "minecraft/MinecraftInstance.h" +#include "minecraft/ShortcutUtils.h" +#include "minecraft/WorldList.h" +#include "minecraft/auth/AccountList.h" + +CreateShortcutDialog::CreateShortcutDialog(BaseInstance* instance, QWidget* parent) + : QDialog(parent), ui(new Ui::CreateShortcutDialog), m_instance(instance) +{ + ui->setupUi(this); + + InstIconKey = instance->iconKey(); + ui->iconButton->setIcon(APPLICATION->icons()->getIcon(InstIconKey)); + ui->instNameTextBox->setPlaceholderText(instance->name()); + + auto mInst = dynamic_cast(instance); + m_QuickJoinSupported = mInst && mInst->traits().contains("feature:is_quick_play_singleplayer"); + auto worldList = mInst->worldList(); + worldList->update(); + if (!m_QuickJoinSupported || worldList->empty()) { + ui->worldTarget->hide(); + ui->worldSelectionBox->hide(); + ui->serverTarget->setChecked(true); + ui->serverTarget->hide(); + ui->serverLabel->show(); + } + + // Populate save targets + if (!DesktopServices::isFlatpak()) { + QString desktopDir = FS::getDesktopDir(); + QString applicationDir = FS::getApplicationsDir(); + + if (!desktopDir.isEmpty()) + ui->saveTargetSelectionBox->addItem(tr("Desktop"), QVariant::fromValue(ShortcutTarget::Desktop)); + + if (!applicationDir.isEmpty()) + ui->saveTargetSelectionBox->addItem(tr("Applications"), QVariant::fromValue(ShortcutTarget::Applications)); + } + ui->saveTargetSelectionBox->addItem(tr("Other..."), QVariant::fromValue(ShortcutTarget::Other)); + + // Populate worlds + if (m_QuickJoinSupported) { + for (const auto& world : worldList->allWorlds()) { + // Entry name: World Name [Game Mode] - Last Played: DateTime + QString entry_name = tr("%1 [%2] - Last Played: %3") + .arg(world.name(), world.gameType().toTranslatedString(), world.lastPlayed().toString(Qt::ISODate)); + ui->worldSelectionBox->addItem(entry_name, world.name()); + } + } + + // Populate accounts + auto accounts = APPLICATION->accounts(); + MinecraftAccountPtr defaultAccount = accounts->defaultAccount(); + if (accounts->count() <= 0) { + ui->overrideAccountCheckbox->setEnabled(false); + } else { + for (int i = 0; i < accounts->count(); i++) { + MinecraftAccountPtr account = accounts->at(i); + auto profileLabel = account->profileName(); + if (account->isInUse()) + profileLabel = tr("%1 (in use)").arg(profileLabel); + auto face = account->getFace(); + QIcon icon = face.isNull() ? QIcon::fromTheme("noaccount") : face; + ui->accountSelectionBox->addItem(profileLabel, account->profileName()); + ui->accountSelectionBox->setItemIcon(i, icon); + if (defaultAccount == account) + ui->accountSelectionBox->setCurrentIndex(i); + } + } +} + +CreateShortcutDialog::~CreateShortcutDialog() +{ + delete ui; +} + +void CreateShortcutDialog::on_iconButton_clicked() +{ + IconPickerDialog dlg(this); + dlg.execWithSelection(InstIconKey); + + if (dlg.result() == QDialog::Accepted) { + InstIconKey = dlg.selectedIconKey; + ui->iconButton->setIcon(APPLICATION->icons()->getIcon(InstIconKey)); + } +} + +void CreateShortcutDialog::on_overrideAccountCheckbox_stateChanged(int state) +{ + ui->accountOptionsGroup->setEnabled(state == Qt::Checked); +} + +void CreateShortcutDialog::on_targetCheckbox_stateChanged(int state) +{ + ui->targetOptionsGroup->setEnabled(state == Qt::Checked); + ui->worldSelectionBox->setEnabled(ui->worldTarget->isChecked()); + ui->serverAddressBox->setEnabled(ui->serverTarget->isChecked()); + stateChanged(); +} + +void CreateShortcutDialog::on_worldTarget_toggled(bool checked) +{ + ui->worldSelectionBox->setEnabled(checked); + stateChanged(); +} + +void CreateShortcutDialog::on_serverTarget_toggled(bool checked) +{ + ui->serverAddressBox->setEnabled(checked); + stateChanged(); +} + +void CreateShortcutDialog::on_worldSelectionBox_currentIndexChanged(int index) +{ + stateChanged(); +} + +void CreateShortcutDialog::on_serverAddressBox_textChanged(const QString& text) +{ + stateChanged(); +} + +void CreateShortcutDialog::stateChanged() +{ + QString result = m_instance->name(); + if (ui->targetCheckbox->isChecked()) { + if (ui->worldTarget->isChecked()) + result = tr("%1 - %2").arg(result, ui->worldSelectionBox->currentData().toString()); + else if (ui->serverTarget->isChecked()) + result = tr("%1 - Server %2").arg(result, ui->serverAddressBox->text()); + } + ui->instNameTextBox->setPlaceholderText(result); + if (!ui->targetCheckbox->isChecked()) + ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(true); + else { + ui->buttonBox->button(QDialogButtonBox::Ok) + ->setEnabled((ui->worldTarget->isChecked() && ui->worldSelectionBox->currentIndex() != -1) || + (ui->serverTarget->isChecked() && !ui->serverAddressBox->text().isEmpty())); + } +} + +// Real work +void CreateShortcutDialog::createShortcut() +{ + QString targetString = tr("instance"); + QStringList extraArgs; + if (ui->targetCheckbox->isChecked()) { + if (ui->worldTarget->isChecked()) { + targetString = tr("world"); + extraArgs = { "--world", ui->worldSelectionBox->currentData().toString() }; + } else if (ui->serverTarget->isChecked()) { + targetString = tr("server"); + extraArgs = { "--server", ui->serverAddressBox->text() }; + } + } + + auto target = ui->saveTargetSelectionBox->currentData().value(); + auto name = ui->instNameTextBox->text(); + if (name.isEmpty()) + name = ui->instNameTextBox->placeholderText(); + if (ui->overrideAccountCheckbox->isChecked()) + extraArgs.append({ "--profile", ui->accountSelectionBox->currentData().toString() }); + + ShortcutUtils::Shortcut args{ m_instance, name, targetString, this, extraArgs, InstIconKey, target }; + if (target == ShortcutTarget::Desktop) + ShortcutUtils::createInstanceShortcutOnDesktop(args); + else if (target == ShortcutTarget::Applications) + ShortcutUtils::createInstanceShortcutInApplications(args); + else + ShortcutUtils::createInstanceShortcutInOther(args); +} diff --git a/launcher/ui/dialogs/CreateShortcutDialog.h b/launcher/ui/dialogs/CreateShortcutDialog.h new file mode 100644 index 0000000..8d666ef --- /dev/null +++ b/launcher/ui/dialogs/CreateShortcutDialog.h @@ -0,0 +1,59 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include "BaseInstance.h" + +class BaseInstance; + +namespace Ui { +class CreateShortcutDialog; +} + +class CreateShortcutDialog : public QDialog { + Q_OBJECT + + public: + explicit CreateShortcutDialog(BaseInstance* instance, QWidget* parent = nullptr); + ~CreateShortcutDialog(); + + void createShortcut(); + + private slots: + // Icon, target and name + void on_iconButton_clicked(); + + // Override account + void on_overrideAccountCheckbox_stateChanged(int state); + + // Override target (world, server) + void on_targetCheckbox_stateChanged(int state); + void on_worldTarget_toggled(bool checked); + void on_serverTarget_toggled(bool checked); + void on_worldSelectionBox_currentIndexChanged(int index); + void on_serverAddressBox_textChanged(const QString& text); + + private: + // Data + Ui::CreateShortcutDialog* ui; + QString InstIconKey; + BaseInstance* m_instance; + bool m_QuickJoinSupported = false; + + // Functions + void stateChanged(); +}; diff --git a/launcher/ui/dialogs/CreateShortcutDialog.ui b/launcher/ui/dialogs/CreateShortcutDialog.ui new file mode 100644 index 0000000..24d4dc2 --- /dev/null +++ b/launcher/ui/dialogs/CreateShortcutDialog.ui @@ -0,0 +1,264 @@ + + + CreateShortcutDialog + + + Qt::WindowModality::ApplicationModal + + + + 0 + 0 + 450 + 370 + + + + Create Instance Shortcut + + + true + + + + + + + + + :/icons/instances/grass:/icons/instances/grass + + + + 80 + 80 + + + + + + + + + + Save To: + + + + + + + + 0 + 0 + + + + + + + + Name: + + + + + + + Name + + + + + + + + + + + Use a different account than the default specified. + + + Override the default account + + + + + + + false + + + + 0 + 0 + + + + + + + + 0 + 0 + + + + + + + + + + + Specify a world or server to automatically join on launch. + + + Select a target to join on launch + + + + + + + false + + + + 0 + 0 + + + + + + + 0 + + + + + World: + + + targetBtnGroup + + + + + + + + + + 0 + 0 + + + + + + + + 0 + + + + + Server Address: + + + targetBtnGroup + + + + + + + false + + + Server Address: + + + + + + + + + Server Address + + + + + + + + + + Note: If a shortcut is moved after creation, it won't be deleted when deleting the instance. + + + + + + + You'll need to delete them manually if that is the case. + + + + + + + Qt::Orientation::Horizontal + + + QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok + + + + + + + iconButton + + + + + buttonBox + accepted() + CreateShortcutDialog + accept() + + + 20 + 20 + + + 20 + 20 + + + + + buttonBox + rejected() + CreateShortcutDialog + reject() + + + 20 + 20 + + + 20 + 20 + + + + + + + + diff --git a/launcher/ui/dialogs/CustomMessageBox.cpp b/launcher/ui/dialogs/CustomMessageBox.cpp new file mode 100644 index 0000000..ca0fe99 --- /dev/null +++ b/launcher/ui/dialogs/CustomMessageBox.cpp @@ -0,0 +1,40 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "CustomMessageBox.h" + +namespace CustomMessageBox { +QMessageBox* selectable(QWidget* parent, + const QString& title, + const QString& text, + QMessageBox::Icon icon, + QMessageBox::StandardButtons buttons, + QMessageBox::StandardButton defaultButton, + QCheckBox* checkBox) +{ + QMessageBox* messageBox = new QMessageBox(parent); + messageBox->setWindowTitle(title); + messageBox->setText(text); + messageBox->setStandardButtons(buttons); + messageBox->setDefaultButton(defaultButton); + messageBox->setTextInteractionFlags(Qt::TextSelectableByMouse); + messageBox->setIcon(icon); + messageBox->setTextInteractionFlags(Qt::TextBrowserInteraction); + if (checkBox) + messageBox->setCheckBox(checkBox); + + return messageBox; +} +} // namespace CustomMessageBox diff --git a/launcher/ui/dialogs/CustomMessageBox.h b/launcher/ui/dialogs/CustomMessageBox.h new file mode 100644 index 0000000..1ee2990 --- /dev/null +++ b/launcher/ui/dialogs/CustomMessageBox.h @@ -0,0 +1,28 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +namespace CustomMessageBox { +QMessageBox* selectable(QWidget* parent, + const QString& title, + const QString& text, + QMessageBox::Icon icon = QMessageBox::NoIcon, + QMessageBox::StandardButtons buttons = QMessageBox::Ok, + QMessageBox::StandardButton defaultButton = QMessageBox::NoButton, + QCheckBox* checkBox = nullptr); +} diff --git a/launcher/ui/dialogs/ExportInstanceDialog.cpp b/launcher/ui/dialogs/ExportInstanceDialog.cpp new file mode 100644 index 0000000..96dab97 --- /dev/null +++ b/launcher/ui/dialogs/ExportInstanceDialog.cpp @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2023 TheKodeToad + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ExportInstanceDialog.h" +#include +#include +#include +#include +#include +#include "FileIgnoreProxy.h" +#include "QObjectPtr.h" +#include "archive/ExportToZipTask.h" +#include "ui/dialogs/CustomMessageBox.h" +#include "ui/dialogs/ProgressDialog.h" +#include "ui_ExportInstanceDialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "Application.h" +#include "SeparatorPrefixTree.h" + +ExportInstanceDialog::ExportInstanceDialog(BaseInstance* instance, QWidget* parent) + : QDialog(parent), m_ui(new Ui::ExportInstanceDialog), m_instance(instance) +{ + m_ui->setupUi(this); + auto model = new QFileSystemModel(this); + model->setIconProvider(&m_icons); + auto root = instance->instanceRoot(); + m_proxyModel = new FileIgnoreProxy(root, this); + m_proxyModel->setSourceModel(model); + auto prefix = QDir(instance->instanceRoot()).relativeFilePath(instance->gameRoot()); + for (auto path : { "logs", "crash-reports", ".cache", ".fabric", ".quilt" }) { + m_proxyModel->ignoreFilesWithPath().insert(FS::PathCombine(prefix, path)); + } + m_proxyModel->ignoreFilesWithName().append({ ".DS_Store", "thumbs.db", "Thumbs.db" }); + m_proxyModel->loadBlockedPathsFromFile(ignoreFileName()); + + m_ui->treeView->setModel(m_proxyModel); + m_ui->treeView->setRootIndex(m_proxyModel->mapFromSource(model->index(root))); + m_ui->treeView->sortByColumn(0, Qt::AscendingOrder); + + connect(m_proxyModel, &QAbstractItemModel::rowsInserted, this, &ExportInstanceDialog::rowsInserted); + + model->setFilter(QDir::AllEntries | QDir::NoDotAndDotDot | QDir::AllDirs | QDir::Hidden); + model->setRootPath(root); + auto headerView = m_ui->treeView->header(); + headerView->setSectionResizeMode(QHeaderView::ResizeToContents); + headerView->setSectionResizeMode(0, QHeaderView::Stretch); + + m_ui->buttonBox->button(QDialogButtonBox::Cancel)->setText(tr("Cancel")); + m_ui->buttonBox->button(QDialogButtonBox::Ok)->setText(tr("OK")); +} + +ExportInstanceDialog::~ExportInstanceDialog() +{ + delete m_ui; +} + +/// Save icon to instance's folder is needed +void SaveIcon(BaseInstance* m_instance) +{ + auto iconKey = m_instance->iconKey(); + auto iconList = APPLICATION->icons(); + auto mmcIcon = iconList->icon(iconKey); + if (!mmcIcon || mmcIcon->isBuiltIn()) { + return; + } + auto path = mmcIcon->getFilePath(); + if (!path.isNull()) { + QFileInfo inInfo(path); + FS::copy(path, FS::PathCombine(m_instance->instanceRoot(), inInfo.fileName()))(); + return; + } + auto& image = mmcIcon->m_images[mmcIcon->type()]; + auto& icon = image.icon; + auto sizes = icon.availableSizes(); + if (sizes.size() == 0) { + return; + } + auto areaOf = [](QSize size) { return size.width() * size.height(); }; + QSize largest = sizes[0]; + // find variant with largest area + for (auto size : sizes) { + if (areaOf(largest) < areaOf(size)) { + largest = size; + } + } + auto pixmap = icon.pixmap(largest); + pixmap.save(FS::PathCombine(m_instance->instanceRoot(), iconKey + ".png")); +} + +void ExportInstanceDialog::doExport() +{ + auto name = FS::RemoveInvalidFilenameChars(m_instance->name()); + + const QString output = QFileDialog::getSaveFileName(this, tr("Export %1").arg(m_instance->name()), + FS::PathCombine(QDir::homePath(), name + ".zip"), "Zip (*.zip)", nullptr); + if (output.isEmpty()) { + QDialog::done(QDialog::Rejected); + return; + } + + SaveIcon(m_instance); + + auto files = QFileInfoList(); + if (!MMCZip::collectFileListRecursively(m_instance->instanceRoot(), nullptr, &files, + std::bind(&FileIgnoreProxy::filterFile, m_proxyModel, std::placeholders::_1))) { + QMessageBox::warning(this, tr("Error"), tr("Unable to export instance")); + QDialog::done(QDialog::Rejected); + return; + } + + auto task = makeShared(output, m_instance->instanceRoot(), files, "", true); + + connect(task.get(), &Task::failed, this, + [this, output](QString reason) { CustomMessageBox::selectable(this, tr("Error"), reason, QMessageBox::Critical)->show(); }); + connect(task.get(), &Task::finished, this, [task] { task->deleteLater(); }); + + ProgressDialog progress(this); + progress.setSkipButton(true, tr("Abort")); + auto result = progress.execWithTask(task.get()); + QDialog::done(result); +} + +void ExportInstanceDialog::done(int result) +{ + m_proxyModel->saveBlockedPathsToFile(ignoreFileName()); + if (result == QDialog::Accepted) { + doExport(); + return; + } + QDialog::done(result); +} + +void ExportInstanceDialog::rowsInserted(QModelIndex parent, int top, int bottom) +{ + // WARNING: possible off-by-one? + for (int i = top; i < bottom; i++) { + auto node = m_proxyModel->index(i, 0, parent); + if (m_proxyModel->shouldExpand(node)) { + auto expNode = node.parent(); + if (!expNode.isValid()) { + continue; + } + m_ui->treeView->expand(node); + } + } +} + +QString ExportInstanceDialog::ignoreFileName() +{ + return FS::PathCombine(m_instance->instanceRoot(), ".packignore"); +} diff --git a/launcher/ui/dialogs/ExportInstanceDialog.h b/launcher/ui/dialogs/ExportInstanceDialog.h new file mode 100644 index 0000000..c1f8559 --- /dev/null +++ b/launcher/ui/dialogs/ExportInstanceDialog.h @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 TheKodeToad + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include "FastFileIconProvider.h" +#include "FileIgnoreProxy.h" + +class BaseInstance; + +namespace Ui { +class ExportInstanceDialog; +} + +class ExportInstanceDialog : public QDialog { + Q_OBJECT + + public: + explicit ExportInstanceDialog(BaseInstance* instance, QWidget* parent = 0); + ~ExportInstanceDialog(); + + virtual void done(int result); + + private: + void doExport(); + QString ignoreFileName(); + + private: + Ui::ExportInstanceDialog* m_ui; + BaseInstance* m_instance; + FileIgnoreProxy* m_proxyModel; + FastFileIconProvider m_icons; + + private slots: + void rowsInserted(QModelIndex parent, int top, int bottom); +}; diff --git a/launcher/ui/dialogs/ExportInstanceDialog.ui b/launcher/ui/dialogs/ExportInstanceDialog.ui new file mode 100644 index 0000000..bcd4e84 --- /dev/null +++ b/launcher/ui/dialogs/ExportInstanceDialog.ui @@ -0,0 +1,83 @@ + + + ExportInstanceDialog + + + + 0 + 0 + 720 + 625 + + + + Export Instance + + + + + + true + + + QAbstractItemView::ExtendedSelection + + + true + + + false + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + treeView + + + + + buttonBox + accepted() + ExportInstanceDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + ExportInstanceDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/launcher/ui/dialogs/ExportPackDialog.cpp b/launcher/ui/dialogs/ExportPackDialog.cpp new file mode 100644 index 0000000..d0a9f09 --- /dev/null +++ b/launcher/ui/dialogs/ExportPackDialog.cpp @@ -0,0 +1,225 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "ExportPackDialog.h" +#include "minecraft/mod/ResourceFolderModel.h" +#include "modplatform/ModIndex.h" +#include "modplatform/flame/FlamePackExportTask.h" +#include "ui/dialogs/CustomMessageBox.h" +#include "ui/dialogs/ProgressDialog.h" +#include "ui_ExportPackDialog.h" + +#include +#include +#include +#include +#include +#include "FileSystem.h" +#include "MMCZip.h" +#include "modplatform/modrinth/ModrinthPackExportTask.h" + +ExportPackDialog::ExportPackDialog(MinecraftInstance* instance, QWidget* parent, ModPlatform::ResourceProvider provider) + : QDialog(parent), m_instance(instance), m_ui(new Ui::ExportPackDialog), m_provider(provider) +{ + Q_ASSERT(m_provider == ModPlatform::ResourceProvider::MODRINTH || m_provider == ModPlatform::ResourceProvider::FLAME); + + m_ui->setupUi(this); + m_ui->name->setPlaceholderText(instance->name()); + m_ui->name->setText(instance->settings()->get("ExportName").toString()); + m_ui->version->setText(instance->settings()->get("ExportVersion").toString()); + m_ui->optionalFiles->setChecked(instance->settings()->get("ExportOptionalFiles").toBool()); + + connect(m_ui->recommendedMemoryCheckBox, &QCheckBox::toggled, m_ui->recommendedMemory, &QWidget::setEnabled); + + if (m_provider == ModPlatform::ResourceProvider::MODRINTH) { + setWindowTitle(tr("Export Modrinth Pack")); + + m_ui->authorLabel->hide(); + m_ui->author->hide(); + + m_ui->recommendedMemoryWidget->hide(); + + m_ui->summary->setPlainText(instance->settings()->get("ExportSummary").toString()); + } else { + setWindowTitle(tr("Export CurseForge Pack")); + + m_ui->summaryLabel->hide(); + m_ui->summary->hide(); + + const int recommendedRAM = instance->settings()->get("ExportRecommendedRAM").toInt(); + + if (recommendedRAM > 0) { + m_ui->recommendedMemoryCheckBox->setChecked(true); + m_ui->recommendedMemory->setValue(recommendedRAM); + } else { + m_ui->recommendedMemoryCheckBox->setChecked(false); + + // recommend based on setting - limited to 12 GiB (CurseForge warns above this amount) + const int defaultRecommendation = qMin(m_instance->settings()->get("MaxMemAlloc").toInt(), 1024 * 12); + m_ui->recommendedMemory->setValue(defaultRecommendation); + } + + m_ui->author->setText(instance->settings()->get("ExportAuthor").toString()); + } + + // ensure a valid pack is generated + // the name and version fields mustn't be empty + connect(m_ui->name, &QLineEdit::textEdited, this, &ExportPackDialog::validate); + connect(m_ui->version, &QLineEdit::textEdited, this, &ExportPackDialog::validate); + // the instance name can technically be empty + validate(); + + QFileSystemModel* model = new QFileSystemModel(this); + model->setIconProvider(&m_icons); + + // use the game root - everything outside cannot be exported + const QDir instanceRoot(instance->instanceRoot()); + m_proxy = new FileIgnoreProxy(instance->instanceRoot(), this); + auto prefix = QDir(instance->instanceRoot()).relativeFilePath(instance->gameRoot()); + for (auto path : { "logs", "crash-reports", ".cache", ".fabric", ".quilt" }) { + m_proxy->ignoreFilesWithPath().insert(FS::PathCombine(prefix, path)); + } + m_proxy->ignoreFilesWithName().append({ ".DS_Store", "thumbs.db", "Thumbs.db" }); + m_proxy->ignoreFilesWithSuffix().append(".pw.toml"); + m_proxy->setSourceModel(model); + m_proxy->loadBlockedPathsFromFile(ignoreFileName()); + + const QDir::Filters filter(QDir::AllEntries | QDir::NoDotAndDotDot | QDir::AllDirs | QDir::Hidden); + + for (auto resourceModel : instance->resourceLists()) { + if (resourceModel == nullptr) { + continue; + } + + if (!resourceModel->indexDir().exists()) { + continue; + } + + if (resourceModel->dir() == resourceModel->indexDir()) { + continue; + } + + m_proxy->ignoreFilesWithPath().insert(instanceRoot.relativeFilePath(resourceModel->indexDir().absolutePath())); + } + + m_ui->files->setModel(m_proxy); + m_ui->files->setRootIndex(m_proxy->mapFromSource(model->index(instance->gameRoot()))); + m_ui->files->sortByColumn(0, Qt::AscendingOrder); + + model->setFilter(filter); + model->setRootPath(instance->gameRoot()); + + QHeaderView* headerView = m_ui->files->header(); + headerView->setSectionResizeMode(QHeaderView::ResizeToContents); + headerView->setSectionResizeMode(0, QHeaderView::Stretch); + + m_ui->buttonBox->button(QDialogButtonBox::Cancel)->setText(tr("Cancel")); + m_ui->buttonBox->button(QDialogButtonBox::Ok)->setText(tr("OK")); +} + +ExportPackDialog::~ExportPackDialog() +{ + delete m_ui; +} + +void ExportPackDialog::done(int result) +{ + m_proxy->saveBlockedPathsToFile(ignoreFileName()); + auto settings = m_instance->settings(); + settings->set("ExportName", m_ui->name->text()); + settings->set("ExportVersion", m_ui->version->text()); + settings->set("ExportOptionalFiles", m_ui->optionalFiles->isChecked()); + + if (m_provider == ModPlatform::ResourceProvider::MODRINTH) + settings->set("ExportSummary", m_ui->summary->toPlainText()); + else { + settings->set("ExportAuthor", m_ui->author->text()); + + if (m_ui->recommendedMemoryCheckBox->isChecked()) + settings->set("ExportRecommendedRAM", m_ui->recommendedMemory->value()); + else + settings->reset("ExportRecommendedRAM"); + } + + if (result == Accepted) { + const QString name = m_ui->name->text().isEmpty() ? m_instance->name() : m_ui->name->text(); + const QString filename = FS::RemoveInvalidFilenameChars(name); + + QString output; + if (m_provider == ModPlatform::ResourceProvider::MODRINTH) { + output = QFileDialog::getSaveFileName(this, tr("Export %1").arg(name), FS::PathCombine(QDir::homePath(), filename + ".mrpack"), + tr("Modrinth pack") + " (*.mrpack *.zip)", nullptr); + if (output.isEmpty()) + return; + if (!(output.endsWith(".zip") || output.endsWith(".mrpack"))) + output.append(".mrpack"); + } else { + output = QFileDialog::getSaveFileName(this, tr("Export %1").arg(name), FS::PathCombine(QDir::homePath(), filename + ".zip"), + tr("CurseForge pack") + " (*.zip)", nullptr); + if (output.isEmpty()) + return; + if (!output.endsWith(".zip")) + output.append(".zip"); + } + + Task* task; + if (m_provider == ModPlatform::ResourceProvider::MODRINTH) { + task = new ModrinthPackExportTask(name, m_ui->version->text(), m_ui->summary->toPlainText(), m_ui->optionalFiles->isChecked(), + m_instance, output, std::bind(&FileIgnoreProxy::filterFile, m_proxy, std::placeholders::_1)); + } else { + FlamePackExportOptions options{}; + + options.name = name; + options.version = m_ui->version->text(); + options.author = m_ui->author->text(); + options.optionalFiles = m_ui->optionalFiles->isChecked(); + options.instance = m_instance; + options.output = output; + options.filter = std::bind(&FileIgnoreProxy::filterFile, m_proxy, std::placeholders::_1); + options.recommendedRAM = m_ui->recommendedMemoryCheckBox->isChecked() ? m_ui->recommendedMemory->value() : 0; + + task = new FlamePackExportTask(std::move(options)); + } + + connect(task, &Task::failed, + [this](const QString reason) { CustomMessageBox::selectable(this, tr("Error"), reason, QMessageBox::Critical)->show(); }); + connect(task, &Task::aborted, [this] { + CustomMessageBox::selectable(this, tr("Task aborted"), tr("The task has been aborted by the user."), QMessageBox::Information) + ->show(); + }); + connect(task, &Task::finished, [task] { task->deleteLater(); }); + + ProgressDialog progress(this); + progress.setSkipButton(true, tr("Abort")); + if (progress.execWithTask(task) != QDialog::Accepted) + return; + } + + QDialog::done(result); +} + +void ExportPackDialog::validate() +{ + m_ui->buttonBox->button(QDialogButtonBox::Ok) + ->setDisabled(m_provider == ModPlatform::ResourceProvider::MODRINTH && m_ui->version->text().isEmpty()); +} + +QString ExportPackDialog::ignoreFileName() +{ + return FS::PathCombine(m_instance->instanceRoot(), ".packignore"); +} diff --git a/launcher/ui/dialogs/ExportPackDialog.h b/launcher/ui/dialogs/ExportPackDialog.h new file mode 100644 index 0000000..81e657a --- /dev/null +++ b/launcher/ui/dialogs/ExportPackDialog.h @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include "BaseInstance.h" +#include "FastFileIconProvider.h" +#include "FileIgnoreProxy.h" +#include "minecraft/MinecraftInstance.h" +#include "modplatform/ModIndex.h" + +namespace Ui { +class ExportPackDialog; +} + +class ExportPackDialog : public QDialog { + Q_OBJECT + + public: + explicit ExportPackDialog(MinecraftInstance* instance, + QWidget* parent = nullptr, + ModPlatform::ResourceProvider provider = ModPlatform::ResourceProvider::MODRINTH); + ~ExportPackDialog(); + + void done(int result) override; + void validate(); + + private: + QString ignoreFileName(); + + private: + MinecraftInstance* m_instance; + Ui::ExportPackDialog* m_ui; + FileIgnoreProxy* m_proxy; + FastFileIconProvider m_icons; + const ModPlatform::ResourceProvider m_provider; +}; diff --git a/launcher/ui/dialogs/ExportPackDialog.ui b/launcher/ui/dialogs/ExportPackDialog.ui new file mode 100644 index 0000000..bda8b8d --- /dev/null +++ b/launcher/ui/dialogs/ExportPackDialog.ui @@ -0,0 +1,267 @@ + + + ExportPackDialog + + + + 0 + 0 + 650 + 532 + + + + true + + + + + + &Description + + + + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter + + + + + &Name: + + + name + + + + + + + + + + &Version: + + + version + + + + + + + 1.0.0 + + + + + + + &Author: + + + author + + + + + + + + + + + + &Summary + + + summary + + + + + + + + 0 + 0 + + + + + 0 + 100 + + + + + 16777215 + 100 + + + + true + + + + + + + + + + &Options + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + &Recommended Memory: + + + + + + + false + + + + 0 + 0 + + + + MiB + + + 8 + + + 32768 + + + 128 + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + &Files + + + files + + + + + + + true + + + QAbstractItemView::ExtendedSelection + + + true + + + false + + + + + + + &Mark disabled files as optional + + + true + + + + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + files + optionalFiles + + + + + buttonBox + accepted() + ExportPackDialog + accept() + + + 324 + 390 + + + 324 + 206 + + + + + buttonBox + rejected() + ExportPackDialog + reject() + + + 324 + 390 + + + 324 + 206 + + + + + diff --git a/launcher/ui/dialogs/ExportToModListDialog.cpp b/launcher/ui/dialogs/ExportToModListDialog.cpp new file mode 100644 index 0000000..e8873f9 --- /dev/null +++ b/launcher/ui/dialogs/ExportToModListDialog.cpp @@ -0,0 +1,232 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "ExportToModListDialog.h" +#include +#include +#include +#include "FileSystem.h" +#include "Markdown.h" +#include "StringUtils.h" +#include "modplatform/helpers/ExportToModList.h" +#include "ui_ExportToModListDialog.h" + +#include +#include +#include +#include +#include + +const QHash ExportToModListDialog::exampleLines = { + { ExportToModList::HTML, "
  • {name} [{version}] by {authors}
  • " }, + { ExportToModList::MARKDOWN, "[{name}]({url}) [{version}] by {authors}" }, + { ExportToModList::PLAINTXT, "{name} ({url}) [{version}] by {authors}" }, + { ExportToModList::JSON, "{\"name\":\"{name}\",\"url\":\"{url}\",\"version\":\"{version}\",\"authors\":\"{authors}\"}," }, + { ExportToModList::CSV, "{name},{url},{version},\"{authors}\"" }, +}; + +ExportToModListDialog::ExportToModListDialog(QString name, QList mods, QWidget* parent) + : QDialog(parent), m_mods(mods), m_template_changed(false), m_name(name), ui(new Ui::ExportToModListDialog) +{ + ui->setupUi(this); + enableCustom(false); + + connect(ui->formatComboBox, &QComboBox::currentIndexChanged, this, &ExportToModListDialog::formatChanged); + connect(ui->authorsCheckBox, &QCheckBox::stateChanged, this, &ExportToModListDialog::trigger); + connect(ui->versionCheckBox, &QCheckBox::stateChanged, this, &ExportToModListDialog::trigger); + connect(ui->urlCheckBox, &QCheckBox::stateChanged, this, &ExportToModListDialog::trigger); + connect(ui->filenameCheckBox, &QCheckBox::stateChanged, this, &ExportToModListDialog::trigger); + connect(ui->authorsButton, &QPushButton::clicked, this, [this](bool) { addExtra(ExportToModList::Authors); }); + connect(ui->versionButton, &QPushButton::clicked, this, [this](bool) { addExtra(ExportToModList::Version); }); + connect(ui->urlButton, &QPushButton::clicked, this, [this](bool) { addExtra(ExportToModList::Url); }); + connect(ui->filenameButton, &QPushButton::clicked, this, [this](bool) { addExtra(ExportToModList::FileName); }); + connect(ui->templateText, &QTextEdit::textChanged, this, [this] { + if (ui->templateText->toPlainText() != exampleLines[m_format]) + ui->formatComboBox->setCurrentIndex(5); + triggerImp(); + }); + connect(ui->copyButton, &QPushButton::clicked, this, [this](bool) { + this->ui->finalText->selectAll(); + this->ui->finalText->copy(); + }); + + ui->buttonBox->button(QDialogButtonBox::Cancel)->setText(tr("Cancel")); + ui->buttonBox->button(QDialogButtonBox::Save)->setText(tr("Save")); + triggerImp(); +} + +ExportToModListDialog::~ExportToModListDialog() +{ + delete ui; +} + +void ExportToModListDialog::formatChanged(int index) +{ + switch (index) { + case 0: { + enableCustom(false); + ui->resultText->show(); + m_format = ExportToModList::HTML; + break; + } + case 1: { + enableCustom(false); + ui->resultText->show(); + m_format = ExportToModList::MARKDOWN; + break; + } + case 2: { + enableCustom(false); + ui->resultText->hide(); + m_format = ExportToModList::PLAINTXT; + break; + } + case 3: { + enableCustom(false); + ui->resultText->hide(); + m_format = ExportToModList::JSON; + break; + } + case 4: { + enableCustom(false); + ui->resultText->hide(); + m_format = ExportToModList::CSV; + break; + } + case 5: { + m_template_changed = true; + enableCustom(true); + ui->resultText->hide(); + m_format = ExportToModList::CUSTOM; + break; + } + } + triggerImp(); +} + +void ExportToModListDialog::triggerImp() +{ + if (m_format == ExportToModList::CUSTOM) { + ui->finalText->setPlainText(ExportToModList::exportToModList(m_mods, ui->templateText->toPlainText())); + return; + } + auto opt = 0; + if (ui->authorsCheckBox->isChecked()) + opt |= ExportToModList::Authors; + if (ui->versionCheckBox->isChecked()) + opt |= ExportToModList::Version; + if (ui->urlCheckBox->isChecked()) + opt |= ExportToModList::Url; + if (ui->filenameCheckBox->isChecked()) + opt |= ExportToModList::FileName; + auto txt = ExportToModList::exportToModList(m_mods, m_format, static_cast(opt)); + ui->finalText->setPlainText(txt); + switch (m_format) { + case ExportToModList::CUSTOM: + return; + case ExportToModList::HTML: + ui->resultText->setHtml(StringUtils::htmlListPatch(txt)); + break; + case ExportToModList::MARKDOWN: + ui->resultText->setHtml(StringUtils::htmlListPatch(markdownToHTML(txt))); + break; + case ExportToModList::PLAINTXT: + break; + case ExportToModList::JSON: + break; + case ExportToModList::CSV: + break; + } + auto exampleLine = exampleLines[m_format]; + if (!m_template_changed && ui->templateText->toPlainText() != exampleLine) + ui->templateText->setPlainText(exampleLine); +} + +void ExportToModListDialog::done(int result) +{ + if (result == Accepted) { + const QString filename = FS::RemoveInvalidFilenameChars(m_name); + const QString output = + QFileDialog::getSaveFileName(this, tr("Export %1").arg(m_name), FS::PathCombine(QDir::homePath(), filename + extension()), + tr("File") + " (*.txt *.html *.md *.json *.csv)", nullptr); + + if (output.isEmpty()) + return; + + try { + FS::write(output, ui->finalText->toPlainText().toUtf8()); + } catch (const FS::FileSystemException& e) { + qCritical() << "Failed to save mod list file :" << e.cause(); + } + } + + QDialog::done(result); +} + +QString ExportToModListDialog::extension() +{ + switch (m_format) { + case ExportToModList::HTML: + return ".html"; + case ExportToModList::MARKDOWN: + return ".md"; + case ExportToModList::PLAINTXT: + return ".txt"; + case ExportToModList::CUSTOM: + return ".txt"; + case ExportToModList::JSON: + return ".json"; + case ExportToModList::CSV: + return ".csv"; + } + return ".txt"; +} + +void ExportToModListDialog::addExtra(ExportToModList::OptionalData option) +{ + if (m_format != ExportToModList::CUSTOM) + return; + switch (option) { + case ExportToModList::Authors: + ui->templateText->insertPlainText("{authors}"); + break; + case ExportToModList::Url: + ui->templateText->insertPlainText("{url}"); + break; + case ExportToModList::Version: + ui->templateText->insertPlainText("{version}"); + break; + case ExportToModList::FileName: + ui->templateText->insertPlainText("{filename}"); + break; + } +} +void ExportToModListDialog::enableCustom(bool enabled) +{ + ui->authorsCheckBox->setHidden(enabled); + ui->authorsButton->setHidden(!enabled); + + ui->versionCheckBox->setHidden(enabled); + ui->versionButton->setHidden(!enabled); + + ui->urlCheckBox->setHidden(enabled); + ui->urlButton->setHidden(!enabled); + + ui->filenameCheckBox->setHidden(enabled); + ui->filenameButton->setHidden(!enabled); +} diff --git a/launcher/ui/dialogs/ExportToModListDialog.h b/launcher/ui/dialogs/ExportToModListDialog.h new file mode 100644 index 0000000..4ebe203 --- /dev/null +++ b/launcher/ui/dialogs/ExportToModListDialog.h @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include +#include "minecraft/mod/Mod.h" +#include "modplatform/helpers/ExportToModList.h" + +namespace Ui { +class ExportToModListDialog; +} + +class ExportToModListDialog : public QDialog { + Q_OBJECT + + public: + explicit ExportToModListDialog(QString name, QList mods, QWidget* parent = nullptr); + ~ExportToModListDialog(); + + void done(int result) override; + + protected slots: + void formatChanged(int index); + void triggerImp(); + void trigger(int) { triggerImp(); }; + void addExtra(ExportToModList::OptionalData option); + + private: + QString extension(); + void enableCustom(bool enabled); + + QList m_mods; + bool m_template_changed; + QString m_name; + ExportToModList::Formats m_format = ExportToModList::Formats::HTML; + Ui::ExportToModListDialog* ui; + static const QHash exampleLines; +}; diff --git a/launcher/ui/dialogs/ExportToModListDialog.ui b/launcher/ui/dialogs/ExportToModListDialog.ui new file mode 100644 index 0000000..ec049d7 --- /dev/null +++ b/launcher/ui/dialogs/ExportToModListDialog.ui @@ -0,0 +1,276 @@ + + + ExportToModListDialog + + + + 0 + 0 + 650 + 522 + + + + Export Pack to ModList + + + true + + + + + + + + Settings + + + + + + + HTML + + + + + Markdown + + + + + Plaintext + + + + + JSON + + + + + CSV + + + + + Custom + + + + + + + + + 0 + 0 + + + + Template + + + + + + + 0 + 0 + + + + This text supports the following placeholders: {name} - Mod name {mod_id} - Mod ID {url} - Mod URL {version} - Mod version {authors} - Mod authors + + + + + + + + + + + 0 + 0 + + + + Optional Info + + + + + + Version + + + + + + + Authors + + + + + + + URL + + + + + + + Filename + + + + + + + Version + + + + + + + Authors + + + + + + + URL + + + + + + + Filename + + + + + + + + + + QFrame::NoFrame + + + QFrame::Plain + + + 1 + + + Format + + + + + + + + + + Result + + + + + + + 0 + 143 + + + + true + + + + + + + true + + + + + + + + + + This depends on the mods' metadata. To ensure it is available, run an update on the instance. Installing the updates isn't necessary. + + + true + + + + + + + + + + + Copy + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Save + + + + + + + + + + + buttonBox + accepted() + ExportToModListDialog + accept() + + + 334 + 435 + + + 324 + 206 + + + + + buttonBox + rejected() + ExportToModListDialog + reject() + + + 324 + 390 + + + 324 + 206 + + + + + diff --git a/launcher/ui/dialogs/IconPickerDialog.cpp b/launcher/ui/dialogs/IconPickerDialog.cpp new file mode 100644 index 0000000..b56e95d --- /dev/null +++ b/launcher/ui/dialogs/IconPickerDialog.cpp @@ -0,0 +1,184 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include +#include +#include +#include + +#include "Application.h" + +#include "IconPickerDialog.h" +#include "ui_IconPickerDialog.h" + +#include "ui/instanceview/InstanceDelegate.h" + +#include +#include "icons/IconList.h" +#include "icons/IconUtils.h" + +IconPickerDialog::IconPickerDialog(QWidget* parent) : QDialog(parent), ui(new Ui::IconPickerDialog) +{ + ui->setupUi(this); + setWindowModality(Qt::WindowModal); + + searchBar = new QLineEdit(this); + searchBar->setPlaceholderText(tr("Search...")); + ui->verticalLayout->insertWidget(0, searchBar); + + proxyModel = new QSortFilterProxyModel(this); + proxyModel->setSourceModel(APPLICATION->icons()); + proxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive); + ui->iconView->setModel(proxyModel); + + auto contentsWidget = ui->iconView; + contentsWidget->setViewMode(QListView::IconMode); + contentsWidget->setFlow(QListView::LeftToRight); + contentsWidget->setIconSize(QSize(48, 48)); + contentsWidget->setMovement(QListView::Static); + contentsWidget->setResizeMode(QListView::Adjust); + contentsWidget->setSelectionMode(QAbstractItemView::SingleSelection); + contentsWidget->setSpacing(5); + contentsWidget->setWordWrap(false); + contentsWidget->setWrapping(true); + contentsWidget->setUniformItemSizes(true); + contentsWidget->setTextElideMode(Qt::ElideRight); + contentsWidget->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); + contentsWidget->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + contentsWidget->setItemDelegate(new ListViewDelegate(contentsWidget)); + + // contentsWidget->setAcceptDrops(true); + contentsWidget->setDropIndicatorShown(true); + contentsWidget->viewport()->setAcceptDrops(true); + contentsWidget->setDragDropMode(QAbstractItemView::DropOnly); + contentsWidget->setDefaultDropAction(Qt::CopyAction); + + contentsWidget->installEventFilter(this); + + contentsWidget->setModel(proxyModel); + + // NOTE: ResetRole forces the button to be on the left, while the OK/Cancel ones are on the right. We win. + auto buttonAdd = ui->buttonBox->addButton(tr("Add Icon"), QDialogButtonBox::ResetRole); + buttonRemove = ui->buttonBox->addButton(tr("Remove Icon"), QDialogButtonBox::ResetRole); + + ui->buttonBox->button(QDialogButtonBox::Cancel)->setText(tr("Cancel")); + ui->buttonBox->button(QDialogButtonBox::Ok)->setText(tr("OK")); + + connect(buttonAdd, &QPushButton::clicked, this, &IconPickerDialog::addNewIcon); + connect(buttonRemove, &QPushButton::clicked, this, &IconPickerDialog::removeSelectedIcon); + + connect(contentsWidget, &QAbstractItemView::doubleClicked, this, &IconPickerDialog::activated); + + connect(contentsWidget->selectionModel(), &QItemSelectionModel::selectionChanged, this, &IconPickerDialog::selectionChanged); + + auto buttonFolder = ui->buttonBox->addButton(tr("Open Folder"), QDialogButtonBox::ResetRole); + connect(buttonFolder, &QPushButton::clicked, this, &IconPickerDialog::openFolder); + connect(searchBar, &QLineEdit::textChanged, this, &IconPickerDialog::filterIcons); + // Prevent incorrect indices from e.g. filesystem changes + connect(APPLICATION->icons(), &IconList::iconUpdated, this, [this]() { proxyModel->invalidate(); }); +} + +bool IconPickerDialog::eventFilter(QObject* obj, QEvent* evt) +{ + if (obj != ui->iconView) + return QDialog::eventFilter(obj, evt); + if (evt->type() != QEvent::KeyPress) { + return QDialog::eventFilter(obj, evt); + } + QKeyEvent* keyEvent = static_cast(evt); + switch (keyEvent->key()) { + case Qt::Key_Delete: + removeSelectedIcon(); + return true; + case Qt::Key_Plus: + addNewIcon(); + return true; + default: + break; + } + return QDialog::eventFilter(obj, evt); +} + +void IconPickerDialog::addNewIcon() +{ + //: The title of the select icons open file dialog + QString selectIcons = tr("Select Icons"); + //: The type of icon files + auto filter = IconUtils::getIconFilter(); + QStringList fileNames = QFileDialog::getOpenFileNames(this, selectIcons, QString(), tr("Icons %1").arg(filter)); + APPLICATION->icons()->installIcons(fileNames); +} + +void IconPickerDialog::removeSelectedIcon() +{ + if (APPLICATION->icons()->trashIcon(selectedIconKey)) + return; + + APPLICATION->icons()->deleteIcon(selectedIconKey); +} + +void IconPickerDialog::activated(QModelIndex index) +{ + selectedIconKey = index.data(Qt::UserRole).toString(); + accept(); +} + +void IconPickerDialog::selectionChanged(QItemSelection selected, QItemSelection deselected) +{ + if (selected.empty()) + return; + + QString key = selected.first().indexes().first().data(Qt::UserRole).toString(); + if (!key.isEmpty()) { + selectedIconKey = key; + } + buttonRemove->setEnabled(APPLICATION->icons()->iconFileExists(selectedIconKey)); +} + +int IconPickerDialog::execWithSelection(QString selection) +{ + auto list = APPLICATION->icons(); + auto contentsWidget = ui->iconView; + selectedIconKey = selection; + + int index_nr = list->getIconIndex(selection); + auto model_index = list->index(index_nr); + contentsWidget->selectionModel()->select(model_index, QItemSelectionModel::Current | QItemSelectionModel::Select); + + QMetaObject::invokeMethod(this, "delayed_scroll", Qt::QueuedConnection, Q_ARG(QModelIndex, model_index)); + return QDialog::exec(); +} + +void IconPickerDialog::delayed_scroll(QModelIndex model_index) +{ + auto contentsWidget = ui->iconView; + contentsWidget->scrollTo(model_index); +} + +IconPickerDialog::~IconPickerDialog() +{ + delete ui; +} + +void IconPickerDialog::openFolder() +{ + DesktopServices::openPath(APPLICATION->icons()->iconDirectory(selectedIconKey), true); +} + +void IconPickerDialog::filterIcons(const QString& query) +{ + proxyModel->setFilterFixedString(query); +} diff --git a/launcher/ui/dialogs/IconPickerDialog.h b/launcher/ui/dialogs/IconPickerDialog.h new file mode 100644 index 0000000..db13153 --- /dev/null +++ b/launcher/ui/dialogs/IconPickerDialog.h @@ -0,0 +1,52 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once +#include +#include +#include +#include + +namespace Ui { +class IconPickerDialog; +} + +class IconPickerDialog : public QDialog { + Q_OBJECT + + public: + explicit IconPickerDialog(QWidget* parent = 0); + ~IconPickerDialog(); + int execWithSelection(QString selection); + QString selectedIconKey; + + protected: + virtual bool eventFilter(QObject*, QEvent*); + + private: + Ui::IconPickerDialog* ui; + QPushButton* buttonRemove; + QLineEdit* searchBar; + QSortFilterProxyModel* proxyModel; + + private slots: + void selectionChanged(QItemSelection, QItemSelection); + void activated(QModelIndex); + void delayed_scroll(QModelIndex); + void addNewIcon(); + void removeSelectedIcon(); + void openFolder(); + void filterIcons(const QString& text); +}; diff --git a/launcher/ui/dialogs/IconPickerDialog.ui b/launcher/ui/dialogs/IconPickerDialog.ui new file mode 100644 index 0000000..c548edf --- /dev/null +++ b/launcher/ui/dialogs/IconPickerDialog.ui @@ -0,0 +1,67 @@ + + + IconPickerDialog + + + + 0 + 0 + 676 + 555 + + + + Pick icon + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + buttonBox + accepted() + IconPickerDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + IconPickerDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/launcher/ui/dialogs/ImportResourceDialog.cpp b/launcher/ui/dialogs/ImportResourceDialog.cpp new file mode 100644 index 0000000..80096ed --- /dev/null +++ b/launcher/ui/dialogs/ImportResourceDialog.cpp @@ -0,0 +1,73 @@ +#include "ImportResourceDialog.h" +#include "ui_ImportResourceDialog.h" + +#include +#include + +#include "Application.h" +#include "InstanceList.h" + +#include +#include "modplatform/ResourceType.h" +#include "ui/instanceview/InstanceDelegate.h" +#include "ui/instanceview/InstanceProxyModel.h" + +ImportResourceDialog::ImportResourceDialog(QString file_path, ModPlatform::ResourceType type, QWidget* parent) + : QDialog(parent), ui(new Ui::ImportResourceDialog), m_resource_type(type), m_file_path(file_path) +{ + ui->setupUi(this); + setWindowModality(Qt::WindowModal); + + auto contentsWidget = ui->instanceView; + contentsWidget->setViewMode(QListView::ListMode); + contentsWidget->setFlow(QListView::LeftToRight); + contentsWidget->setIconSize(QSize(48, 48)); + contentsWidget->setMovement(QListView::Static); + contentsWidget->setResizeMode(QListView::Adjust); + contentsWidget->setSelectionMode(QAbstractItemView::SingleSelection); + contentsWidget->setSpacing(5); + contentsWidget->setWordWrap(true); + contentsWidget->setWrapping(true); + // NOTE: We can't have uniform sizes because the text may wrap if it's too long. If we set this, it will cut off the wrapped text. + contentsWidget->setUniformItemSizes(false); + contentsWidget->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); + contentsWidget->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + contentsWidget->setItemDelegate(new ListViewDelegate()); + + proxyModel = new InstanceProxyModel(this); + proxyModel->setSourceModel(APPLICATION->instances()); + proxyModel->sort(0); + contentsWidget->setModel(proxyModel); + + connect(contentsWidget, &QAbstractItemView::doubleClicked, this, &ImportResourceDialog::activated); + connect(contentsWidget->selectionModel(), &QItemSelectionModel::selectionChanged, this, &ImportResourceDialog::selectionChanged); + + ui->label->setText( + tr("Choose the instance you would like to import this %1 to.").arg(ModPlatform::ResourceTypeUtils::getName(m_resource_type))); + ui->label_file_path->setText(tr("File: %1").arg(m_file_path)); + + ui->buttonBox->button(QDialogButtonBox::Cancel)->setText(tr("Cancel")); + ui->buttonBox->button(QDialogButtonBox::Ok)->setText(tr("OK")); +} + +void ImportResourceDialog::activated(QModelIndex index) +{ + selectedInstanceKey = index.data(InstanceList::InstanceIDRole).toString(); + accept(); +} + +void ImportResourceDialog::selectionChanged(QItemSelection selected, QItemSelection deselected) +{ + if (selected.empty()) + return; + + QString key = selected.first().indexes().first().data(InstanceList::InstanceIDRole).toString(); + if (!key.isEmpty()) { + selectedInstanceKey = key; + } +} + +ImportResourceDialog::~ImportResourceDialog() +{ + delete ui; +} diff --git a/launcher/ui/dialogs/ImportResourceDialog.h b/launcher/ui/dialogs/ImportResourceDialog.h new file mode 100644 index 0000000..d960996 --- /dev/null +++ b/launcher/ui/dialogs/ImportResourceDialog.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include + +#include "modplatform/ResourceType.h" +#include "ui/instanceview/InstanceProxyModel.h" + +namespace Ui { +class ImportResourceDialog; +} + +class ImportResourceDialog : public QDialog { + Q_OBJECT + + public: + explicit ImportResourceDialog(QString file_path, ModPlatform::ResourceType type, QWidget* parent = nullptr); + ~ImportResourceDialog() override; + QString selectedInstanceKey; + + private: + Ui::ImportResourceDialog* ui; + ModPlatform::ResourceType m_resource_type; + QString m_file_path; + InstanceProxyModel* proxyModel; + + private slots: + void selectionChanged(QItemSelection, QItemSelection); + void activated(QModelIndex); +}; diff --git a/launcher/ui/dialogs/ImportResourceDialog.ui b/launcher/ui/dialogs/ImportResourceDialog.ui new file mode 100644 index 0000000..cc3f4ec --- /dev/null +++ b/launcher/ui/dialogs/ImportResourceDialog.ui @@ -0,0 +1,81 @@ + + + ImportResourceDialog + + + + 0 + 0 + 676 + 555 + + + + Choose instance to import to + + + + + + Choose the instance you would like to import this resource pack to. + + + + + + + + + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + buttonBox + accepted() + ImportResourceDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + ImportResourceDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/launcher/ui/dialogs/InstallLoaderDialog.cpp b/launcher/ui/dialogs/InstallLoaderDialog.cpp new file mode 100644 index 0000000..2e9abe6 --- /dev/null +++ b/launcher/ui/dialogs/InstallLoaderDialog.cpp @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "InstallLoaderDialog.h" + +#include +#include +#include +#include "Application.h" +#include "BuildConfig.h" +#include "DesktopServices.h" +#include "meta/Index.h" +#include "minecraft/MinecraftInstance.h" +#include "minecraft/PackProfile.h" +#include "ui/widgets/PageContainer.h" +#include "ui/widgets/VersionSelectWidget.h" + +class InstallLoaderPage : public VersionSelectWidget, public BasePage { + Q_OBJECT + public: + InstallLoaderPage(const QString& id, const QString& iconName, const QString& name, const Version& oldestVersion, PackProfile* profile) + : VersionSelectWidget(nullptr), uid(id), iconName(iconName), name(name) + { + const QString minecraftVersion = profile->getComponentVersion("net.minecraft"); + setEmptyString(tr("No versions are currently available for Minecraft %1").arg(minecraftVersion)); + setExactIfPresentFilter(BaseVersionList::ParentVersionRole, minecraftVersion); + + if (oldestVersion != Version() && Version(minecraftVersion) < oldestVersion) + setExactFilter(BaseVersionList::ParentVersionRole, "AAA"); + + if (const QString currentVersion = profile->getComponentVersion(id); !currentVersion.isNull()) + setCurrentVersion(currentVersion); + } + + QString id() const override { return uid; } + QString displayName() const override { return name; } + QIcon icon() const override { return QIcon::fromTheme(iconName); } + + void openedImpl() override + { + if (loaded) + return; + + const auto versions = APPLICATION->metadataIndex()->get(uid); + if (!versions) + return; + + initialize(versions.get()); + loaded = true; + } + + void setParentContainer(BasePageContainer* container) override + { + auto dialog = dynamic_cast(dynamic_cast(container)->parent()); + connect(view(), &QAbstractItemView::doubleClicked, dialog, &QDialog::accept); + } + + private: + const QString uid; + const QString iconName; + const QString name; + bool loaded = false; +}; + +static InstallLoaderPage* pageCast(BasePage* page) +{ + auto result = dynamic_cast(page); + Q_ASSERT(result != nullptr); + return result; +} + +InstallLoaderDialog::InstallLoaderDialog(PackProfile* profile, const QString& uid, QWidget* parent) + : QDialog(parent), profile(profile), container(new PageContainer(this, QString(), this)), buttons(new QDialogButtonBox(this)) +{ + auto layout = new QVBoxLayout(this); + // small margins look ugly on macOS on modal windows + #ifndef Q_OS_MACOS + layout->setContentsMargins(0, 0, 0, 0); + #endif + container->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding); + layout->addWidget(container); + + auto buttonLayout = new QHBoxLayout(this); + // small margins look ugly on macOS on modal windows + #ifndef Q_OS_MACOS + buttonLayout->setContentsMargins(0, 0, 6, 6); + #endif + auto refreshButton = new QPushButton(tr("&Refresh"), this); + connect(refreshButton, &QPushButton::clicked, this, [this] { pageCast(container->selectedPage())->loadList(); }); + buttonLayout->addWidget(refreshButton); + + buttons->setOrientation(Qt::Horizontal); + buttons->setStandardButtons(QDialogButtonBox::Cancel | QDialogButtonBox::Ok); + buttons->button(QDialogButtonBox::Ok)->setText(tr("Ok")); + buttons->button(QDialogButtonBox::Cancel)->setText(tr("Cancel")); + connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + buttonLayout->addWidget(buttons); + + container->addButtons(buttonLayout); + + setWindowTitle(dialogTitle()); + setWindowModality(Qt::WindowModal); + resize(520, 347); + + for (BasePage* page : container->getPages()) { + if (page->id() == uid) + container->selectPage(page->id()); + + connect(pageCast(page), &VersionSelectWidget::selectedVersionChanged, this, [this, page] { + if (page->id() == container->selectedPage()->id()) + validate(container->selectedPage()); + }); + } + connect(container, &PageContainer::selectedPageChanged, this, [this](BasePage* previous, BasePage* current) { validate(current); }); + pageCast(container->selectedPage())->selectSearch(); + validate(container->selectedPage()); +} + +QList InstallLoaderDialog::getPages() +{ + return { // NeoForge + new InstallLoaderPage("net.neoforged", "neoforged", tr("NeoForge"), {}, profile), + // Forge + new InstallLoaderPage("net.minecraftforge", "forge", tr("Forge"), {}, profile), + // Fabric + new InstallLoaderPage("net.fabricmc.fabric-loader", "fabricmc", tr("Fabric"), Version("1.14"), profile), + // Quilt + new InstallLoaderPage("org.quiltmc.quilt-loader", "quiltmc", tr("Quilt"), Version("1.14"), profile), + // LiteLoader + new InstallLoaderPage("com.mumfrey.liteloader", "liteloader", tr("LiteLoader"), {}, profile) + }; +} + +QString InstallLoaderDialog::dialogTitle() +{ + return tr("Install Loader"); +} + +void InstallLoaderDialog::validate(BasePage* page) +{ + buttons->button(QDialogButtonBox::Ok)->setEnabled(pageCast(page)->selectedVersion() != nullptr); +} + +void InstallLoaderDialog::done(int result) +{ + if (result == Accepted) { + auto* page = pageCast(container->selectedPage()); + if (page->selectedVersion()) { + profile->setComponentVersion(page->id(), page->selectedVersion()->descriptor()); + profile->resolve(Net::Mode::Online); + } + } + + QDialog::done(result); +} +#include "InstallLoaderDialog.moc" diff --git a/launcher/ui/dialogs/InstallLoaderDialog.h b/launcher/ui/dialogs/InstallLoaderDialog.h new file mode 100644 index 0000000..501f136 --- /dev/null +++ b/launcher/ui/dialogs/InstallLoaderDialog.h @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include "ui/pages/BasePageProvider.h" + +class MinecraftInstance; +class PageContainer; +class PackProfile; +class QDialogButtonBox; + +class InstallLoaderDialog final : public QDialog, protected BasePageProvider { + Q_OBJECT + + public: + explicit InstallLoaderDialog(PackProfile* instance, const QString& uid = QString(), QWidget* parent = nullptr); + + QList getPages() override; + QString dialogTitle() override; + + void validate(BasePage* page); + void done(int result) override; + + private: + PackProfile* profile; + PageContainer* container; + QDialogButtonBox* buttons; +}; diff --git a/launcher/ui/dialogs/MSALoginDialog.cpp b/launcher/ui/dialogs/MSALoginDialog.cpp new file mode 100644 index 0000000..e238a54 --- /dev/null +++ b/launcher/ui/dialogs/MSALoginDialog.cpp @@ -0,0 +1,234 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "MSALoginDialog.h" +#include "Application.h" +#include "settings/SettingsObject.h" + +#include "ui_MSALoginDialog.h" + +#include "DesktopServices.h" +#include "minecraft/auth/AuthFlow.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "qrencode.h" + +MSALoginDialog::MSALoginDialog(QWidget* parent) : QDialog(parent), ui(new Ui::MSALoginDialog) +{ + ui->setupUi(this); + + // make font monospace + QFont font; + font.setPixelSize(ui->code->fontInfo().pixelSize()); + font.setFamily(APPLICATION->settings()->get("ConsoleFont").toString()); + font.setStyleHint(QFont::Monospace); + font.setFixedPitch(true); + ui->code->setFont(font); + + connect(ui->copyCode, &QPushButton::clicked, this, [this] { QApplication::clipboard()->setText(ui->code->text()); }); + connect(ui->loginButton, &QPushButton::clicked, this, [this] { + if (m_url.isValid()) { + if (!DesktopServices::openUrl(m_url)) { + QApplication::clipboard()->setText(m_url.toString()); + } + } + }); + + ui->buttonBox->button(QDialogButtonBox::Cancel)->setText(tr("Cancel")); +} + +int MSALoginDialog::exec() +{ + // Setup the login task and start it + m_account = MinecraftAccount::createBlankMSA(); + m_authflow_task = m_account->login(false); + connect(m_authflow_task.get(), &Task::failed, this, &MSALoginDialog::onTaskFailed); + connect(m_authflow_task.get(), &Task::succeeded, this, &QDialog::accept); + connect(m_authflow_task.get(), &Task::aborted, this, &MSALoginDialog::reject); + connect(m_authflow_task.get(), &Task::status, this, &MSALoginDialog::onAuthFlowStatus); + connect(m_authflow_task.get(), &AuthFlow::authorizeWithBrowser, this, &MSALoginDialog::authorizeWithBrowser); + connect(m_authflow_task.get(), &AuthFlow::authorizeWithBrowserWithExtra, this, &MSALoginDialog::authorizeWithBrowserWithExtra); + connect(ui->buttonBox->button(QDialogButtonBox::Cancel), &QPushButton::clicked, m_authflow_task.get(), &Task::abort); + + m_devicecode_task.reset(new AuthFlow(m_account->accountData(), AuthFlow::Action::DeviceCode)); + connect(m_devicecode_task.get(), &Task::failed, this, &MSALoginDialog::onTaskFailed); + connect(m_devicecode_task.get(), &Task::succeeded, this, &QDialog::accept); + connect(m_devicecode_task.get(), &Task::aborted, this, &MSALoginDialog::reject); + connect(m_devicecode_task.get(), &Task::status, this, &MSALoginDialog::onDeviceFlowStatus); + connect(m_devicecode_task.get(), &AuthFlow::authorizeWithBrowser, this, &MSALoginDialog::authorizeWithBrowser); + connect(m_devicecode_task.get(), &AuthFlow::authorizeWithBrowserWithExtra, this, &MSALoginDialog::authorizeWithBrowserWithExtra); + connect(ui->buttonBox->button(QDialogButtonBox::Cancel), &QPushButton::clicked, m_devicecode_task.get(), &Task::abort); + QMetaObject::invokeMethod(m_authflow_task.get(), &Task::start, Qt::QueuedConnection); + QMetaObject::invokeMethod(m_devicecode_task.get(), &Task::start, Qt::QueuedConnection); + + return QDialog::exec(); +} + +MSALoginDialog::~MSALoginDialog() +{ + delete ui; +} + +void MSALoginDialog::onTaskFailed(QString reason) +{ + // Set message + m_authflow_task->disconnect(); + m_devicecode_task->disconnect(); + ui->stackedWidget->setCurrentIndex(0); + auto lines = reason.split('\n'); + QString processed; + for (auto line : lines) { + if (line.size()) { + processed += "" + line + "
    "; + } else { + processed += "
    "; + } + } + ui->status->setText(processed); + auto task = m_authflow_task; + if (task->failReason().isEmpty()) { + task = m_devicecode_task; + } + if (task) { + ui->loadingLabel->setText(task->getStatus()); + } + disconnect(ui->buttonBox->button(QDialogButtonBox::Cancel), &QPushButton::clicked, m_authflow_task.get(), &Task::abort); + disconnect(ui->buttonBox->button(QDialogButtonBox::Cancel), &QPushButton::clicked, m_devicecode_task.get(), &Task::abort); + connect(ui->buttonBox->button(QDialogButtonBox::Cancel), &QPushButton::clicked, this, &MSALoginDialog::reject); +} + +void MSALoginDialog::authorizeWithBrowser(const QUrl& url) +{ + ui->stackedWidget2->setCurrentIndex(1); + ui->stackedWidget2->adjustSize(); + ui->stackedWidget2->updateGeometry(); + this->adjustSize(); + ui->loginButton->setToolTip(QString("
    %1
    ").arg(url.toString())); + m_url = url; +} + +void paintQR(QPainter& painter, const QSize canvasSize, const QString& data, QColor fg) +{ + const auto* qr = QRcode_encodeString(data.toUtf8().constData(), 0, QRecLevel::QR_ECLEVEL_M, QRencodeMode::QR_MODE_8, 1); + if (!qr) { + qWarning() << "Unable to encode" << data << "as QR code"; + return; + } + + painter.setPen(Qt::NoPen); + painter.setBrush(fg); + + // Make sure the QR code fits in the canvas with some padding + const auto qrSize = qr->width; + const auto canvasWidth = canvasSize.width(); + const auto canvasHeight = canvasSize.height(); + const auto scale = 0.8 * std::min(canvasWidth / qrSize, canvasHeight / qrSize); + + // Find an offset to center it in the canvas + const auto offsetX = (canvasWidth - qrSize * scale) / 2; + const auto offsetY = (canvasHeight - qrSize * scale) / 2; + + for (int y = 0; y < qrSize; y++) { + for (int x = 0; x < qrSize; x++) { + auto shouldFillIn = qr->data[y * qrSize + x] & 1; + if (shouldFillIn) { + QRectF r(offsetX + x * scale, offsetY + y * scale, scale, scale); + painter.drawRects(&r, 1); + } + } + } +} + +void MSALoginDialog::authorizeWithBrowserWithExtra(QString url, QString code, [[maybe_unused]] int expiresIn) +{ + ui->stackedWidget->setCurrentIndex(1); + ui->stackedWidget->adjustSize(); + ui->stackedWidget->updateGeometry(); + this->adjustSize(); + + const auto linkString = QString("%2").arg(url, url); + if (url == "https://www.microsoft.com/link" && !code.isEmpty()) { + url += QString("?otc=%1").arg(code); + } + ui->code->setText(code); + + auto size = QSize(150, 150); + QPixmap pixmap(size); + pixmap.fill(Qt::white); + + QPainter painter(&pixmap); + paintQR(painter, size, url, Qt::black); + + // Set the generated pixmap to the label + ui->qr->setPixmap(pixmap); + + ui->qrMessage->setText(tr("Open %1 or scan the QR and enter the above code if needed.").arg(linkString)); +} + +void MSALoginDialog::onDeviceFlowStatus(QString status) +{ + ui->stackedWidget->setCurrentIndex(0); + ui->stackedWidget->adjustSize(); + ui->stackedWidget->updateGeometry(); + this->adjustSize(); + ui->status->setText(status); +} + +void MSALoginDialog::onAuthFlowStatus(QString status) +{ + ui->stackedWidget2->setCurrentIndex(0); + ui->stackedWidget2->adjustSize(); + ui->stackedWidget2->updateGeometry(); + this->adjustSize(); + ui->status2->setText(status); +} + +// Public interface +MinecraftAccountPtr MSALoginDialog::newAccount(QWidget* parent) +{ + MSALoginDialog dlg(parent); + if (dlg.exec() == QDialog::Accepted) { + return dlg.m_account; + } + return nullptr; +} diff --git a/launcher/ui/dialogs/MSALoginDialog.h b/launcher/ui/dialogs/MSALoginDialog.h new file mode 100644 index 0000000..f19abbe --- /dev/null +++ b/launcher/ui/dialogs/MSALoginDialog.h @@ -0,0 +1,54 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +#include "minecraft/auth/AuthFlow.h" +#include "minecraft/auth/MinecraftAccount.h" + +namespace Ui { +class MSALoginDialog; +} + +class MSALoginDialog : public QDialog { + Q_OBJECT + + public: + ~MSALoginDialog(); + + static MinecraftAccountPtr newAccount(QWidget* parent); + int exec() override; + + private: + explicit MSALoginDialog(QWidget* parent = 0); + + protected slots: + void onTaskFailed(QString reason); + void onDeviceFlowStatus(QString status); + void onAuthFlowStatus(QString status); + void authorizeWithBrowser(const QUrl& url); + void authorizeWithBrowserWithExtra(QString url, QString code, int expiresIn); + + private: + Ui::MSALoginDialog* ui; + MinecraftAccountPtr m_account; + shared_qobject_ptr m_devicecode_task; + shared_qobject_ptr m_authflow_task; + + QUrl m_url; +}; diff --git a/launcher/ui/dialogs/MSALoginDialog.ui b/launcher/ui/dialogs/MSALoginDialog.ui new file mode 100644 index 0000000..69cd2e1 --- /dev/null +++ b/launcher/ui/dialogs/MSALoginDialog.ui @@ -0,0 +1,429 @@ + + + MSALoginDialog + + + + 0 + 0 + 440 + 447 + + + + + 0 + 430 + + + + Add Microsoft Account + + + + + + 1 + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + 16 + 75 + true + + + + Please wait... + + + Qt::AlignCenter + + + true + + + + + + + Status + + + Qt::AlignCenter + + + true + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 250 + 40 + + + + Sign in with Microsoft + + + true + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + + + + 0 + 0 + + + + Qt::Horizontal + + + + + + + + 16 + + + + Or + + + Qt::AlignCenter + + + + + + + + 0 + 0 + + + + Qt::Horizontal + + + + + + + + + 1 + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + 16 + 75 + true + + + + Please wait... + + + Qt::AlignCenter + + + true + + + + + + + Status + + + Qt::AlignCenter + + + true + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 0 + 0 + + + + + 150 + 150 + + + + + 150 + 150 + + + + + + + true + + + Qt::AlignCenter + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 30 + 75 + true + + + + IBeamCursor + + + CODE + + + Qt::AlignCenter + + + Qt::TextBrowserInteraction + + + + + + + Copy code to clipboard + + + + + + + .. + + + + 22 + 22 + + + + true + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + Info + + + Qt::AlignCenter + + + true + + + true + + + Qt::TextBrowserInteraction + + + + + + + + + + + QDialogButtonBox::Cancel + + + + + + + + diff --git a/launcher/ui/dialogs/NetworkJobFailedDialog.cpp b/launcher/ui/dialogs/NetworkJobFailedDialog.cpp new file mode 100644 index 0000000..e0d3a2c --- /dev/null +++ b/launcher/ui/dialogs/NetworkJobFailedDialog.cpp @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2025 Octol1ttle + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "NetworkJobFailedDialog.h" + +#include +#include +#include +#include + +#include "ui_NetworkJobFailedDialog.h" + +NetworkJobFailedDialog::NetworkJobFailedDialog(const QString& jobName, const int attempts, const int requests, const int failed, QWidget* parent) + : QDialog(parent), m_ui(new Ui::NetworkJobFailedDialog) +{ + m_ui->setupUi(this); + m_ui->failLabel->setText(m_ui->failLabel->text().arg(jobName)); + if (failed == requests) { + m_ui->requestCountLabel->setText(tr("All %1 requests have failed after %2 attempts").arg(failed).arg(attempts)); + } else if (failed < requests / 2) { + m_ui->requestCountLabel->setText( + tr("Out of %1 requests, %2 have failed after %3 attempts").arg(requests).arg(failed).arg(attempts)); + } else { + m_ui->requestCountLabel->setText( + tr("Out of %1 requests, only %2 succeeded after %3 attempts").arg(requests).arg(requests - failed).arg(attempts)); + } + + m_ui->detailsTable->header()->setSectionResizeMode(0, QHeaderView::Stretch); + m_ui->detailsTable->header()->setSectionResizeMode(1, QHeaderView::ResizeToContents); + + m_ui->detailsTable->setSelectionMode(QAbstractItemView::ExtendedSelection); + + const auto* copyShortcut = new QShortcut(QKeySequence::Copy, m_ui->detailsTable); + connect(copyShortcut, &QShortcut::activated, this, &NetworkJobFailedDialog::copyUrl); + + const auto* copyButton = m_ui->dialogButtonBox->addButton(tr("Copy URL"), QDialogButtonBox::ActionRole); + connect(copyButton, &QPushButton::clicked, this, &NetworkJobFailedDialog::copyUrl); + + connect(m_ui->dialogButtonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(m_ui->dialogButtonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); +} + +NetworkJobFailedDialog::~NetworkJobFailedDialog() +{ + delete m_ui; +} + +void NetworkJobFailedDialog::addFailedRequest(const QUrl& url, QString error) const +{ + auto* item = new QTreeWidgetItem(m_ui->detailsTable, { url.toString(), std::move(error) }); + m_ui->detailsTable->addTopLevelItem(item); + if (m_ui->detailsTable->selectedItems().isEmpty()) { + m_ui->detailsTable->setCurrentItem(item); + } +} + +void NetworkJobFailedDialog::copyUrl() const +{ + auto items = m_ui->detailsTable->selectedItems(); + if (items.isEmpty()) { + return; + } + + QString urls = items.first()->text(0); + for (auto& item : items.sliced(1)) { + urls += "\n" + item->text(0); + } + + auto* clipboard = QGuiApplication::clipboard(); + clipboard->setText(urls); +} diff --git a/launcher/ui/dialogs/NetworkJobFailedDialog.h b/launcher/ui/dialogs/NetworkJobFailedDialog.h new file mode 100644 index 0000000..9bfb7c4 --- /dev/null +++ b/launcher/ui/dialogs/NetworkJobFailedDialog.h @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2025 Octol1ttle + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include + +QT_BEGIN_NAMESPACE +namespace Ui { +class NetworkJobFailedDialog; +} +QT_END_NAMESPACE + +class NetworkJobFailedDialog : public QDialog { + Q_OBJECT + + public: + explicit NetworkJobFailedDialog(const QString& jobName, int attempts, int requests, int failed, QWidget* parent = nullptr); + ~NetworkJobFailedDialog() override; + + void addFailedRequest(const QUrl& url, QString error) const; + + private slots: + void copyUrl() const; + + private: + Ui::NetworkJobFailedDialog* m_ui; +}; diff --git a/launcher/ui/dialogs/NetworkJobFailedDialog.ui b/launcher/ui/dialogs/NetworkJobFailedDialog.ui new file mode 100644 index 0000000..b133052 --- /dev/null +++ b/launcher/ui/dialogs/NetworkJobFailedDialog.ui @@ -0,0 +1,99 @@ + + + NetworkJobFailedDialog + + + + 0 + 0 + 450 + 350 + + + + Network error + + + + + + + + + + 0 + 0 + + + + A network operation has failed: %1 + + + + + + + + 0 + 0 + + + + (request count) + + + + + + + QAbstractItemView::EditTrigger::NoEditTriggers + + + true + + + QAbstractItemView::ScrollMode::ScrollPerPixel + + + 0 + + + false + + + + URL + + + + + Error + + + + + + + + + 0 + 0 + + + + What would you like to do? + + + + + + + QDialogButtonBox::StandardButton::Abort|QDialogButtonBox::StandardButton::Retry + + + + + + + + diff --git a/launcher/ui/dialogs/NewComponentDialog.cpp b/launcher/ui/dialogs/NewComponentDialog.cpp new file mode 100644 index 0000000..d1e4208 --- /dev/null +++ b/launcher/ui/dialogs/NewComponentDialog.cpp @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "NewComponentDialog.h" +#include "Application.h" +#include "ui_NewComponentDialog.h" + +#include +#include +#include +#include + +#include "IconPickerDialog.h" +#include "ProgressDialog.h" +#include "VersionSelectDialog.h" + +#include +#include +#include +#include + +#include +#include + +NewComponentDialog::NewComponentDialog(const QString& initialName, const QString& initialUid, QWidget* parent) + : QDialog(parent), ui(new Ui::NewComponentDialog) +{ + ui->setupUi(this); + resize(minimumSizeHint()); + + ui->nameTextBox->setText(initialName); + ui->uidTextBox->setText(initialUid); + + connect(ui->nameTextBox, &QLineEdit::textChanged, this, &NewComponentDialog::updateDialogState); + connect(ui->uidTextBox, &QLineEdit::textChanged, this, &NewComponentDialog::updateDialogState); + + ui->nameTextBox->setFocus(); + + ui->buttonBox->button(QDialogButtonBox::Cancel)->setText(tr("Cancel")); + ui->buttonBox->button(QDialogButtonBox::Ok)->setText(tr("OK")); + + originalPlaceholderText = ui->uidTextBox->placeholderText(); + updateDialogState(); +} + +NewComponentDialog::~NewComponentDialog() +{ + delete ui; +} + +void NewComponentDialog::updateDialogState() +{ + auto protoUid = ui->nameTextBox->text().toLower(); + static const QRegularExpression s_removeChars("[^a-z]"); + protoUid.remove(s_removeChars); + if (protoUid.isEmpty()) { + ui->uidTextBox->setPlaceholderText(originalPlaceholderText); + } else { + QString suggestedUid = "org.multimc.custom." + protoUid; + ui->uidTextBox->setPlaceholderText(suggestedUid); + } + bool allowOK = !name().isEmpty() && !uid().isEmpty() && !uidBlacklist.contains(uid()); + ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(allowOK); +} + +QString NewComponentDialog::name() const +{ + auto result = ui->nameTextBox->text(); + if (result.size()) { + return result.trimmed(); + } + return QString(); +} + +QString NewComponentDialog::uid() const +{ + auto result = ui->uidTextBox->text(); + if (result.size()) { + return result.trimmed(); + } + result = ui->uidTextBox->placeholderText(); + if (result.size() && result != originalPlaceholderText) { + return result.trimmed(); + } + return QString(); +} + +void NewComponentDialog::setBlacklist(QStringList badUids) +{ + uidBlacklist = badUids; +} diff --git a/launcher/ui/dialogs/NewComponentDialog.h b/launcher/ui/dialogs/NewComponentDialog.h new file mode 100644 index 0000000..4fb68ff --- /dev/null +++ b/launcher/ui/dialogs/NewComponentDialog.h @@ -0,0 +1,46 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +#include +#include + +namespace Ui { +class NewComponentDialog; +} + +class NewComponentDialog : public QDialog { + Q_OBJECT + + public: + explicit NewComponentDialog(const QString& initialName = QString(), const QString& initialUid = QString(), QWidget* parent = 0); + virtual ~NewComponentDialog(); + void setBlacklist(QStringList badUids); + + QString name() const; + QString uid() const; + + private slots: + void updateDialogState(); + + private: + Ui::NewComponentDialog* ui; + + QString originalPlaceholderText; + QStringList uidBlacklist; +}; diff --git a/launcher/ui/dialogs/NewComponentDialog.ui b/launcher/ui/dialogs/NewComponentDialog.ui new file mode 100644 index 0000000..03b0d22 --- /dev/null +++ b/launcher/ui/dialogs/NewComponentDialog.ui @@ -0,0 +1,101 @@ + + + NewComponentDialog + + + Qt::ApplicationModal + + + + 0 + 0 + 345 + 146 + + + + Add Empty Component + + + + :/icons/toolbar/copy:/icons/toolbar/copy + + + true + + + + + + Name + + + + + + + uid + + + + + + + Qt::Horizontal + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + nameTextBox + uidTextBox + + + + + + + buttonBox + accepted() + NewComponentDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + NewComponentDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/launcher/ui/dialogs/NewInstanceDialog.cpp b/launcher/ui/dialogs/NewInstanceDialog.cpp new file mode 100644 index 0000000..8cf0945 --- /dev/null +++ b/launcher/ui/dialogs/NewInstanceDialog.cpp @@ -0,0 +1,336 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "NewInstanceDialog.h" +#include "Application.h" +#include "ui/pages/modplatform/ModpackProviderBasePage.h" +#include "ui/pages/modplatform/import_ftb/ImportFTBPage.h" +#include "ui_NewInstanceDialog.h" + +#include +#include +#include +#include + +#include "IconPickerDialog.h" +#include "ProgressDialog.h" +#include "VersionSelectDialog.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "ui/pages/modplatform/CustomPage.h" +#include "ui/pages/modplatform/ImportPage.h" +#include "ui/pages/modplatform/atlauncher/AtlPage.h" +#include "ui/pages/modplatform/flame/FlamePage.h" +#include "ui/pages/modplatform/ftb/FtbPage.h" +#include "ui/pages/modplatform/legacy_ftb/Page.h" +#include "ui/pages/modplatform/modrinth/ModrinthPage.h" +#include "ui/pages/modplatform/technic/TechnicPage.h" +#include "ui/widgets/PageContainer.h" + +NewInstanceDialog::NewInstanceDialog(const QString& initialGroup, + const QString& url, + const QMap& extra_info, + QWidget* parent) + : QDialog(parent), ui(new Ui::NewInstanceDialog) +{ + ui->setupUi(this); + + setWindowIcon(QIcon::fromTheme("new")); + + InstIconKey = "default"; + ui->iconButton->setIcon(APPLICATION->icons()->getIcon(InstIconKey)); + + QStringList groups = APPLICATION->instances()->getGroups(); + groups.prepend(""); + int index = groups.indexOf(initialGroup); + if (index == -1) { + index = 1; + groups.insert(index, initialGroup); + } + ui->groupBox->addItems(groups); + ui->groupBox->setCurrentIndex(index); + ui->groupBox->lineEdit()->setPlaceholderText(tr("No group")); + + // NOTE: m_buttons must be initialized before PageContainer, because it indirectly accesses m_buttons through setSuggestedPack! Do not + // move this below. + m_buttons = new QDialogButtonBox(QDialogButtonBox::Help | QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + + m_container = new PageContainer(this, {}, this); + m_container->useSidebarStyle(false); + m_container->setSizePolicy(QSizePolicy::Policy::Preferred, QSizePolicy::Policy::Expanding); + m_container->layout()->setContentsMargins(0, 0, 0, 0); + ui->verticalLayout->insertWidget(2, m_container); + + m_container->addButtons(m_buttons); + connect(m_container, &PageContainer::selectedPageChanged, this, [this](BasePage* previous, BasePage* selected) { + m_buttons->button(QDialogButtonBox::Ok)->setEnabled(creationTask && !instName().isEmpty()); + }); + + // Bonk Qt over its stupid head and make sure it understands which button is the default one... + // See: https://stackoverflow.com/questions/24556831/qbuttonbox-set-default-button + auto OkButton = m_buttons->button(QDialogButtonBox::Ok); + OkButton->setDefault(true); + OkButton->setAutoDefault(true); + OkButton->setText(tr("OK")); + connect(OkButton, &QPushButton::clicked, this, &NewInstanceDialog::accept); + + auto CancelButton = m_buttons->button(QDialogButtonBox::Cancel); + CancelButton->setDefault(false); + CancelButton->setAutoDefault(false); + CancelButton->setText(tr("Cancel")); + connect(CancelButton, &QPushButton::clicked, this, &NewInstanceDialog::reject); + + auto HelpButton = m_buttons->button(QDialogButtonBox::Help); + HelpButton->setDefault(false); + HelpButton->setAutoDefault(false); + HelpButton->setText(tr("Help")); + connect(HelpButton, &QPushButton::clicked, m_container, &PageContainer::help); + + if (!url.isEmpty()) { + QUrl actualUrl(url); + m_container->selectPage("import"); + importPage->setUrl(url); + importPage->setExtraInfo(extra_info); + } + + updateDialogState(); + + if (APPLICATION->settings()->get("NewInstanceGeometry").isValid()) { + restoreGeometry(QByteArray::fromBase64(APPLICATION->settings()->get("NewInstanceGeometry").toString().toUtf8())); + } else { + auto screen = parent->screen(); + auto geometry = screen->availableSize(); + resize(width(), qMin(geometry.height() - 50, 710)); + } + + connect(m_container, &PageContainer::selectedPageChanged, this, &NewInstanceDialog::selectedPageChanged); +} + +void NewInstanceDialog::reject() +{ + APPLICATION->settings()->set("NewInstanceGeometry", QString::fromUtf8(saveGeometry().toBase64())); + + // This is just so that the pages get the close() call and can react to it, if needed. + m_container->prepareToClose(); + + QDialog::reject(); +} + +void NewInstanceDialog::accept() +{ + APPLICATION->settings()->set("NewInstanceGeometry", QString::fromUtf8(saveGeometry().toBase64())); + importIconNow(); + + // This is just so that the pages get the close() call and can react to it, if needed. + m_container->prepareToClose(); + + QDialog::accept(); +} + +QList NewInstanceDialog::getPages() +{ + QList pages; + + importPage = new ImportPage(this); + + pages.append(new CustomPage(this)); + pages.append(importPage); + pages.append(new AtlPage(this)); + if (APPLICATION->capabilities() & Application::SupportsFlame) + pages.append(new FlamePage(this)); + pages.append(new FtbPage(this)); + pages.append(new LegacyFTB::Page(this)); + pages.append(new FTBImportAPP::ImportFTBPage(this)); + pages.append(new ModrinthPage(this)); + pages.append(new TechnicPage(this)); + + return pages; +} + +QString NewInstanceDialog::dialogTitle() +{ + return tr("New Instance"); +} + +NewInstanceDialog::~NewInstanceDialog() +{ + delete ui; +} + +void NewInstanceDialog::setSuggestedPack(const QString& name, InstanceTask* task) +{ + creationTask.reset(task); + + ui->instNameTextBox->setPlaceholderText(name); + importVersion.clear(); + + if (!task) { + ui->iconButton->setIcon(APPLICATION->icons()->getIcon(InstIconKey)); + importIcon = false; + } + + auto allowOK = task && !instName().isEmpty(); + m_buttons->button(QDialogButtonBox::Ok)->setEnabled(allowOK); +} + +void NewInstanceDialog::setSuggestedPack(const QString& name, QString version, InstanceTask* task) +{ + creationTask.reset(task); + + ui->instNameTextBox->setPlaceholderText(name); + importVersion = std::move(version); + + if (!task) { + ui->iconButton->setIcon(APPLICATION->icons()->getIcon(InstIconKey)); + importIcon = false; + } + + auto allowOK = task && !instName().isEmpty(); + m_buttons->button(QDialogButtonBox::Ok)->setEnabled(allowOK); +} + +void NewInstanceDialog::setSuggestedIconFromFile(const QString& path, const QString& name) +{ + importIcon = true; + importIconPath = path; + importIconName = name; + + // Hmm, for some reason they can be to small + ui->iconButton->setIcon(QIcon(path)); +} + +void NewInstanceDialog::setSuggestedIcon(const QString& key) +{ + if (key == "default") + return; + + auto icon = APPLICATION->icons()->getIcon(key); + importIcon = false; + + ui->iconButton->setIcon(icon); +} + +InstanceTask* NewInstanceDialog::extractTask() +{ + InstanceTask* extracted = creationTask.release(); + + InstanceName inst_name(ui->instNameTextBox->placeholderText().trimmed(), importVersion); + inst_name.setName(ui->instNameTextBox->text().trimmed()); + extracted->setName(inst_name); + + extracted->setGroup(instGroup()); + extracted->setIcon(iconKey()); + return extracted; +} + +void NewInstanceDialog::updateDialogState() +{ + auto allowOK = creationTask && !instName().isEmpty(); + auto OkButton = m_buttons->button(QDialogButtonBox::Ok); + if (OkButton->isEnabled() != allowOK) { + OkButton->setEnabled(allowOK); + } +} + +QString NewInstanceDialog::instName() const +{ + auto result = ui->instNameTextBox->text().trimmed(); + if (result.size()) { + return result; + } + result = ui->instNameTextBox->placeholderText().trimmed(); + if (result.size()) { + return result; + } + return QString(); +} + +QString NewInstanceDialog::instGroup() const +{ + return ui->groupBox->currentText(); +} +QString NewInstanceDialog::iconKey() const +{ + return InstIconKey; +} + +void NewInstanceDialog::on_iconButton_clicked() +{ + importIconNow(); // so the user can switch back + IconPickerDialog dlg(this); + dlg.execWithSelection(InstIconKey); + + if (dlg.result() == QDialog::Accepted) { + InstIconKey = dlg.selectedIconKey; + ui->iconButton->setIcon(APPLICATION->icons()->getIcon(InstIconKey)); + importIcon = false; + } +} + +void NewInstanceDialog::on_instNameTextBox_textChanged([[maybe_unused]] const QString& arg1) +{ + updateDialogState(); +} + +void NewInstanceDialog::importIconNow() +{ + if (importIcon) { + APPLICATION->icons()->installIcon(importIconPath, importIconName); + InstIconKey = importIconName.mid(0, importIconName.lastIndexOf('.')); + importIcon = false; + } + APPLICATION->settings()->set("NewInstanceGeometry", QString::fromUtf8(saveGeometry().toBase64())); +} + +void NewInstanceDialog::selectedPageChanged(BasePage* previous, BasePage* selected) +{ + auto prevPage = dynamic_cast(previous); + if (prevPage) { + m_searchTerm = prevPage->getSerachTerm(); + } + + auto nextPage = dynamic_cast(selected); + if (nextPage) { + nextPage->setSearchTerm(m_searchTerm); + } +} diff --git a/launcher/ui/dialogs/NewInstanceDialog.h b/launcher/ui/dialogs/NewInstanceDialog.h new file mode 100644 index 0000000..e97c9f5 --- /dev/null +++ b/launcher/ui/dialogs/NewInstanceDialog.h @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +#include "InstanceTask.h" +#include "ui/pages/BasePageProvider.h" + +namespace Ui { +class NewInstanceDialog; +} + +class PageContainer; +class QDialogButtonBox; +class ImportPage; +class FlamePage; + +class NewInstanceDialog : public QDialog, public BasePageProvider { + Q_OBJECT + + public: + explicit NewInstanceDialog(const QString& initialGroup, + const QString& url = QString(), + const QMap& extra_info = {}, + QWidget* parent = 0); + ~NewInstanceDialog(); + + void updateDialogState(); + + void setSuggestedPack(const QString& name = QString(), InstanceTask* task = nullptr); + void setSuggestedPack(const QString& name, QString version, InstanceTask* task = nullptr); + void setSuggestedIconFromFile(const QString& path, const QString& name); + void setSuggestedIcon(const QString& key); + + InstanceTask* extractTask(); + + QString dialogTitle() override; + QList getPages() override; + + QString instName() const; + QString instGroup() const; + QString iconKey() const; + + public slots: + void accept() override; + void reject() override; + + private slots: + void on_iconButton_clicked(); + void on_instNameTextBox_textChanged(const QString& arg1); + void selectedPageChanged(BasePage* previous, BasePage* selected); + + private: + Ui::NewInstanceDialog* ui = nullptr; + PageContainer* m_container = nullptr; + QDialogButtonBox* m_buttons = nullptr; + + QString InstIconKey; + ImportPage* importPage = nullptr; + std::unique_ptr creationTask; + + bool importIcon = false; + QString importIconPath; + QString importIconName; + + QString importVersion; + + QString m_searchTerm; + + void importIconNow(); +}; diff --git a/launcher/ui/dialogs/NewInstanceDialog.ui b/launcher/ui/dialogs/NewInstanceDialog.ui new file mode 100644 index 0000000..8ca0b78 --- /dev/null +++ b/launcher/ui/dialogs/NewInstanceDialog.ui @@ -0,0 +1,91 @@ + + + NewInstanceDialog + + + Qt::ApplicationModal + + + + 0 + 0 + 730 + 127 + + + + New Instance + + + + :/icons/toolbar/new:/icons/toolbar/new + + + true + + + + + + + + true + + + + + + + &Group: + + + groupBox + + + + + + + 128 + + + + + + + &Name: + + + instNameTextBox + + + + + + + + 80 + 80 + + + + + + + + + + Qt::Horizontal + + + + + + + iconButton + instNameTextBox + groupBox + + + + diff --git a/launcher/ui/dialogs/NewsDialog.cpp b/launcher/ui/dialogs/NewsDialog.cpp new file mode 100644 index 0000000..0657c89 --- /dev/null +++ b/launcher/ui/dialogs/NewsDialog.cpp @@ -0,0 +1,63 @@ +#include "NewsDialog.h" + +#include "Application.h" +#include "settings/SettingsObject.h" + +#include "ui_NewsDialog.h" + +NewsDialog::NewsDialog(QList entries, QWidget* parent) : QDialog(parent), ui(new Ui::NewsDialog()) +{ + ui->setupUi(this); + + for (auto entry : entries) { + ui->articleListWidget->addItem(entry->title); + m_entries.insert(entry->title, entry); + } + + connect(ui->articleListWidget, &QListWidget::currentTextChanged, this, &NewsDialog::selectedArticleChanged); + connect(ui->toggleListButton, &QPushButton::clicked, this, &NewsDialog::toggleArticleList); + + m_article_list_hidden = ui->articleListWidget->isHidden(); + + auto first_item = ui->articleListWidget->item(0); + first_item->setSelected(true); + + auto article_entry = m_entries.constFind(first_item->text()).value(); + ui->articleTitleLabel->setText(QString("%2").arg(article_entry->link, first_item->text())); + + ui->currentArticleContentBrowser->setText(article_entry->content); + ui->currentArticleContentBrowser->flush(); + + connect(this, &QDialog::finished, this, [this] { + APPLICATION->settings()->set("NewsGeometry", QString::fromUtf8(saveGeometry().toBase64())); + }); + const QByteArray base64Geometry = APPLICATION->settings()->get("NewsGeometry").toString().toUtf8(); + restoreGeometry(QByteArray::fromBase64(base64Geometry)); +} + +NewsDialog::~NewsDialog() +{ + delete ui; +} + +void NewsDialog::selectedArticleChanged(const QString& new_title) +{ + auto article_entry = m_entries.constFind(new_title).value(); + + ui->articleTitleLabel->setText(QString("%2").arg(article_entry->link, new_title)); + + ui->currentArticleContentBrowser->setText(article_entry->content); + ui->currentArticleContentBrowser->flush(); +} + +void NewsDialog::toggleArticleList() +{ + m_article_list_hidden = !m_article_list_hidden; + + ui->articleListWidget->setHidden(m_article_list_hidden); + + if (m_article_list_hidden) + ui->toggleListButton->setText(tr("Show article list")); + else + ui->toggleListButton->setText(tr("Hide article list")); +} diff --git a/launcher/ui/dialogs/NewsDialog.h b/launcher/ui/dialogs/NewsDialog.h new file mode 100644 index 0000000..add6b8d --- /dev/null +++ b/launcher/ui/dialogs/NewsDialog.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include + +#include "news/NewsEntry.h" + +namespace Ui { +class NewsDialog; +} + +class NewsDialog : public QDialog { + Q_OBJECT + + public: + NewsDialog(QList entries, QWidget* parent = nullptr); + ~NewsDialog(); + + public slots: + void toggleArticleList(); + + private slots: + void selectedArticleChanged(const QString& new_title); + + private: + Ui::NewsDialog* ui; + + QHash m_entries; + bool m_article_list_hidden = false; +}; diff --git a/launcher/ui/dialogs/NewsDialog.ui b/launcher/ui/dialogs/NewsDialog.ui new file mode 100644 index 0000000..08f35a0 --- /dev/null +++ b/launcher/ui/dialogs/NewsDialog.ui @@ -0,0 +1,120 @@ + + + NewsDialog + + + + 0 + 0 + 800 + 500 + + + + News + + + true + + + + + + + + + + + 0 + 0 + + + + + + + + + + + + Placeholder + + + Qt::AlignCenter + + + true + + + + + + + Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse|Qt::TextBrowserInteraction|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse + + + true + + + true + + + + + + + + + + + + + + 10 + 0 + + + + Close + + + + + + + Hide article list + + + + + + + + + + ProjectDescriptionPage + QTextBrowser +
    ui/widgets/ProjectDescriptionPage.h
    +
    +
    + + + + closeButton + clicked() + NewsDialog + accept() + + + 199 + 277 + + + 199 + 149 + + + + +
    diff --git a/launcher/ui/dialogs/ProfileSelectDialog.cpp b/launcher/ui/dialogs/ProfileSelectDialog.cpp new file mode 100644 index 0000000..4c4995f --- /dev/null +++ b/launcher/ui/dialogs/ProfileSelectDialog.cpp @@ -0,0 +1,103 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ProfileSelectDialog.h" +#include "ui_ProfileSelectDialog.h" + +#include +#include +#include +#include + +#include "Application.h" + +// HACK: hide checkboxes from AccountList +class HideCheckboxProxyModel : public QIdentityProxyModel { + public: + using QIdentityProxyModel::QIdentityProxyModel; + + QVariant data(const QModelIndex& index, int role) const override + { + if (role == Qt::CheckStateRole) { + return {}; + } + + return QIdentityProxyModel::data(index, role); + } +}; + +ProfileSelectDialog::ProfileSelectDialog(const QString& message, int flags, QWidget* parent) + : QDialog(parent), ui(new Ui::ProfileSelectDialog) +{ + ui->setupUi(this); + + m_accounts = APPLICATION->accounts(); + + auto proxy = new HideCheckboxProxyModel(ui->view); + proxy->setSourceModel(m_accounts); + ui->view->setModel(proxy); + + // Set the message label. + ui->msgLabel->setVisible(!message.isEmpty()); + ui->msgLabel->setText(message); + + // Flags... + ui->globalDefaultCheck->setVisible(flags & GlobalDefaultCheckbox); + ui->instDefaultCheck->setVisible(flags & InstanceDefaultCheckbox); + qDebug() << flags; + + // Select the first entry in the list. + ui->view->setCurrentIndex(ui->view->model()->index(0, 0)); + + connect(ui->view, &QAbstractItemView::doubleClicked, this, &ProfileSelectDialog::on_buttonBox_accepted); + + ui->buttonBox->button(QDialogButtonBox::Cancel)->setText(tr("Cancel")); + ui->buttonBox->button(QDialogButtonBox::Ok)->setText(tr("OK")); +} + +ProfileSelectDialog::~ProfileSelectDialog() +{ + delete ui; +} + +MinecraftAccountPtr ProfileSelectDialog::selectedAccount() const +{ + return m_selected; +} + +bool ProfileSelectDialog::useAsGlobalDefault() const +{ + return ui->globalDefaultCheck->isChecked(); +} + +bool ProfileSelectDialog::useAsInstDefaullt() const +{ + return ui->instDefaultCheck->isChecked(); +} + +void ProfileSelectDialog::on_buttonBox_accepted() +{ + QModelIndexList selection = ui->view->selectionModel()->selectedIndexes(); + if (selection.size() > 0) { + QModelIndex selected = selection.first(); + m_selected = selected.data(AccountList::PointerRole).value(); + } + close(); +} + +void ProfileSelectDialog::on_buttonBox_rejected() +{ + close(); +} diff --git a/launcher/ui/dialogs/ProfileSelectDialog.h b/launcher/ui/dialogs/ProfileSelectDialog.h new file mode 100644 index 0000000..a44e82d --- /dev/null +++ b/launcher/ui/dialogs/ProfileSelectDialog.h @@ -0,0 +1,86 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +#include + +#include "minecraft/auth/AccountList.h" + +namespace Ui { +class ProfileSelectDialog; +} + +class ProfileSelectDialog : public QDialog { + Q_OBJECT + public: + enum Flags { + NoFlags = 0, + + /*! + * Shows a check box on the dialog that allows the user to specify that the account + * they've selected should be used as the global default for all instances. + */ + GlobalDefaultCheckbox, + + /*! + * Shows a check box on the dialog that allows the user to specify that the account + * they've selected should be used as the default for the instance they are currently launching. + * This is not currently implemented. + */ + InstanceDefaultCheckbox, + }; + + /*! + * Constructs a new account select dialog with the given parent and message. + * The message will be shown at the top of the dialog. It is an empty string by default. + */ + explicit ProfileSelectDialog(const QString& message = "", int flags = 0, QWidget* parent = 0); + ~ProfileSelectDialog(); + + /*! + * Gets a pointer to the account that the user selected. + * This is null if the user clicked cancel or hasn't clicked OK yet. + */ + MinecraftAccountPtr selectedAccount() const; + + /*! + * Returns true if the user checked the "use as global default" checkbox. + * If the checkbox wasn't shown, this function returns false. + */ + bool useAsGlobalDefault() const; + + /*! + * Returns true if the user checked the "use as instance default" checkbox. + * If the checkbox wasn't shown, this function returns false. + */ + bool useAsInstDefaullt() const; + + public slots: + void on_buttonBox_accepted(); + + void on_buttonBox_rejected(); + + protected: + AccountList* m_accounts; + + //! The account that was selected when the user clicked OK. + MinecraftAccountPtr m_selected; + + private: + Ui::ProfileSelectDialog* ui; +}; diff --git a/launcher/ui/dialogs/ProfileSelectDialog.ui b/launcher/ui/dialogs/ProfileSelectDialog.ui new file mode 100644 index 0000000..a72b3e2 --- /dev/null +++ b/launcher/ui/dialogs/ProfileSelectDialog.ui @@ -0,0 +1,56 @@ + + + ProfileSelectDialog + + + + 0 + 0 + 465 + 300 + + + + Select an Account + + + + + + Select a profile. + + + + + + + + + + + + Use as default? + + + + + + + Use as default for this instance only? + + + + + + + + + QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok + + + + + + + + diff --git a/launcher/ui/dialogs/ProfileSetupDialog.cpp b/launcher/ui/dialogs/ProfileSetupDialog.cpp new file mode 100644 index 0000000..291827b --- /dev/null +++ b/launcher/ui/dialogs/ProfileSetupDialog.cpp @@ -0,0 +1,286 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ProfileSetupDialog.h" +#include "net/RawHeaderProxy.h" +#include "ui_ProfileSetupDialog.h" + +#include +#include +#include +#include +#include + +#include "ui/dialogs/ProgressDialog.h" + +#include +#include "minecraft/auth/Parsers.h" +#include "net/Upload.h" + +ProfileSetupDialog::ProfileSetupDialog(MinecraftAccountPtr accountToSetup, QWidget* parent) + : QDialog(parent), m_accountToSetup(accountToSetup), ui(new Ui::ProfileSetupDialog) +{ + ui->setupUi(this); + ui->errorLabel->setVisible(false); + + goodIcon = QIcon::fromTheme("status-good"); + yellowIcon = QIcon::fromTheme("status-yellow"); + badIcon = QIcon::fromTheme("status-bad"); + + static const QRegularExpression s_permittedNames("[a-zA-Z0-9_]{3,16}"); + auto nameEdit = ui->nameEdit; + nameEdit->setValidator(new QRegularExpressionValidator(s_permittedNames)); + nameEdit->setClearButtonEnabled(true); + validityAction = nameEdit->addAction(yellowIcon, QLineEdit::LeadingPosition); + connect(nameEdit, &QLineEdit::textEdited, this, &ProfileSetupDialog::nameEdited); + + checkStartTimer.setSingleShot(true); + connect(&checkStartTimer, &QTimer::timeout, this, &ProfileSetupDialog::startCheck); + + setNameStatus(NameStatus::NotSet, QString()); + + ui->buttonBox->button(QDialogButtonBox::Cancel)->setText(tr("Cancel")); + ui->buttonBox->button(QDialogButtonBox::Ok)->setText(tr("OK")); +} + +ProfileSetupDialog::~ProfileSetupDialog() +{ + delete ui; +} + +void ProfileSetupDialog::on_buttonBox_accepted() +{ + setupProfile(currentCheck); +} + +void ProfileSetupDialog::on_buttonBox_rejected() +{ + reject(); +} + +void ProfileSetupDialog::setNameStatus(ProfileSetupDialog::NameStatus status, QString errorString = QString()) +{ + nameStatus = status; + auto okButton = ui->buttonBox->button(QDialogButtonBox::Ok); + switch (nameStatus) { + case NameStatus::Available: { + validityAction->setIcon(goodIcon); + okButton->setEnabled(true); + } break; + case NameStatus::NotSet: + case NameStatus::Pending: + validityAction->setIcon(yellowIcon); + okButton->setEnabled(false); + break; + case NameStatus::Exists: + case NameStatus::Error: + validityAction->setIcon(badIcon); + okButton->setEnabled(false); + break; + } + if (!errorString.isEmpty()) { + ui->errorLabel->setText(errorString); + ui->errorLabel->setVisible(true); + } else { + ui->errorLabel->setVisible(false); + } +} + +void ProfileSetupDialog::nameEdited(const QString& name) +{ + if (!ui->nameEdit->hasAcceptableInput()) { + setNameStatus(NameStatus::NotSet, tr("Name is too short - must be between 3 and 16 characters long.")); + return; + } + scheduleCheck(name); +} + +void ProfileSetupDialog::scheduleCheck(const QString& name) +{ + queuedCheck = name; + setNameStatus(NameStatus::Pending); + checkStartTimer.start(1000); +} + +void ProfileSetupDialog::startCheck() +{ + if (isChecking) { + return; + } + if (queuedCheck.isNull()) { + return; + } + checkName(queuedCheck); +} + +void ProfileSetupDialog::checkName(const QString& name) +{ + if (isChecking) { + return; + } + + currentCheck = name; + isChecking = true; + + QUrl url(QString("https://api.minecraftservices.com/minecraft/profile/name/%1/available").arg(name)); + auto headers = QList{ { "Content-Type", "application/json" }, + { "Accept", "application/json" }, + { "Authorization", QString("Bearer %1").arg(m_accountToSetup->accessToken()).toUtf8() } }; + + if (m_check_task) + disconnect(m_check_task.get(), nullptr, this, nullptr); + auto [task, response] = Net::Download::makeByteArray(url); + + m_check_task = task; + m_check_task->addHeaderProxy(std::make_unique(headers)); + + connect(m_check_task.get(), &Task::finished, this, [this, response] { checkFinished(response); }); + + m_check_task->setNetwork(APPLICATION->network()); + m_check_task->start(); +} + +void ProfileSetupDialog::checkFinished(QByteArray* response) +{ + if (m_check_task->error() == QNetworkReply::NoError) { + auto doc = QJsonDocument::fromJson(*response); + auto root = doc.object(); + auto statusValue = root.value("status").toString("INVALID"); + if (statusValue == "AVAILABLE") { + setNameStatus(NameStatus::Available); + } else if (statusValue == "DUPLICATE") { + setNameStatus(NameStatus::Exists, tr("Minecraft profile with name %1 already exists.").arg(currentCheck)); + } else if (statusValue == "NOT_ALLOWED") { + setNameStatus(NameStatus::Exists, tr("The name %1 is not allowed.").arg(currentCheck)); + } else { + setNameStatus(NameStatus::Error, tr("Unhandled profile name status: %1").arg(statusValue)); + } + } else { + setNameStatus(NameStatus::Error, tr("Failed to check name availability.")); + } + isChecking = false; +} + +void ProfileSetupDialog::setupProfile(const QString& profileName) +{ + if (isWorking) { + return; + } + + QString payloadTemplate("{\"profileName\":\"%1\"}"); + + QUrl url("https://api.minecraftservices.com/minecraft/profile"); + auto headers = QList{ { "Content-Type", "application/json" }, + { "Accept", "application/json" }, + { "Authorization", QString("Bearer %1").arg(m_accountToSetup->accessToken()).toUtf8() } }; + + auto [task, response] = Net::Upload::makeByteArray(url, payloadTemplate.arg(profileName).toUtf8()); + m_profile_task = task; + m_profile_task->addHeaderProxy(std::make_unique(headers)); + + connect(m_profile_task.get(), &Task::finished, this, [this, response] { setupProfileFinished(response); }); + + m_profile_task->setNetwork(APPLICATION->network()); + m_profile_task->start(); + + isWorking = true; + + auto button = ui->buttonBox->button(QDialogButtonBox::Cancel); + button->setEnabled(false); +} + +namespace { + +struct MojangError { + static MojangError fromJSON(QByteArray data) + { + MojangError out; + out.rawError = QString::fromUtf8(data); + auto doc = QJsonDocument::fromJson(data, &out.parseError); + + out.fullyParsed = false; + if (!out.parseError.error) { + auto object = doc.object(); + out.fullyParsed = true; + out.fullyParsed &= Parsers::getString(object.value("path"), out.path); + out.fullyParsed &= Parsers::getString(object.value("error"), out.error); + out.fullyParsed &= Parsers::getString(object.value("errorMessage"), out.errorMessage); + } + + return out; + } + + QString rawError; + QJsonParseError parseError; + bool fullyParsed; + + QString path; + QString error; + QString errorMessage; +}; + +} // namespace + +void ProfileSetupDialog::setupProfileFinished(QByteArray* response) +{ + isWorking = false; + if (m_profile_task->error() == QNetworkReply::NoError) { + /* + * data contains the profile in the response + * ... we could parse it and update the account, but let's just return back to the normal login flow instead... + */ + accept(); + } else { + auto parsedError = MojangError::fromJSON(*response); + ui->errorLabel->setVisible(true); + + QString errorMessage = + tr("Network Error: %1\nHTTP Status: %2").arg(m_profile_task->errorString(), QString::number(m_profile_task->replyStatusCode())); + + if (parsedError.fullyParsed) { + errorMessage += "Path: " + parsedError.path + "\n"; + errorMessage += "Error: " + parsedError.error + "\n"; + errorMessage += "Message: " + parsedError.errorMessage + "\n"; + } else { + errorMessage += "Failed to parse error from Mojang API: " + parsedError.parseError.errorString() + "\n"; + errorMessage += "Log:\n" + parsedError.rawError + "\n"; + } + + ui->errorLabel->setText(tr("The server responded with the following error:") + "\n\n" + errorMessage); + qDebug() << parsedError.rawError; + auto button = ui->buttonBox->button(QDialogButtonBox::Cancel); + button->setEnabled(true); + } +} diff --git a/launcher/ui/dialogs/ProfileSetupDialog.h b/launcher/ui/dialogs/ProfileSetupDialog.h new file mode 100644 index 0000000..1da9c11 --- /dev/null +++ b/launcher/ui/dialogs/ProfileSetupDialog.h @@ -0,0 +1,74 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include + +#include +#include "net/Download.h" +#include "net/Upload.h" + +namespace Ui { +class ProfileSetupDialog; +} + +class ProfileSetupDialog : public QDialog { + Q_OBJECT + public: + explicit ProfileSetupDialog(MinecraftAccountPtr accountToSetup, QWidget* parent = 0); + ~ProfileSetupDialog(); + + enum class NameStatus { NotSet, Pending, Available, Exists, Error } nameStatus = NameStatus::NotSet; + + private slots: + void on_buttonBox_accepted(); + void on_buttonBox_rejected(); + + void nameEdited(const QString& name); + void startCheck(); + + void checkFinished(QByteArray* response); + void setupProfileFinished(QByteArray* response); + + protected: + void scheduleCheck(const QString& name); + void checkName(const QString& name); + void setNameStatus(NameStatus status, QString errorString); + + void setupProfile(const QString& profileName); + + private: + MinecraftAccountPtr m_accountToSetup; + Ui::ProfileSetupDialog* ui; + QIcon goodIcon; + QIcon yellowIcon; + QIcon badIcon; + QAction* validityAction = nullptr; + + QString queuedCheck; + + bool isChecking = false; + bool isWorking = false; + QString currentCheck; + + QTimer checkStartTimer; + + Net::Download::Ptr m_check_task; + Net::Upload::Ptr m_profile_task; +}; diff --git a/launcher/ui/dialogs/ProfileSetupDialog.ui b/launcher/ui/dialogs/ProfileSetupDialog.ui new file mode 100644 index 0000000..947110d --- /dev/null +++ b/launcher/ui/dialogs/ProfileSetupDialog.ui @@ -0,0 +1,77 @@ + + + ProfileSetupDialog + + + + 0 + 0 + 615 + 208 + + + + Choose Minecraft name + + + + + + + 0 + 0 + + + + You just need to take one more step to be able to play Minecraft on this account. + +Choose your name carefully: + + + true + + + Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse|Qt::TextBrowserInteraction|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse + + + nameEdit + + + + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + true + + + Errors go here + + + true + + + true + + + Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse|Qt::TextBrowserInteraction|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse + + + + + + + nameEdit + + + + diff --git a/launcher/ui/dialogs/ProgressDialog.cpp b/launcher/ui/dialogs/ProgressDialog.cpp new file mode 100644 index 0000000..6aa0b4b --- /dev/null +++ b/launcher/ui/dialogs/ProgressDialog.cpp @@ -0,0 +1,291 @@ +/// SPDX-License-Identifier: GPL-3.0-only +/* + * PrismLaucher - Minecraft Launcher + * Copyright (C) 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ProgressDialog.h" +#include +#include "ui_ProgressDialog.h" + +#include +#include +#include + +#include "tasks/Task.h" + +#include "ui/widgets/SubTaskProgressBar.h" + +// map a value in a numeric range of an arbitrary type to between 0 and INT_MAX +// for getting the best precision out of the qt progress bar +template , bool> = true> +std::tuple map_int_zero_max(T current, T range_max, T range_min) +{ + int int_max = std::numeric_limits::max(); + + auto type_range = range_max - range_min; + double percentage = static_cast(current - range_min) / static_cast(type_range); + int mapped_current = percentage * int_max; + + return { mapped_current, int_max }; +} + +ProgressDialog::ProgressDialog(QWidget* parent) : QDialog(parent), ui(new Ui::ProgressDialog) +{ + ui->setupUi(this); + ui->taskProgressScrollArea->setHidden(true); + this->setWindowFlags(this->windowFlags() & ~Qt::WindowContextHelpButtonHint); + setAttribute(Qt::WidgetAttribute::WA_QuitOnClose, true); + changeProgress(0, 100); + updateSize(true); + setSkipButton(false); +} + +void ProgressDialog::setSkipButton(bool present, QString label) +{ + ui->skipButton->setAutoDefault(false); + ui->skipButton->setDefault(false); + ui->skipButton->setFocusPolicy(Qt::ClickFocus); + ui->skipButton->setEnabled(present); + ui->skipButton->setVisible(present); + ui->skipButton->setText(label); + updateSize(); +} + +void ProgressDialog::on_skipButton_clicked(bool checked) +{ + Q_UNUSED(checked); + if (ui->skipButton->isEnabled()) // prevent other triggers from aborting + m_task->abort(); +} + +ProgressDialog::~ProgressDialog() +{ + for (auto conn : this->m_taskConnections) { + disconnect(conn); + } + delete ui; +} + +void ProgressDialog::updateSize(bool recenterParent) +{ + QSize lastSize = this->size(); + QPoint lastPos = this->pos(); + int minHeight = ui->globalStatusDetailsLabel->minimumSize().height() + (ui->verticalLayout->spacing() * 2); + minHeight += ui->globalProgressBar->minimumSize().height() + ui->verticalLayout->spacing(); + if (!ui->taskProgressScrollArea->isHidden()) + minHeight += ui->taskProgressScrollArea->minimumSizeHint().height() + ui->verticalLayout->spacing(); + if (ui->skipButton->isVisible()) + minHeight += ui->skipButton->height() + ui->verticalLayout->spacing(); + minHeight = std::max(minHeight, 60); + QSize minSize = QSize(480, minHeight); + + setMinimumSize(minSize); + adjustSize(); + + QSize newSize = this->size(); + // if the current window is a different size + auto parent = this->parentWidget(); + if (recenterParent && parent) { + auto newX = std::max(0, parent->x() + ((parent->width() - newSize.width()) / 2)); + auto newY = std::max(0, parent->y() + ((parent->height() - newSize.height()) / 2)); + this->move(newX, newY); + } else if (lastSize != newSize) { + // center on old position after resize + QSize sizeDiff = lastSize - newSize; // last size was smaller, the results should be negative + auto newX = std::max(0, lastPos.x() + (sizeDiff.width() / 2)); + auto newY = std::max(0, lastPos.y() + (sizeDiff.height() / 2)); + this->move(newX, newY); + } +} + +int ProgressDialog::execWithTask(Task* task) +{ + this->m_task = task; + + if (!task) { + qDebug() << "Programmer error: Progress dialog created with null task."; + return QDialog::DialogCode::Accepted; + } + + QDialog::DialogCode result{}; + if (handleImmediateResult(result)) { + return result; + } + + // Connect signals. + this->m_taskConnections.push_back(connect(task, &Task::started, this, &ProgressDialog::onTaskStarted)); + this->m_taskConnections.push_back(connect(task, &Task::failed, this, &ProgressDialog::onTaskFailed)); + this->m_taskConnections.push_back(connect(task, &Task::succeeded, this, &ProgressDialog::onTaskSucceeded)); + this->m_taskConnections.push_back(connect(task, &Task::status, this, &ProgressDialog::changeStatus)); + this->m_taskConnections.push_back(connect(task, &Task::details, this, &ProgressDialog::changeStatus)); + this->m_taskConnections.push_back(connect(task, &Task::stepProgress, this, &ProgressDialog::changeStepProgress)); + this->m_taskConnections.push_back(connect(task, &Task::progress, this, &ProgressDialog::changeProgress)); + this->m_taskConnections.push_back(connect(task, &Task::aborted, this, &ProgressDialog::hide)); + this->m_taskConnections.push_back(connect(task, &Task::abortStatusChanged, ui->skipButton, &QPushButton::setEnabled)); + this->m_taskConnections.push_back(connect(task, &Task::abortButtonTextChanged, ui->skipButton, &QPushButton::setText)); + + m_is_multi_step = task->isMultiStep(); + ui->taskProgressScrollArea->setHidden(!m_is_multi_step); + updateSize(); + + // It's a good idea to start the task after we entered the dialog's event loop :^) + if (!task->isRunning()) { + QMetaObject::invokeMethod(task, &Task::start, Qt::QueuedConnection); + } else { + changeStatus(task->getStatus()); + changeProgress(task->getProgress(), task->getTotalProgress()); + } + + return QDialog::exec(); +} + +// TODO: only provide the unique_ptr overloads +int ProgressDialog::execWithTask(std::unique_ptr&& task) +{ + connect(this, &ProgressDialog::destroyed, task.get(), &Task::deleteLater); + return execWithTask(task.release()); +} +int ProgressDialog::execWithTask(std::unique_ptr& task) +{ + connect(this, &ProgressDialog::destroyed, task.get(), &Task::deleteLater); + return execWithTask(task.release()); +} + +bool ProgressDialog::handleImmediateResult(QDialog::DialogCode& result) +{ + if (m_task->isFinished()) { + if (m_task->wasSuccessful()) { + result = QDialog::Accepted; + } else { + result = QDialog::Rejected; + } + return true; + } + return false; +} + +Task* ProgressDialog::getTask() +{ + return m_task; +} + +void ProgressDialog::onTaskStarted() {} + +void ProgressDialog::onTaskFailed([[maybe_unused]] QString failure) +{ + reject(); + hide(); +} + +void ProgressDialog::onTaskSucceeded() +{ + accept(); + hide(); +} + +void ProgressDialog::changeStatus([[maybe_unused]] const QString& status) +{ + ui->globalStatusLabel->setText(m_task->getStatus()); + ui->globalStatusLabel->adjustSize(); + ui->globalStatusDetailsLabel->setText(m_task->getDetails()); + ui->globalStatusDetailsLabel->adjustSize(); + + updateSize(); +} + +void ProgressDialog::addTaskProgress(TaskStepProgress const& progress) +{ + SubTaskProgressBar* task_bar = new SubTaskProgressBar(this); + taskProgress.insert(progress.uid, task_bar); + ui->taskProgressLayout->addWidget(task_bar); +} + +void ProgressDialog::changeStepProgress(TaskStepProgress const& task_progress) +{ + m_is_multi_step = true; + if (ui->taskProgressScrollArea->isHidden()) { + ui->taskProgressScrollArea->setHidden(false); + updateSize(); + } + + if (!taskProgress.contains(task_progress.uid)) + addTaskProgress(task_progress); + auto task_bar = taskProgress.value(task_progress.uid); + + auto const [mapped_current, mapped_total] = map_int_zero_max(task_progress.current, task_progress.total, 0); + if (task_progress.total <= 0) { + task_bar->setRange(0, 0); + } else { + task_bar->setRange(0, mapped_total); + } + + task_bar->setValue(mapped_current); + task_bar->setStatus(task_progress.status); + task_bar->setDetails(task_progress.details); + + if (task_progress.isDone()) { + task_bar->setVisible(false); + } +} + +void ProgressDialog::changeProgress(qint64 current, qint64 total) +{ + ui->globalProgressBar->setMaximum(total); + ui->globalProgressBar->setValue(current); +} + +void ProgressDialog::keyPressEvent(QKeyEvent* e) +{ + if (ui->skipButton->isVisible()) { + if (e->key() == Qt::Key_Escape) { + on_skipButton_clicked(true); + return; + } else if (e->key() == Qt::Key_Tab) { + ui->skipButton->setFocusPolicy(Qt::StrongFocus); + ui->skipButton->setFocus(); + ui->skipButton->setAutoDefault(true); + ui->skipButton->setDefault(true); + return; + } + } + QDialog::keyPressEvent(e); +} + +void ProgressDialog::closeEvent(QCloseEvent* e) +{ + if (m_task && m_task->isRunning()) { + e->ignore(); + } else { + QDialog::closeEvent(e); + } +} diff --git a/launcher/ui/dialogs/ProgressDialog.h b/launcher/ui/dialogs/ProgressDialog.h new file mode 100644 index 0000000..50e4418 --- /dev/null +++ b/launcher/ui/dialogs/ProgressDialog.h @@ -0,0 +1,101 @@ +/// SPDX-License-Identifier: GPL-3.0-only +/* + * PrismLaucher - Minecraft Launcher + * Copyright (C) 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include + +#include "QObjectPtr.h" +#include "tasks/Task.h" + +#include "ui/widgets/SubTaskProgressBar.h" + +class Task; +class SequentialTask; + +namespace Ui { +class ProgressDialog; +} + +class ProgressDialog : public QDialog { + Q_OBJECT + + public: + explicit ProgressDialog(QWidget* parent = 0); + ~ProgressDialog(); + + void updateSize(bool recenterParent = false); + + int execWithTask(Task* task); + int execWithTask(std::unique_ptr&& task); + int execWithTask(std::unique_ptr& task); + + void setSkipButton(bool present, QString label = QString()); + + Task* getTask(); + + public slots: + void onTaskStarted(); + void onTaskFailed(QString failure); + void onTaskSucceeded(); + + void changeStatus(const QString& status); + void changeProgress(qint64 current, qint64 total); + void changeStepProgress(TaskStepProgress const& task_progress); + + private slots: + void on_skipButton_clicked(bool checked); + + protected: + virtual void keyPressEvent(QKeyEvent* e); + virtual void closeEvent(QCloseEvent* e); + + private: + bool handleImmediateResult(QDialog::DialogCode& result); + void addTaskProgress(TaskStepProgress const& progress); + + private: + Ui::ProgressDialog* ui; + + Task* m_task; + + QList m_taskConnections; + + bool m_is_multi_step = false; + QHash taskProgress; +}; diff --git a/launcher/ui/dialogs/ProgressDialog.ui b/launcher/ui/dialogs/ProgressDialog.ui new file mode 100644 index 0000000..156ff24 --- /dev/null +++ b/launcher/ui/dialogs/ProgressDialog.ui @@ -0,0 +1,144 @@ + + + ProgressDialog + + + + 0 + 0 + 480 + 210 + + + + + 1 + 1 + + + + + 480 + 210 + + + + Please wait... + + + true + + + + + + + + + 0 + 0 + + + + + 0 + 15 + + + + Global Task Status... + + + true + + + + + + + Global Status Details... + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + + true + + + + 0 + 24 + + + + 24 + + + + + + + + 0 + 0 + + + + + 0 + 100 + + + + QFrame::StyledPanel + + + Qt::ScrollBarAsNeeded + + + QAbstractScrollArea::AdjustToContents + + + true + + + + + 0 + 0 + 460 + 108 + + + + + 2 + + + + + + + + + + 0 + 0 + + + + Skip + + + + + + + + diff --git a/launcher/ui/dialogs/ResourceDownloadDialog.cpp b/launcher/ui/dialogs/ResourceDownloadDialog.cpp new file mode 100644 index 0000000..002f85e --- /dev/null +++ b/launcher/ui/dialogs/ResourceDownloadDialog.cpp @@ -0,0 +1,423 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "ResourceDownloadDialog.h" +#include + +#include +#include + +#include "Application.h" +#include "ResourceDownloadTask.h" + +#include "minecraft/PackProfile.h" +#include "minecraft/mod/ModFolderModel.h" +#include "minecraft/mod/ResourcePackFolderModel.h" +#include "minecraft/mod/ShaderPackFolderModel.h" +#include "minecraft/mod/TexturePackFolderModel.h" + +#include "minecraft/mod/tasks/GetModDependenciesTask.h" +#include "modplatform/ModIndex.h" +#include "ui/dialogs/CustomMessageBox.h" +#include "ui/dialogs/ProgressDialog.h" +#include "ui/dialogs/ReviewMessageBox.h" + +#include "ui/pages/modplatform/ResourcePage.h" + +#include "ui/pages/modplatform/flame/FlameResourcePages.h" +#include "ui/pages/modplatform/modrinth/ModrinthResourcePages.h" + +#include "modplatform/flame/FlameAPI.h" +#include "modplatform/modrinth/ModrinthAPI.h" +#include "ui/widgets/PageContainer.h" + +namespace ResourceDownload { + +ResourceDownloadDialog::ResourceDownloadDialog(QWidget* parent, ResourceFolderModel* base_model) + : QDialog(parent) + , m_base_model(base_model) + , m_buttons(QDialogButtonBox::Help | QDialogButtonBox::Ok | QDialogButtonBox::Cancel) + , m_vertical_layout(this) +{ + setObjectName(QStringLiteral("ResourceDownloadDialog")); + + resize(static_cast(std::max(0.5 * parent->width(), 400.0)), static_cast(std::max(0.75 * parent->height(), 400.0))); + + setWindowIcon(QIcon::fromTheme("new")); + + // small margins look ugly on macOS on modal windows + #ifndef Q_OS_MACOS + m_buttons.setContentsMargins(0, 0, 6, 6); + #endif + // Bonk Qt over its stupid head and make sure it understands which button is the default one... + // See: https://stackoverflow.com/questions/24556831/qbuttonbox-set-default-button + auto OkButton = m_buttons.button(QDialogButtonBox::Ok); + OkButton->setEnabled(false); + OkButton->setDefault(true); + OkButton->setAutoDefault(true); + OkButton->setText(tr("Review and confirm")); + OkButton->setShortcut(tr("Ctrl+Return")); + + auto CancelButton = m_buttons.button(QDialogButtonBox::Cancel); + CancelButton->setDefault(false); + CancelButton->setAutoDefault(false); + + auto HelpButton = m_buttons.button(QDialogButtonBox::Help); + HelpButton->setDefault(false); + HelpButton->setAutoDefault(false); + + setWindowModality(Qt::WindowModal); +} + +void ResourceDownloadDialog::accept() +{ + if (!geometrySaveKey().isEmpty()) + APPLICATION->settings()->set(geometrySaveKey(), QString::fromUtf8(saveGeometry().toBase64())); + + QDialog::accept(); +} + +void ResourceDownloadDialog::reject() +{ + auto selected = getTasks(); + if (selected.count() > 0) { + auto reply = CustomMessageBox::selectable(this, tr("Confirmation Needed"), + tr("You have %1 selected resources.\n" + "Are you sure you want to close this dialog?") + .arg(selected.count()), + QMessageBox::Question, QMessageBox::Yes | QMessageBox::No, QMessageBox::No) + ->exec(); + if (reply != QMessageBox::Yes) { + return; + } + } + + if (!geometrySaveKey().isEmpty()) + APPLICATION->settings()->set(geometrySaveKey(), QString::fromUtf8(saveGeometry().toBase64())); + + QDialog::reject(); +} + +// NOTE: We can't have this in the ctor because PageContainer calls a virtual function, and so +// won't work with subclasses if we put it in this ctor. +void ResourceDownloadDialog::initializeContainer() +{ + // small margins look ugly on macOS on modal windows + #ifndef Q_OS_MACOS + layout()->setContentsMargins(0, 0, 0, 0); + #endif + + m_container = new PageContainer(this, {}, this); + m_container->setSizePolicy(QSizePolicy::Policy::Preferred, QSizePolicy::Policy::Expanding); + m_container->layout()->setContentsMargins(0, 0, 0, 0); + m_vertical_layout.addWidget(m_container); + + m_container->addButtons(&m_buttons); + + connect(m_container, &PageContainer::selectedPageChanged, this, &ResourceDownloadDialog::selectedPageChanged); +} + +void ResourceDownloadDialog::connectButtons() +{ + auto OkButton = m_buttons.button(QDialogButtonBox::Ok); + OkButton->setToolTip( + tr("Opens a new popup to review your selected %1 and confirm your selection. Shortcut: Ctrl+Return").arg(resourcesString())); + connect(OkButton, &QPushButton::clicked, this, &ResourceDownloadDialog::confirm); + + auto CancelButton = m_buttons.button(QDialogButtonBox::Cancel); + connect(CancelButton, &QPushButton::clicked, this, &ResourceDownloadDialog::reject); + + auto HelpButton = m_buttons.button(QDialogButtonBox::Help); + connect(HelpButton, &QPushButton::clicked, m_container, &PageContainer::help); +} + +void ResourceDownloadDialog::confirm() +{ + auto confirm_dialog = ReviewMessageBox::create(this, tr("Confirm %1 to download").arg(resourcesString())); + confirm_dialog->retranslateUi(resourcesString()); + + QHash dependencyExtraInfo; + QStringList depNames; + if (auto task = getModDependenciesTask(); task) { + connect(task.get(), &Task::failed, this, + [this](QString reason) { CustomMessageBox::selectable(this, tr("Error"), reason, QMessageBox::Critical)->exec(); }); + + auto weak = task.toWeakRef(); + connect(task.get(), &Task::succeeded, this, [this, weak]() { + QStringList warnings; + if (auto task = weak.lock()) { + warnings = task->warnings(); + } + if (warnings.count()) { + CustomMessageBox::selectable(this, tr("Warnings"), warnings.join('\n'), QMessageBox::Warning)->exec(); + } + }); + + // Check for updates + ProgressDialog progress_dialog(this); + progress_dialog.setSkipButton(true, tr("Abort")); + progress_dialog.setWindowTitle(tr("Checking for dependencies...")); + auto ret = progress_dialog.execWithTask(task.get()); + + // If the dialog was skipped / some download error happened + if (ret == QDialog::DialogCode::Rejected) { + QMetaObject::invokeMethod(this, "reject", Qt::QueuedConnection); + return; + } else { + for (auto dep : task->getDependecies()) { + addResource(dep->pack, dep->version); + depNames << dep->pack->name; + } + dependencyExtraInfo = task->getExtraInfo(); + } + } + + auto selected = getTasks(); + std::sort(selected.begin(), selected.end(), [](const DownloadTaskPtr& a, const DownloadTaskPtr& b) { + return QString::compare(a->getName(), b->getName(), Qt::CaseInsensitive) < 0; + }); + for (auto& task : selected) { + auto extraInfo = dependencyExtraInfo.value(task->getPack()->addonId.toString()); + confirm_dialog->appendResource({ task->getName(), task->getFilename(), ModPlatform::ProviderCapabilities::name(task->getProvider()), + extraInfo.required_by, task->getVersion().version_type.toString(), !extraInfo.maybe_installed }); + } + + if (confirm_dialog->exec()) { + auto deselected = confirm_dialog->deselectedResources(); + for (auto page : m_container->getPages()) { + auto res = static_cast(page); + for (auto name : deselected) + res->removeResourceFromPage(name); + } + + this->accept(); + } else { + for (auto name : depNames) + removeResource(name); + } +} + +bool ResourceDownloadDialog::selectPage(QString pageId) +{ + return m_container->selectPage(pageId); +} + +ResourcePage* ResourceDownloadDialog::selectedPage() +{ + ResourcePage* result = dynamic_cast(m_container->selectedPage()); + Q_ASSERT(result != nullptr); + return result; +} + +void ResourceDownloadDialog::addResource(ModPlatform::IndexedPack::Ptr pack, ModPlatform::IndexedVersion& ver) +{ + removeResource(pack->name); + selectedPage()->addResourceToPage(pack, ver, getBaseModel()); + setButtonStatus(); +} + +void ResourceDownloadDialog::removeResource(const QString& pack_name) +{ + for (auto page : m_container->getPages()) { + static_cast(page)->removeResourceFromPage(pack_name); + } + setButtonStatus(); +} + +void ResourceDownloadDialog::setButtonStatus() +{ + auto selected = false; + for (auto page : m_container->getPages()) { + auto res = static_cast(page); + selected = selected || res->hasSelectedPacks(); + } + m_buttons.button(QDialogButtonBox::Ok)->setEnabled(selected); +} + +const QList ResourceDownloadDialog::getTasks() +{ + QList selected; + for (auto page : m_container->getPages()) { + auto res = static_cast(page); + selected.append(res->selectedPacks()); + } + return selected; +} + +void ResourceDownloadDialog::selectedPageChanged(BasePage* previous, BasePage* selected) +{ + auto* prev_page = dynamic_cast(previous); + if (!prev_page) { + qCritical() << "Page '" << previous->displayName() << "' in ResourceDownloadDialog is not a ResourcePage!"; + return; + } + + // Same effect as having a global search bar + ResourcePage* result = dynamic_cast(selected); + Q_ASSERT(result != nullptr); + result->setSearchTerm(prev_page->getSearchTerm()); +} + +ModDownloadDialog::ModDownloadDialog(QWidget* parent, ModFolderModel* mods, BaseInstance* instance) + : ResourceDownloadDialog(parent, mods), m_instance(instance) +{ + setWindowTitle(dialogTitle()); + + initializeContainer(); + connectButtons(); + + if (!geometrySaveKey().isEmpty()) + restoreGeometry(QByteArray::fromBase64(APPLICATION->settings()->get(geometrySaveKey()).toString().toUtf8())); +} + +QList ModDownloadDialog::getPages() +{ + QList pages; + + auto loaders = static_cast(m_instance)->getPackProfile()->getSupportedModLoaders().value(); + + if (ModrinthAPI::validateModLoaders(loaders)) + pages.append(ModrinthModPage::create(this, *m_instance)); + if (APPLICATION->capabilities() & Application::SupportsFlame && FlameAPI::validateModLoaders(loaders)) + pages.append(FlameModPage::create(this, *m_instance)); + + return pages; +} + +GetModDependenciesTask::Ptr ModDownloadDialog::getModDependenciesTask() +{ + if (!APPLICATION->settings()->get("ModDependenciesDisabled").toBool()) { // dependencies + if (auto model = dynamic_cast(getBaseModel()); model) { + QList> selectedVers; + for (auto& selected : getTasks()) { + selectedVers.append(std::make_shared(selected->getPack(), selected->getVersion())); + } + + return makeShared(m_instance, model, selectedVers); + } + } + return nullptr; +} + +ResourcePackDownloadDialog::ResourcePackDownloadDialog(QWidget* parent, ResourcePackFolderModel* resource_packs, BaseInstance* instance) + : ResourceDownloadDialog(parent, resource_packs), m_instance(instance) +{ + setWindowTitle(dialogTitle()); + + initializeContainer(); + connectButtons(); + + if (!geometrySaveKey().isEmpty()) + restoreGeometry(QByteArray::fromBase64(APPLICATION->settings()->get(geometrySaveKey()).toString().toUtf8())); +} + +QList ResourcePackDownloadDialog::getPages() +{ + QList pages; + + pages.append(ModrinthResourcePackPage::create(this, *m_instance)); + if (APPLICATION->capabilities() & Application::SupportsFlame) + pages.append(FlameResourcePackPage::create(this, *m_instance)); + + return pages; +} + +TexturePackDownloadDialog::TexturePackDownloadDialog(QWidget* parent, TexturePackFolderModel* resource_packs, BaseInstance* instance) + : ResourceDownloadDialog(parent, resource_packs), m_instance(instance) +{ + setWindowTitle(dialogTitle()); + + initializeContainer(); + connectButtons(); + + if (!geometrySaveKey().isEmpty()) + restoreGeometry(QByteArray::fromBase64(APPLICATION->settings()->get(geometrySaveKey()).toString().toUtf8())); +} + +QList TexturePackDownloadDialog::getPages() +{ + QList pages; + + pages.append(ModrinthTexturePackPage::create(this, *m_instance)); + if (APPLICATION->capabilities() & Application::SupportsFlame) + pages.append(FlameTexturePackPage::create(this, *m_instance)); + + return pages; +} + +ShaderPackDownloadDialog::ShaderPackDownloadDialog(QWidget* parent, ShaderPackFolderModel* shaders, BaseInstance* instance) + : ResourceDownloadDialog(parent, shaders), m_instance(instance) +{ + setWindowTitle(dialogTitle()); + + initializeContainer(); + connectButtons(); + + if (!geometrySaveKey().isEmpty()) + restoreGeometry(QByteArray::fromBase64(APPLICATION->settings()->get(geometrySaveKey()).toString().toUtf8())); +} + +QList ShaderPackDownloadDialog::getPages() +{ + QList pages; + pages.append(ModrinthShaderPackPage::create(this, *m_instance)); + if (APPLICATION->capabilities() & Application::SupportsFlame) + pages.append(FlameShaderPackPage::create(this, *m_instance)); + return pages; +} + +void ResourceDownloadDialog::setResourceMetadata(const std::shared_ptr& meta) +{ + switch (meta->provider) { + case ModPlatform::ResourceProvider::MODRINTH: + selectPage(Modrinth::id()); + break; + case ModPlatform::ResourceProvider::FLAME: + selectPage(Flame::id()); + break; + } + setWindowTitle(tr("Change %1 version").arg(meta->name)); + m_container->hidePageList(); + m_buttons.hide(); + auto page = selectedPage(); + page->openProject(meta->project_id); +} + +DataPackDownloadDialog::DataPackDownloadDialog(QWidget* parent, DataPackFolderModel* data_packs, BaseInstance* instance) + : ResourceDownloadDialog(parent, data_packs), m_instance(instance) +{ + setWindowTitle(dialogTitle()); + + initializeContainer(); + connectButtons(); + + if (!geometrySaveKey().isEmpty()) + restoreGeometry(QByteArray::fromBase64(APPLICATION->settings()->get(geometrySaveKey()).toByteArray())); +} + +QList DataPackDownloadDialog::getPages() +{ + QList pages; + pages.append(ModrinthDataPackPage::create(this, *m_instance)); + if (APPLICATION->capabilities() & Application::SupportsFlame) + pages.append(FlameDataPackPage::create(this, *m_instance)); + return pages; +} + +} // namespace ResourceDownload diff --git a/launcher/ui/dialogs/ResourceDownloadDialog.h b/launcher/ui/dialogs/ResourceDownloadDialog.h new file mode 100644 index 0000000..a85a85a --- /dev/null +++ b/launcher/ui/dialogs/ResourceDownloadDialog.h @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include +#include +#include + +#include "QObjectPtr.h" +#include "minecraft/mod/DataPackFolderModel.h" +#include "minecraft/mod/tasks/GetModDependenciesTask.h" +#include "modplatform/ModIndex.h" +#include "ui/pages/BasePageProvider.h" + +class BaseInstance; +class ModFolderModel; +class PageContainer; +class QVBoxLayout; +class QDialogButtonBox; +class ResourceDownloadTask; +class ResourceFolderModel; +class ResourcePackFolderModel; +class TexturePackFolderModel; +class ShaderPackFolderModel; + +namespace ResourceDownload { + +class ResourcePage; + +class ResourceDownloadDialog : public QDialog, public BasePageProvider { + Q_OBJECT + + public: + using DownloadTaskPtr = shared_qobject_ptr; + + ResourceDownloadDialog(QWidget* parent, ResourceFolderModel* base_model); + + void initializeContainer(); + void connectButtons(); + + //: String that gets appended to the download dialog title ("Download " + resourcesString()) + virtual QString resourcesString() const { return tr("resources"); } + + QString dialogTitle() override { return tr("Download %1").arg(resourcesString()); }; + + bool selectPage(QString pageId); + ResourcePage* selectedPage(); + + void addResource(ModPlatform::IndexedPack::Ptr, ModPlatform::IndexedVersion&); + void removeResource(const QString&); + + const QList getTasks(); + ResourceFolderModel* getBaseModel() const { return m_base_model; } + + void setResourceMetadata(const std::shared_ptr& meta); + + public slots: + void accept() override; + void reject() override; + + protected slots: + void selectedPageChanged(BasePage* previous, BasePage* selected); + + virtual void confirm(); + + protected: + virtual QString geometrySaveKey() const { return ""; } + void setButtonStatus(); + + virtual GetModDependenciesTask::Ptr getModDependenciesTask() { return nullptr; } + + protected: + ResourceFolderModel* m_base_model; + + PageContainer* m_container = nullptr; + + QDialogButtonBox m_buttons; + QVBoxLayout m_vertical_layout; +}; + +class ModDownloadDialog final : public ResourceDownloadDialog { + Q_OBJECT + + public: + explicit ModDownloadDialog(QWidget* parent, ModFolderModel* mods, BaseInstance* instance); + ~ModDownloadDialog() override = default; + + //: String that gets appended to the mod download dialog title ("Download " + resourcesString()) + QString resourcesString() const override { return tr("mods"); } + QString geometrySaveKey() const override { return "ModDownloadGeometry"; } + + QList getPages() override; + GetModDependenciesTask::Ptr getModDependenciesTask() override; + + private: + BaseInstance* m_instance; +}; + +class ResourcePackDownloadDialog final : public ResourceDownloadDialog { + Q_OBJECT + + public: + explicit ResourcePackDownloadDialog(QWidget* parent, ResourcePackFolderModel* resource_packs, BaseInstance* instance); + ~ResourcePackDownloadDialog() override = default; + + //: String that gets appended to the resource pack download dialog title ("Download " + resourcesString()) + QString resourcesString() const override { return tr("resource packs"); } + QString geometrySaveKey() const override { return "RPDownloadGeometry"; } + + QList getPages() override; + + private: + BaseInstance* m_instance; +}; + +class TexturePackDownloadDialog final : public ResourceDownloadDialog { + Q_OBJECT + + public: + explicit TexturePackDownloadDialog(QWidget* parent, TexturePackFolderModel* resource_packs, BaseInstance* instance); + ~TexturePackDownloadDialog() override = default; + + //: String that gets appended to the texture pack download dialog title ("Download " + resourcesString()) + QString resourcesString() const override { return tr("texture packs"); } + QString geometrySaveKey() const override { return "TPDownloadGeometry"; } + + QList getPages() override; + + private: + BaseInstance* m_instance; +}; + +class ShaderPackDownloadDialog final : public ResourceDownloadDialog { + Q_OBJECT + + public: + explicit ShaderPackDownloadDialog(QWidget* parent, ShaderPackFolderModel* shader_packs, BaseInstance* instance); + ~ShaderPackDownloadDialog() override = default; + + //: String that gets appended to the shader pack download dialog title ("Download " + resourcesString()) + QString resourcesString() const override { return tr("shader packs"); } + QString geometrySaveKey() const override { return "ShaderDownloadGeometry"; } + + QList getPages() override; + + private: + BaseInstance* m_instance; +}; + +class DataPackDownloadDialog final : public ResourceDownloadDialog { + Q_OBJECT + + public: + explicit DataPackDownloadDialog(QWidget* parent, DataPackFolderModel* data_packs, BaseInstance* instance); + ~DataPackDownloadDialog() override = default; + + //: String that gets appended to the data pack download dialog title ("Download " + resourcesString()) + QString resourcesString() const override { return tr("data packs"); } + QString geometrySaveKey() const override { return "DataPackDownloadGeometry"; } + + QList getPages() override; + + private: + BaseInstance* m_instance; +}; + +} // namespace ResourceDownload diff --git a/launcher/ui/dialogs/ResourceUpdateDialog.cpp b/launcher/ui/dialogs/ResourceUpdateDialog.cpp new file mode 100644 index 0000000..99b01c3 --- /dev/null +++ b/launcher/ui/dialogs/ResourceUpdateDialog.cpp @@ -0,0 +1,522 @@ +#include "ResourceUpdateDialog.h" +#include "Application.h" +#include "ChooseProviderDialog.h" +#include "CustomMessageBox.h" +#include "ProgressDialog.h" +#include "ScrollMessageBox.h" +#include "StringUtils.h" +#include "minecraft/mod/tasks/GetModDependenciesTask.h" +#include "modplatform/ModIndex.h" +#include "modplatform/flame/FlameAPI.h" +#include "tasks/SequentialTask.h" +#include "ui_ReviewMessageBox.h" + +#include "Markdown.h" + +#include "tasks/ConcurrentTask.h" + +#include "minecraft/MinecraftInstance.h" +#include "minecraft/PackProfile.h" + +#include "modplatform/EnsureMetadataTask.h" +#include "modplatform/flame/FlameCheckUpdate.h" +#include "modplatform/modrinth/ModrinthCheckUpdate.h" + +#include +#include +#include +#include + +#include + +static std::vector mcVersions(BaseInstance* inst) +{ + return { static_cast(inst)->getPackProfile()->getComponent("net.minecraft")->getVersion() }; +} + +ResourceUpdateDialog::ResourceUpdateDialog(QWidget* parent, + BaseInstance* instance, + ResourceFolderModel* resourceModel, + QList& searchFor, + bool includeDeps, + QList loadersList) + : ReviewMessageBox(parent, tr("Confirm resources to update"), "") + , m_parent(parent) + , m_resourceModel(resourceModel) + , m_candidates(searchFor) + , m_secondTryMetadata(new ConcurrentTask("Second Metadata Search", APPLICATION->settings()->get("NumberOfConcurrentTasks").toInt())) + , m_instance(instance) + , m_includeDeps(includeDeps) + , m_loadersList(std::move(loadersList)) +{ + ReviewMessageBox::setGeometry(0, 0, 800, 600); + + ui->explainLabel->setText(tr("You're about to update the following resources:")); + ui->onlyCheckedLabel->setText(tr("Only resources with a check will be updated!")); +} + +void ResourceUpdateDialog::checkCandidates() +{ + // Ensure mods have valid metadata + auto went_well = ensureMetadata(); + if (!went_well) { + m_aborted = true; + return; + } + + // Report failed metadata generation + if (!m_failedMetadata.empty()) { + QString text; + for (const auto& failed : m_failedMetadata) { + const auto& mod = std::get<0>(failed); + const auto& reason = std::get<1>(failed); + text += tr("Mod name: %1
    File name: %2
    Reason: %3

    ").arg(mod->name(), mod->fileinfo().fileName(), reason); + } + + ScrollMessageBox message_dialog(m_parent, tr("Metadata generation failed"), + tr("Could not generate metadata for the following resources:
    " + "Do you wish to proceed without those resources?"), + text); + message_dialog.setModal(true); + if (message_dialog.exec() == QDialog::Rejected) { + m_aborted = true; + QMetaObject::invokeMethod(this, "reject", Qt::QueuedConnection); + return; + } + } + + auto versions = mcVersions(m_instance); + + SequentialTask check_task(tr("Checking for updates")); + + if (!m_modrinthToUpdate.empty()) { + m_modrinthCheckTask.reset(new ModrinthCheckUpdate(m_modrinthToUpdate, versions, m_loadersList, m_resourceModel)); + connect(m_modrinthCheckTask.get(), &CheckUpdateTask::checkFailed, this, + [this](Resource* resource, QString reason, QUrl recover_url) { + m_failedCheckUpdate.append({ resource, reason, recover_url }); + }); + check_task.addTask(m_modrinthCheckTask); + } + + if (!m_flameToUpdate.empty()) { + m_flameCheckTask.reset(new FlameCheckUpdate(m_flameToUpdate, versions, m_loadersList, m_resourceModel)); + connect(m_flameCheckTask.get(), &CheckUpdateTask::checkFailed, this, [this](Resource* resource, QString reason, QUrl recover_url) { + m_failedCheckUpdate.append({ resource, reason, recover_url }); + }); + check_task.addTask(m_flameCheckTask); + } + + connect(&check_task, &Task::failed, this, + [this](QString reason) { CustomMessageBox::selectable(this, tr("Error"), reason, QMessageBox::Critical)->exec(); }); + + connect(&check_task, &Task::succeeded, this, [this, &check_task]() { + QStringList warnings = check_task.warnings(); + if (warnings.count()) { + CustomMessageBox::selectable(this, tr("Warnings"), warnings.join('\n'), QMessageBox::Warning)->exec(); + } + }); + + // Check for updates + ProgressDialog progress_dialog(m_parent); + progress_dialog.setSkipButton(true, tr("Abort")); + progress_dialog.setWindowTitle(tr("Checking for updates...")); + auto ret = progress_dialog.execWithTask(&check_task); + + // If the dialog was skipped / some download error happened + if (ret == QDialog::DialogCode::Rejected) { + m_aborted = true; + QMetaObject::invokeMethod(this, "reject", Qt::QueuedConnection); + return; + } + + QList> selectedVers; + + // Add found updates for Modrinth + if (m_modrinthCheckTask) { + auto modrinth_updates = m_modrinthCheckTask->getUpdates(); + for (auto& updatable : modrinth_updates) { + qDebug() << QString("Mod %1 has an update available!").arg(updatable.name); + + appendResource(updatable); + m_tasks.insert(updatable.name, updatable.download); + } + selectedVers.append(m_modrinthCheckTask->getDependencies()); + } + + // Add found updated for Flame + if (m_flameCheckTask) { + auto flame_updates = m_flameCheckTask->getUpdates(); + for (auto& updatable : flame_updates) { + qDebug() << QString("Mod %1 has an update available!").arg(updatable.name); + + appendResource(updatable); + m_tasks.insert(updatable.name, updatable.download); + } + selectedVers.append(m_flameCheckTask->getDependencies()); + } + + // Report failed update checking + if (!m_failedCheckUpdate.empty()) { + QString text; + for (const auto& failed : m_failedCheckUpdate) { + const auto& mod = std::get<0>(failed); + const auto& reason = std::get<1>(failed); + const auto& recover_url = std::get<2>(failed); + + qDebug() << mod->name() << "failed to check for updates!"; + + text += tr("Mod name: %1").arg(mod->name()) + "
    "; + if (!reason.isEmpty()) + text += tr("Reason: %1").arg(reason) + "
    "; + if (!recover_url.isEmpty()) + //: %1 is the link to download it manually + text += tr("Possible solution: Getting the latest version manually:
    %1
    ") + .arg(QString("%1").arg(recover_url.toString())); + text += "
    "; + } + + ScrollMessageBox message_dialog(m_parent, tr("Failed to check for updates"), + tr("Could not check or get the following resources for updates:
    " + "Do you wish to proceed without those resources?"), + text, "Disable unavailable mods"); + message_dialog.setModal(true); + if (message_dialog.exec() == QDialog::Rejected) { + m_aborted = true; + QMetaObject::invokeMethod(this, "reject", Qt::QueuedConnection); + return; + } + + // Disable unavailable mods + if (message_dialog.isOptionChecked()) { + for (const auto& failed : m_failedCheckUpdate) { + const auto& mod = std::get<0>(failed); + mod->enable(EnableAction::DISABLE); + } + } + } + + if (m_includeDeps && !APPLICATION->settings()->get("ModDependenciesDisabled").toBool()) { // dependencies + auto* mod_model = dynamic_cast(m_resourceModel); + + if (mod_model != nullptr) { + auto depTask = makeShared(m_instance, mod_model, selectedVers); + + connect(depTask.get(), &Task::failed, this, [this](const QString& reason) { + CustomMessageBox::selectable(this, tr("Error"), reason, QMessageBox::Critical)->exec(); + }); + auto weak = depTask.toWeakRef(); + connect(depTask.get(), &Task::succeeded, this, [this, weak]() { + QStringList warnings; + if (auto depTask = weak.lock()) { + warnings = depTask->warnings(); + } + if (warnings.count()) { + CustomMessageBox::selectable(this, tr("Warnings"), warnings.join('\n'), QMessageBox::Warning)->exec(); + } + }); + + ProgressDialog progress_dialog_deps(m_parent); + progress_dialog_deps.setSkipButton(true, tr("Abort")); + progress_dialog_deps.setWindowTitle(tr("Checking for dependencies...")); + auto dret = progress_dialog_deps.execWithTask(depTask.get()); + + // If the dialog was skipped / some download error happened + if (dret == QDialog::DialogCode::Rejected) { + m_aborted = true; + QMetaObject::invokeMethod(this, "reject", Qt::QueuedConnection); + return; + } + static FlameAPI api; + + auto dependencyExtraInfo = depTask->getExtraInfo(); + + for (const auto& dep : depTask->getDependecies()) { + auto changelog = dep->version.changelog; + if (dep->pack->provider == ModPlatform::ResourceProvider::FLAME) + changelog = api.getModFileChangelog(dep->version.addonId.toInt(), dep->version.fileId.toInt()); + auto download_task = makeShared(dep->pack, dep->version, m_resourceModel); + auto extraInfo = dependencyExtraInfo.value(dep->version.addonId.toString()); + CheckUpdateTask::Update updatable = { + dep->pack->name, dep->version.hash, tr("Not installed"), dep->version.version, dep->version.version_type, + changelog, dep->pack->provider, download_task, !extraInfo.maybe_installed + }; + + appendResource(updatable, extraInfo.required_by); + m_tasks.insert(updatable.name, updatable.download); + } + } + } + + // If there's no resource to be updated + if (ui->modTreeWidget->topLevelItemCount() == 0) { + m_noUpdates = true; + } else { + // FIXME: Find a more efficient way of doing this! + + // Sort major items in alphabetical order (also sorts the children unfortunately) + ui->modTreeWidget->sortItems(0, Qt::SortOrder::AscendingOrder); + + // Re-sort the children + auto* item = ui->modTreeWidget->topLevelItem(0); + for (int i = 1; item != nullptr; ++i) { + item->sortChildren(0, Qt::SortOrder::DescendingOrder); + item = ui->modTreeWidget->topLevelItem(i); + } + } + + if (m_aborted || m_noUpdates) + QMetaObject::invokeMethod(this, "reject", Qt::QueuedConnection); +} + +// Part 1: Ensure we have a valid metadata +auto ResourceUpdateDialog::ensureMetadata() -> bool +{ + auto index_dir = indexDir(); + + SequentialTask seq(tr("Looking for metadata")); + + // A better use of data structures here could remove the need for this QHash + QHash should_try_others; + QList modrinth_tmp; + QList flame_tmp; + + bool confirm_rest = false; + bool try_others_rest = false; + bool skip_rest = false; + ModPlatform::ResourceProvider provider_rest = ModPlatform::ResourceProvider::MODRINTH; + + // adds resource to list based on provider + auto addToTmp = [&modrinth_tmp, &flame_tmp](Resource* resource, ModPlatform::ResourceProvider p) { + switch (p) { + case ModPlatform::ResourceProvider::MODRINTH: + modrinth_tmp.push_back(resource); + break; + case ModPlatform::ResourceProvider::FLAME: + flame_tmp.push_back(resource); + break; + } + }; + + // ask the user on what provider to seach for the mod first + for (auto candidate : m_candidates) { + if (candidate->status() != ResourceStatus::NO_METADATA) { + onMetadataEnsured(candidate); + continue; + } + + if (skip_rest) + continue; + + if (candidate->type() == ResourceType::FOLDER) { + continue; + } + + if (confirm_rest) { + addToTmp(candidate, provider_rest); + should_try_others.insert(candidate->internal_id(), try_others_rest); + continue; + } + + ChooseProviderDialog chooser(this); + chooser.setDescription(tr("The resource '%1' does not have a metadata yet. We need to generate it in order to track relevant " + "information on how to update this mod. " + "To do this, please select a mod provider which we can use to check for updates for this mod.") + .arg(candidate->name())); + auto confirmed = chooser.exec() == QDialog::DialogCode::Accepted; + + auto response = chooser.getResponse(); + + if (response.skip_all) + skip_rest = true; + if (response.confirm_all) { + confirm_rest = true; + provider_rest = response.chosen; + try_others_rest = response.try_others; + } + + should_try_others.insert(candidate->internal_id(), response.try_others); + + if (confirmed) + addToTmp(candidate, response.chosen); + } + + // prepare task for the modrinth mods + if (!modrinth_tmp.empty()) { + auto modrinth_task = makeShared(modrinth_tmp, index_dir, ModPlatform::ResourceProvider::MODRINTH); + connect(modrinth_task.get(), &EnsureMetadataTask::metadataReady, [this](Resource* candidate) { onMetadataEnsured(candidate); }); + connect(modrinth_task.get(), &EnsureMetadataTask::metadataFailed, [this, &should_try_others](Resource* candidate) { + onMetadataFailed(candidate, should_try_others.find(candidate->internal_id()).value(), ModPlatform::ResourceProvider::MODRINTH); + }); + connect(modrinth_task.get(), &EnsureMetadataTask::failed, + [this](QString reason) { CustomMessageBox::selectable(this, tr("Error"), reason, QMessageBox::Critical)->exec(); }); + + if (modrinth_task->getHashingTask()) + seq.addTask(modrinth_task->getHashingTask()); + + seq.addTask(modrinth_task); + } + + // prepare task for the flame mods + if (!flame_tmp.empty()) { + auto flame_task = makeShared(flame_tmp, index_dir, ModPlatform::ResourceProvider::FLAME); + connect(flame_task.get(), &EnsureMetadataTask::metadataReady, [this](Resource* candidate) { onMetadataEnsured(candidate); }); + connect(flame_task.get(), &EnsureMetadataTask::metadataFailed, [this, &should_try_others](Resource* candidate) { + onMetadataFailed(candidate, should_try_others.find(candidate->internal_id()).value(), ModPlatform::ResourceProvider::FLAME); + }); + connect(flame_task.get(), &EnsureMetadataTask::failed, + [this](QString reason) { CustomMessageBox::selectable(this, tr("Error"), reason, QMessageBox::Critical)->exec(); }); + + if (flame_task->getHashingTask()) + seq.addTask(flame_task->getHashingTask()); + + seq.addTask(flame_task); + } + + seq.addTask(m_secondTryMetadata); + + // execute all the tasks + ProgressDialog checking_dialog(m_parent); + checking_dialog.setSkipButton(true, tr("Abort")); + checking_dialog.setWindowTitle(tr("Generating metadata...")); + auto ret_metadata = checking_dialog.execWithTask(&seq); + + return (ret_metadata != QDialog::DialogCode::Rejected); +} + +void ResourceUpdateDialog::onMetadataEnsured(Resource* resource) +{ + // When the mod is a folder, for instance + if (!resource->metadata()) + return; + + switch (resource->metadata()->provider) { + case ModPlatform::ResourceProvider::MODRINTH: + m_modrinthToUpdate.push_back(resource); + break; + case ModPlatform::ResourceProvider::FLAME: + m_flameToUpdate.push_back(resource); + break; + } +} + +ModPlatform::ResourceProvider next(ModPlatform::ResourceProvider p) +{ + switch (p) { + case ModPlatform::ResourceProvider::MODRINTH: + return ModPlatform::ResourceProvider::FLAME; + case ModPlatform::ResourceProvider::FLAME: + return ModPlatform::ResourceProvider::MODRINTH; + } + + return ModPlatform::ResourceProvider::FLAME; +} + +void ResourceUpdateDialog::onMetadataFailed(Resource* resource, bool try_others, ModPlatform::ResourceProvider first_choice) +{ + if (try_others) { + auto index_dir = indexDir(); + + auto task = makeShared(resource, index_dir, next(first_choice)); + connect(task.get(), &EnsureMetadataTask::metadataReady, [this](Resource* candidate) { onMetadataEnsured(candidate); }); + connect(task.get(), &EnsureMetadataTask::metadataFailed, [this](Resource* candidate) { onMetadataFailed(candidate, false); }); + connect(task.get(), &EnsureMetadataTask::failed, + [this](const QString& reason) { CustomMessageBox::selectable(this, tr("Error"), reason, QMessageBox::Critical)->exec(); }); + if (task->getHashingTask()) { + auto seq = makeShared(); + seq->addTask(task->getHashingTask()); + seq->addTask(task); + m_secondTryMetadata->addTask(seq); + } else { + m_secondTryMetadata->addTask(task); + } + } else { + QString reason{ tr("Couldn't find a valid version on the selected mod provider(s)") }; + + m_failedMetadata.append({ resource, reason }); + } +} + +void ResourceUpdateDialog::appendResource(CheckUpdateTask::Update const& info, QStringList requiredBy) +{ + auto item_top = new QTreeWidgetItem(ui->modTreeWidget); + item_top->setCheckState(0, info.enabled ? Qt::CheckState::Checked : Qt::CheckState::Unchecked); + if (!info.enabled) { + item_top->setToolTip(0, tr("Mod was disabled as it may be already installed.")); + } + item_top->setText(0, info.name); + item_top->setExpanded(true); + + auto provider_item = new QTreeWidgetItem(item_top); + QString provider_name = ModPlatform::ProviderCapabilities::readableName(info.provider); + provider_item->setText(0, tr("Provider: %1").arg(provider_name)); + provider_item->setData(0, Qt::UserRole, provider_name); + + auto old_version_item = new QTreeWidgetItem(item_top); + old_version_item->setText(0, tr("Old version: %1").arg(info.old_version)); + old_version_item->setData(0, Qt::UserRole, info.old_version); + + auto new_version_item = new QTreeWidgetItem(item_top); + new_version_item->setText(0, tr("New version: %1").arg(info.new_version)); + new_version_item->setData(0, Qt::UserRole, info.new_version); + + if (info.new_version_type.has_value()) { + auto new_version_type_item = new QTreeWidgetItem(item_top); + new_version_type_item->setText(0, tr("New Version Type: %1").arg(info.new_version_type.value().toString())); + new_version_type_item->setData(0, Qt::UserRole, info.new_version_type.value().toString()); + } + + if (!requiredBy.isEmpty()) { + auto requiredByItem = new QTreeWidgetItem(item_top); + if (requiredBy.length() == 1) { + requiredByItem->setText(0, tr("Required by: %1").arg(requiredBy.back())); + requiredByItem->setData(0, Qt::UserRole, requiredBy.back()); + } else { + requiredByItem->setText(0, tr("Required by:")); + for (auto req : requiredBy) { + auto reqItem = new QTreeWidgetItem(requiredByItem); + reqItem->setText(0, req); + } + } + + ui->toggleDepsButton->show(); + m_deps << item_top; + } + + auto changelog_item = new QTreeWidgetItem(item_top); + changelog_item->setText(0, tr("Changelog of the latest version")); + + auto changelog = new QTreeWidgetItem(changelog_item); + auto changelog_area = new QTextBrowser(); + + QString text = info.changelog; + changelog->setData(0, Qt::UserRole, text); + if (info.provider == ModPlatform::ResourceProvider::MODRINTH) { + text = markdownToHTML(info.changelog.toUtf8()); + } + + changelog_area->setHtml(StringUtils::htmlListPatch(text)); + changelog_area->setOpenExternalLinks(true); + changelog_area->setLineWrapMode(QTextBrowser::LineWrapMode::WidgetWidth); + changelog_area->setVerticalScrollBarPolicy(Qt::ScrollBarPolicy::ScrollBarAsNeeded); + + ui->modTreeWidget->setItemWidget(changelog, 0, changelog_area); + + ui->modTreeWidget->addTopLevelItem(item_top); +} + +auto ResourceUpdateDialog::getTasks() -> const QList +{ + QList list; + + auto* item = ui->modTreeWidget->topLevelItem(0); + + for (int i = 1; item != nullptr; ++i) { + if (item->checkState(0) == Qt::CheckState::Checked) { + list.push_back(m_tasks.find(item->text(0)).value()); + } + + item = ui->modTreeWidget->topLevelItem(i); + } + + return list; +} diff --git a/launcher/ui/dialogs/ResourceUpdateDialog.h b/launcher/ui/dialogs/ResourceUpdateDialog.h new file mode 100644 index 0000000..ea81aeb --- /dev/null +++ b/launcher/ui/dialogs/ResourceUpdateDialog.h @@ -0,0 +1,68 @@ +#pragma once + +#include "BaseInstance.h" +#include "ResourceDownloadTask.h" +#include "ReviewMessageBox.h" + +#include "minecraft/mod/ModFolderModel.h" + +#include "modplatform/CheckUpdateTask.h" + +class Mod; +class ModrinthCheckUpdate; +class FlameCheckUpdate; +class ConcurrentTask; + +class ResourceUpdateDialog final : public ReviewMessageBox { + Q_OBJECT + public: + explicit ResourceUpdateDialog(QWidget* parent, + BaseInstance* instance, + ResourceFolderModel* resourceModel, + QList& searchFor, + bool includeDeps, + QList loadersList = {}); + + void checkCandidates(); + + void appendResource(const CheckUpdateTask::Update& info, QStringList requiredBy = {}); + + const QList getTasks(); + auto indexDir() const -> QDir { return m_resourceModel->indexDir(); } + + auto noUpdates() const -> bool { return m_noUpdates; }; + auto aborted() const -> bool { return m_aborted; }; + + private: + auto ensureMetadata() -> bool; + + private slots: + void onMetadataEnsured(Resource* resource); + void onMetadataFailed(Resource* resource, + bool try_others = false, + ModPlatform::ResourceProvider firstChoice = ModPlatform::ResourceProvider::MODRINTH); + + private: + QWidget* m_parent; + + shared_qobject_ptr m_modrinthCheckTask; + shared_qobject_ptr m_flameCheckTask; + + ResourceFolderModel* m_resourceModel; + + QList& m_candidates; + QList m_modrinthToUpdate; + QList m_flameToUpdate; + + ConcurrentTask::Ptr m_secondTryMetadata; + QList> m_failedMetadata; + QList> m_failedCheckUpdate; + + QHash m_tasks; + BaseInstance* m_instance; + + bool m_noUpdates = false; + bool m_aborted = false; + bool m_includeDeps = false; + QList m_loadersList; +}; diff --git a/launcher/ui/dialogs/ReviewMessageBox.cpp b/launcher/ui/dialogs/ReviewMessageBox.cpp new file mode 100644 index 0000000..d955697 --- /dev/null +++ b/launcher/ui/dialogs/ReviewMessageBox.cpp @@ -0,0 +1,129 @@ +#include "ReviewMessageBox.h" +#include "ui_ReviewMessageBox.h" + +#include +#include +#include + +ReviewMessageBox::ReviewMessageBox(QWidget* parent, [[maybe_unused]] QString const& title, [[maybe_unused]] QString const& icon) + : QDialog(parent), ui(new Ui::ReviewMessageBox) +{ + ui->setupUi(this); + + auto back_button = ui->buttonBox->button(QDialogButtonBox::Cancel); + back_button->setText(tr("Back")); + + ui->toggleDepsButton->hide(); + ui->modTreeWidget->header()->setSectionResizeMode(0, QHeaderView::Stretch); + ui->modTreeWidget->header()->setStretchLastSection(false); + ui->modTreeWidget->header()->setSectionResizeMode(1, QHeaderView::ResizeToContents); + + connect(ui->buttonBox, &QDialogButtonBox::accepted, this, &ReviewMessageBox::accept); + connect(ui->buttonBox, &QDialogButtonBox::rejected, this, &ReviewMessageBox::reject); + + ui->buttonBox->button(QDialogButtonBox::Cancel)->setText(tr("Cancel")); + ui->buttonBox->button(QDialogButtonBox::Ok)->setText(tr("OK")); + + // Overwrite Ctrl+C functionality to exclude the label when copying text from tree + auto shortcut = new QShortcut(QKeySequence::Copy, ui->modTreeWidget); + connect(shortcut, &QShortcut::activated, [this]() { + auto currentItem = this->ui->modTreeWidget->currentItem(); + if (!currentItem) + return; + auto currentColumn = this->ui->modTreeWidget->currentColumn(); + + auto data = currentItem->data(currentColumn, Qt::UserRole); + QString txt; + + if (data.isValid()) { + txt = data.toString(); + } else { + txt = currentItem->text(currentColumn); + } + + QApplication::clipboard()->setText(txt); + }); +} + +ReviewMessageBox::~ReviewMessageBox() +{ + delete ui; +} + +auto ReviewMessageBox::create(QWidget* parent, QString&& title, QString&& icon) -> ReviewMessageBox* +{ + return new ReviewMessageBox(parent, title, icon); +} + +void ReviewMessageBox::appendResource(ResourceInformation&& info) +{ + auto itemTop = new QTreeWidgetItem(ui->modTreeWidget); + itemTop->setCheckState(0, info.enabled ? Qt::CheckState::Checked : Qt::CheckState::Unchecked); + itemTop->setText(0, info.name); + if (!info.enabled) { + itemTop->setToolTip(0, tr("Mod was disabled as it may be already installed.")); + } + + auto filenameItem = new QTreeWidgetItem(itemTop); + filenameItem->setText(0, tr("Filename: %1").arg(info.filename)); + filenameItem->setData(0, Qt::UserRole, info.filename); + + auto providerItem = new QTreeWidgetItem(itemTop); + providerItem->setText(0, tr("Provider: %1").arg(info.provider)); + providerItem->setData(0, Qt::UserRole, info.provider); + + if (!info.required_by.isEmpty()) { + auto requiredByItem = new QTreeWidgetItem(itemTop); + if (info.required_by.length() == 1) { + requiredByItem->setText(0, tr("Required by: %1").arg(info.required_by.back())); + requiredByItem->setData(0, Qt::UserRole, info.required_by.back()); + } else { + requiredByItem->setText(0, tr("Required by:")); + for (auto req : info.required_by) { + auto reqItem = new QTreeWidgetItem(requiredByItem); + reqItem->setText(0, req); + } + } + + ui->toggleDepsButton->show(); + m_deps << itemTop; + } + + auto versionTypeItem = new QTreeWidgetItem(itemTop); + versionTypeItem->setText(0, tr("Version Type: %1").arg(info.version_type)); + versionTypeItem->setData(0, Qt::UserRole, info.version_type); + + ui->modTreeWidget->addTopLevelItem(itemTop); +} + +auto ReviewMessageBox::deselectedResources() -> QStringList +{ + QStringList list; + + auto* item = ui->modTreeWidget->topLevelItem(0); + + for (int i = 1; item != nullptr; ++i) { + if (item->checkState(0) == Qt::CheckState::Unchecked) { + list.append(item->text(0)); + } + + item = ui->modTreeWidget->topLevelItem(i); + } + + return list; +} + +void ReviewMessageBox::retranslateUi(QString resources_name) +{ + setWindowTitle(tr("Confirm %1 selection").arg(resources_name)); + + ui->explainLabel->setText(tr("You're about to download the following %1:").arg(resources_name)); + ui->onlyCheckedLabel->setText(tr("Only %1 with a check will be downloaded!").arg(resources_name)); +} +void ReviewMessageBox::on_toggleDepsButton_clicked() +{ + m_deps_checked = !m_deps_checked; + auto state = m_deps_checked ? Qt::Checked : Qt::Unchecked; + for (auto dep : m_deps) + dep->setCheckState(0, state); +}; diff --git a/launcher/ui/dialogs/ReviewMessageBox.h b/launcher/ui/dialogs/ReviewMessageBox.h new file mode 100644 index 0000000..ebc4979 --- /dev/null +++ b/launcher/ui/dialogs/ReviewMessageBox.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include + +namespace Ui { +class ReviewMessageBox; +} + +class ReviewMessageBox : public QDialog { + Q_OBJECT + + public: + static auto create(QWidget* parent, QString&& title, QString&& icon = "") -> ReviewMessageBox*; + + using ResourceInformation = struct res_info { + QString name; + QString filename; + QString provider; + QStringList required_by; + QString version_type; + bool enabled = true; + }; + + void appendResource(ResourceInformation&& info); + auto deselectedResources() -> QStringList; + + void retranslateUi(QString resources_name); + + ~ReviewMessageBox() override; + + protected slots: + void on_toggleDepsButton_clicked(); + + protected: + ReviewMessageBox(QWidget* parent, const QString& title, const QString& icon); + + Ui::ReviewMessageBox* ui; + + QList m_deps; + bool m_deps_checked = true; +}; diff --git a/launcher/ui/dialogs/ReviewMessageBox.ui b/launcher/ui/dialogs/ReviewMessageBox.ui new file mode 100644 index 0000000..dbe3510 --- /dev/null +++ b/launcher/ui/dialogs/ReviewMessageBox.ui @@ -0,0 +1,74 @@ + + + ReviewMessageBox + + + + 0 + 0 + 500 + 350 + + + + true + + + true + + + + + + true + + + QAbstractItemView::NoSelection + + + QAbstractItemView::SelectItems + + + false + + + + + + + + + + + + + + + + + + + + + + Toggle Dependencies + + + + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + + diff --git a/launcher/ui/dialogs/ScrollMessageBox.cpp b/launcher/ui/dialogs/ScrollMessageBox.cpp new file mode 100644 index 0000000..361b610 --- /dev/null +++ b/launcher/ui/dialogs/ScrollMessageBox.cpp @@ -0,0 +1,30 @@ +#include "ScrollMessageBox.h" +#include +#include "ui_ScrollMessageBox.h" + +ScrollMessageBox::ScrollMessageBox(QWidget* parent, const QString& title, const QString& text, const QString& body, const QString& option) + : QDialog(parent), ui(new Ui::ScrollMessageBox) +{ + ui->setupUi(this); + this->setWindowTitle(title); + ui->label->setText(text); + ui->textBrowser->setText(body); + + if (!option.isEmpty()) { + ui->optionCheckBox->setVisible(true); + ui->optionCheckBox->setText(option); + } + + ui->buttonBox->button(QDialogButtonBox::Cancel)->setText(tr("Cancel")); + ui->buttonBox->button(QDialogButtonBox::Ok)->setText(tr("OK")); +} + +ScrollMessageBox::~ScrollMessageBox() +{ + delete ui; +} + +bool ScrollMessageBox::isOptionChecked() const +{ + return ui->optionCheckBox->isChecked(); +} diff --git a/launcher/ui/dialogs/ScrollMessageBox.h b/launcher/ui/dialogs/ScrollMessageBox.h new file mode 100644 index 0000000..f91c902 --- /dev/null +++ b/launcher/ui/dialogs/ScrollMessageBox.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +QT_BEGIN_NAMESPACE +namespace Ui { +class ScrollMessageBox; +} +QT_END_NAMESPACE + +class ScrollMessageBox : public QDialog { + Q_OBJECT + + public: + ScrollMessageBox(QWidget* parent, const QString& title, const QString& text, const QString& body, const QString& option = {}); + + ~ScrollMessageBox() override; + + bool isOptionChecked() const; + + private: + Ui::ScrollMessageBox* ui; +}; diff --git a/launcher/ui/dialogs/ScrollMessageBox.ui b/launcher/ui/dialogs/ScrollMessageBox.ui new file mode 100644 index 0000000..2ebe860 --- /dev/null +++ b/launcher/ui/dialogs/ScrollMessageBox.ui @@ -0,0 +1,95 @@ + + + ScrollMessageBox + + + + 0 + 0 + 500 + 455 + + + + ScrollMessageBox + + + + + + + + + Qt::RichText + + + + + + + + + false + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + true + + + true + + + + + + + + + buttonBox + accepted() + ScrollMessageBox + accept() + + + 199 + 425 + + + 199 + 227 + + + + + buttonBox + rejected() + ScrollMessageBox + reject() + + + 199 + 425 + + + 199 + 227 + + + + + \ No newline at end of file diff --git a/launcher/ui/dialogs/UpdateAvailableDialog.cpp b/launcher/ui/dialogs/UpdateAvailableDialog.cpp new file mode 100644 index 0000000..f288fe7 --- /dev/null +++ b/launcher/ui/dialogs/UpdateAvailableDialog.cpp @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> +// +// SPDX-License-Identifier: GPL-3.0-only + +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "UpdateAvailableDialog.h" +#include +#include "BuildConfig.h" +#include "Markdown.h" +#include "StringUtils.h" +#include "ui_UpdateAvailableDialog.h" + +UpdateAvailableDialog::UpdateAvailableDialog(const QString& currentVersion, + const QString& availableVersion, + const QString& releaseNotes, + QWidget* parent) + : QDialog(parent), ui(new Ui::UpdateAvailableDialog) +{ + ui->setupUi(this); + + QString launcherName = BuildConfig.LAUNCHER_DISPLAYNAME; + + ui->headerLabel->setText(tr("A new version of %1 is available!").arg(launcherName)); + ui->versionAvailableLabel->setText( + tr("Version %1 is now available - you have %2 . Would you like to download it now?").arg(availableVersion).arg(currentVersion)); + ui->icon->setPixmap(QIcon::fromTheme("checkupdate").pixmap(64)); + + auto releaseNotesHtml = markdownToHTML(releaseNotes); + ui->releaseNotes->setHtml(StringUtils::htmlListPatch(releaseNotesHtml)); + ui->releaseNotes->setOpenExternalLinks(true); + + connect(ui->skipButton, &QPushButton::clicked, this, [this]() { + setResult(ResultCode::Skip); + done(ResultCode::Skip); + }); + + connect(ui->delayButton, &QPushButton::clicked, this, [this]() { + setResult(ResultCode::DontInstall); + done(ResultCode::DontInstall); + }); + + connect(ui->installButton, &QPushButton::clicked, this, [this]() { + setResult(ResultCode::Install); + done(ResultCode::Install); + }); +} diff --git a/launcher/ui/dialogs/UpdateAvailableDialog.h b/launcher/ui/dialogs/UpdateAvailableDialog.h new file mode 100644 index 0000000..6af9ace --- /dev/null +++ b/launcher/ui/dialogs/UpdateAvailableDialog.h @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> +// +// SPDX-License-Identifier: GPL-3.0-only + +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +#pragma once + +#include + +namespace Ui { +class UpdateAvailableDialog; +} + +class UpdateAvailableDialog : public QDialog { + Q_OBJECT + + public: + enum ResultCode { + Install = 10, + DontInstall = 11, + Skip = 12, + }; + + explicit UpdateAvailableDialog(const QString& currentVersion, + const QString& availableVersion, + const QString& releaseNotes, + QWidget* parent = 0); + ~UpdateAvailableDialog() = default; + + private: + Ui::UpdateAvailableDialog* ui; +}; diff --git a/launcher/ui/dialogs/UpdateAvailableDialog.ui b/launcher/ui/dialogs/UpdateAvailableDialog.ui new file mode 100644 index 0000000..b0d85f6 --- /dev/null +++ b/launcher/ui/dialogs/UpdateAvailableDialog.ui @@ -0,0 +1,155 @@ + + + UpdateAvailableDialog + + + + 0 + 0 + 636 + 352 + + + + Update Available + + + + + + + + + + + 64 + 64 + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + 9 + + + 9 + + + 9 + + + 9 + + + + + + 11 + 75 + true + + + + A new version is available! + + + + + + + Version %1 is now available - you have %2 . Would you like to download it now? + + + + + + + + 75 + true + + + + Release Notes: + + + + + + + + + + + + + + + + Skip This Version + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Remind Me Later + + + false + + + false + + + + + + + Install Update + + + true + + + + + + + + + + diff --git a/launcher/ui/dialogs/VersionSelectDialog.cpp b/launcher/ui/dialogs/VersionSelectDialog.cpp new file mode 100644 index 0000000..3037728 --- /dev/null +++ b/launcher/ui/dialogs/VersionSelectDialog.cpp @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "VersionSelectDialog.h" + +#include +#include +#include +#include +#include +#include + +#include "ui/widgets/VersionSelectWidget.h" + +#include "BaseVersion.h" +#include "BaseVersionList.h" + +VersionSelectDialog::VersionSelectDialog(BaseVersionList* vlist, QString title, QWidget* parent, bool cancelable) : QDialog(parent) +{ + setObjectName(QStringLiteral("VersionSelectDialog")); + resize(400, 347); + m_verticalLayout = new QVBoxLayout(this); + m_verticalLayout->setObjectName(QStringLiteral("verticalLayout")); + + m_versionWidget = new VersionSelectWidget(parent); + m_verticalLayout->addWidget(m_versionWidget); + + m_horizontalLayout = new QHBoxLayout(); + m_horizontalLayout->setObjectName(QStringLiteral("horizontalLayout")); + + m_refreshButton = new QPushButton(this); + m_refreshButton->setObjectName(QStringLiteral("refreshButton")); + m_horizontalLayout->addWidget(m_refreshButton); + + m_buttonBox = new QDialogButtonBox(this); + m_buttonBox->setObjectName(QStringLiteral("buttonBox")); + m_buttonBox->setOrientation(Qt::Horizontal); + m_buttonBox->setStandardButtons(QDialogButtonBox::Cancel | QDialogButtonBox::Ok); + + m_buttonBox->button(QDialogButtonBox::Ok)->setText(tr("Ok")); + m_buttonBox->button(QDialogButtonBox::Cancel)->setText(tr("Cancel")); + m_horizontalLayout->addWidget(m_buttonBox); + + m_verticalLayout->addLayout(m_horizontalLayout); + + retranslate(); + + connect(m_buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(m_versionWidget->view(), &QAbstractItemView::doubleClicked, this, &QDialog::accept); + connect(m_buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); + + QMetaObject::connectSlotsByName(this); + setWindowModality(Qt::WindowModal); + setWindowTitle(title); + + m_vlist = vlist; + + if (!cancelable) { + m_buttonBox->button(QDialogButtonBox::Cancel)->setEnabled(false); + } +} + +void VersionSelectDialog::retranslate() +{ + // FIXME: overrides custom title given in constructor! + setWindowTitle(tr("Choose Version")); + m_refreshButton->setToolTip(tr("Reloads the version list.")); + m_refreshButton->setText(tr("&Refresh")); +} + +void VersionSelectDialog::setCurrentVersion(const QString& version) +{ + m_currentVersion = version; + m_versionWidget->setCurrentVersion(version); +} + +void VersionSelectDialog::setEmptyString(QString emptyString) +{ + m_versionWidget->setEmptyString(emptyString); +} + +void VersionSelectDialog::setEmptyErrorString(QString emptyErrorString) +{ + m_versionWidget->setEmptyErrorString(emptyErrorString); +} + +void VersionSelectDialog::setResizeOn(int column) +{ + resizeOnColumn = column; +} + +int VersionSelectDialog::exec() +{ + QDialog::open(); + m_versionWidget->initialize(m_vlist, true); + m_versionWidget->selectSearch(); + if (resizeOnColumn != -1) { + m_versionWidget->setResizeOn(resizeOnColumn); + } + return QDialog::exec(); +} + +void VersionSelectDialog::selectRecommended() +{ + m_versionWidget->selectRecommended(); +} + +BaseVersion::Ptr VersionSelectDialog::selectedVersion() const +{ + return m_versionWidget->selectedVersion(); +} + +void VersionSelectDialog::on_refreshButton_clicked() +{ + m_versionWidget->loadList(); +} + +void VersionSelectDialog::setExactFilter(BaseVersionList::ModelRoles role, QString filter) +{ + m_versionWidget->setExactFilter(role, filter); +} + +void VersionSelectDialog::setExactIfPresentFilter(BaseVersionList::ModelRoles role, QString filter) +{ + m_versionWidget->setExactIfPresentFilter(role, filter); +} + +void VersionSelectDialog::setFuzzyFilter(BaseVersionList::ModelRoles role, QString filter) +{ + m_versionWidget->setFuzzyFilter(role, filter); +} diff --git a/launcher/ui/dialogs/VersionSelectDialog.h b/launcher/ui/dialogs/VersionSelectDialog.h new file mode 100644 index 0000000..ed1de60 --- /dev/null +++ b/launcher/ui/dialogs/VersionSelectDialog.h @@ -0,0 +1,72 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +#include "BaseVersionList.h" + +class QVBoxLayout; +class QHBoxLayout; +class QDialogButtonBox; +class VersionSelectWidget; +class QPushButton; + +class VersionProxyModel; + +class VersionSelectDialog : public QDialog { + Q_OBJECT + + public: + explicit VersionSelectDialog(BaseVersionList* vlist, QString title, QWidget* parent = 0, bool cancelable = true); + virtual ~VersionSelectDialog() = default; + + int exec() override; + + BaseVersion::Ptr selectedVersion() const; + + void setCurrentVersion(const QString& version); + void setFuzzyFilter(BaseVersionList::ModelRoles role, QString filter); + void setExactFilter(BaseVersionList::ModelRoles role, QString filter); + void setExactIfPresentFilter(BaseVersionList::ModelRoles role, QString filter); + void setEmptyString(QString emptyString); + void setEmptyErrorString(QString emptyErrorString); + void setResizeOn(int column); + + private slots: + void on_refreshButton_clicked(); + + private: + void retranslate(); + void selectRecommended(); + + private: + QString m_currentVersion; + VersionSelectWidget* m_versionWidget = nullptr; + QVBoxLayout* m_verticalLayout = nullptr; + QHBoxLayout* m_horizontalLayout = nullptr; + QPushButton* m_refreshButton = nullptr; + QDialogButtonBox* m_buttonBox = nullptr; + + BaseVersionList* m_vlist = nullptr; + + VersionProxyModel* m_proxyModel = nullptr; + + int resizeOnColumn = -1; + + Task* loadTask = nullptr; +}; diff --git a/launcher/ui/dialogs/skins/SkinManageDialog.cpp b/launcher/ui/dialogs/skins/SkinManageDialog.cpp new file mode 100644 index 0000000..4dfeeaa --- /dev/null +++ b/launcher/ui/dialogs/skins/SkinManageDialog.cpp @@ -0,0 +1,594 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023-2024 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "SkinManageDialog.h" +#include "ui/dialogs/skins/draw/SkinOpenGLWindow.h" +#include "ui_SkinManageDialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Application.h" +#include "settings/SettingsObject.h" +#include "DesktopServices.h" +#include "Json.h" +#include "QObjectPtr.h" + +#include "minecraft/auth/Parsers.h" +#include "minecraft/skins/CapeChange.h" +#include "minecraft/skins/SkinDelete.h" +#include "minecraft/skins/SkinList.h" +#include "minecraft/skins/SkinModel.h" +#include "minecraft/skins/SkinUpload.h" + +#include "net/Download.h" +#include "net/NetJob.h" +#include "tasks/Task.h" + +#include "ui/dialogs/CustomMessageBox.h" +#include "ui/dialogs/ProgressDialog.h" +#include "ui/instanceview/InstanceDelegate.h" + +SkinManageDialog::SkinManageDialog(QWidget* parent, MinecraftAccountPtr acct) + : QDialog(parent), m_acct(acct), m_ui(new Ui::SkinManageDialog), m_list(this, APPLICATION->settings()->get("SkinsDir").toString(), acct) +{ + m_ui->setupUi(this); + + if (SkinOpenGLWindow::hasOpenGL()) { + m_skinPreview = new SkinOpenGLWindow(this, palette().color(QPalette::Normal, QPalette::Base)); + } else { + m_skinPreviewLabel = new QLabel(this); + m_skinPreviewLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + } + + setWindowModality(Qt::WindowModal); + + auto contentsWidget = m_ui->listView; + contentsWidget->setViewMode(QListView::IconMode); + contentsWidget->setFlow(QListView::LeftToRight); + contentsWidget->setIconSize(QSize(48, 48)); + contentsWidget->setMovement(QListView::Static); + contentsWidget->setResizeMode(QListView::Adjust); + contentsWidget->setSelectionMode(QAbstractItemView::SingleSelection); + contentsWidget->setSpacing(5); + contentsWidget->setWordWrap(false); + contentsWidget->setWrapping(true); + contentsWidget->setUniformItemSizes(true); + contentsWidget->setTextElideMode(Qt::ElideRight); + contentsWidget->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); + contentsWidget->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + contentsWidget->installEventFilter(this); + contentsWidget->setItemDelegate(new ListViewDelegate(this)); + + contentsWidget->setAcceptDrops(true); + contentsWidget->setDropIndicatorShown(true); + contentsWidget->viewport()->setAcceptDrops(true); + contentsWidget->setDragDropMode(QAbstractItemView::DropOnly); + contentsWidget->setDefaultDropAction(Qt::CopyAction); + + contentsWidget->installEventFilter(this); + contentsWidget->setModel(&m_list); + + connect(contentsWidget, &QAbstractItemView::doubleClicked, this, &SkinManageDialog::activated); + + connect(contentsWidget->selectionModel(), &QItemSelectionModel::selectionChanged, this, &SkinManageDialog::selectionChanged); + connect(m_ui->listView, &QListView::customContextMenuRequested, this, &SkinManageDialog::show_context_menu); + connect(m_ui->elytraCB, &QCheckBox::stateChanged, this, [this]() { + if (m_skinPreview) { + m_skinPreview->setElytraVisible(m_ui->elytraCB->isChecked()); + } + on_capeCombo_currentIndexChanged(0); + }); + + setupCapes(); + + m_ui->listView->setCurrentIndex(m_list.index(m_list.getSelectedAccountSkin())); + + m_ui->buttonBox->button(QDialogButtonBox::Cancel)->setText(tr("Cancel")); + m_ui->buttonBox->button(QDialogButtonBox::Ok)->setText(tr("OK")); + + if (m_skinPreview) { + m_ui->skinLayout->insertWidget(0, QWidget::createWindowContainer(m_skinPreview, this)); + } else { + m_ui->skinLayout->insertWidget(0, m_skinPreviewLabel); + } +} + +SkinManageDialog::~SkinManageDialog() +{ + delete m_ui; + if (m_skinPreview) { + delete m_skinPreview; + } +} + +void SkinManageDialog::activated(QModelIndex index) +{ + m_selectedSkinKey = index.data(Qt::UserRole).toString(); + accept(); +} + +void SkinManageDialog::selectionChanged(QItemSelection selected, [[maybe_unused]] QItemSelection deselected) +{ + if (selected.empty()) + return; + + QString key = selected.first().indexes().first().data(Qt::UserRole).toString(); + if (key.isEmpty()) + return; + m_selectedSkinKey = key; + auto skin = getSelectedSkin(); + if (!skin) + return; + + if (m_skinPreview) { + m_skinPreview->updateScene(skin); + } else { + m_skinPreviewLabel->setPixmap( + QPixmap::fromImage(skin->getPreview()).scaled(m_skinPreviewLabel->size(), Qt::KeepAspectRatio, Qt::FastTransformation)); + } + m_ui->capeCombo->setCurrentIndex(m_capesIdx.value(skin->getCapeId())); + m_ui->steveBtn->setChecked(skin->getModel() == SkinModel::CLASSIC); + m_ui->alexBtn->setChecked(skin->getModel() == SkinModel::SLIM); +} + +void SkinManageDialog::delayed_scroll(QModelIndex model_index) +{ + auto contentsWidget = m_ui->listView; + contentsWidget->scrollTo(model_index); +} + +void SkinManageDialog::on_openDirBtn_clicked() +{ + DesktopServices::openPath(m_list.getDir(), true); +} + +void SkinManageDialog::on_fileBtn_clicked() +{ + auto filter = QMimeDatabase().mimeTypeForName("image/png").filterString(); + QString raw_path = QFileDialog::getOpenFileName(this, tr("Select Skin Texture"), QString(), filter); + if (raw_path.isNull()) { + return; + } + auto message = m_list.installSkin(raw_path, {}); + if (!message.isEmpty()) { + CustomMessageBox::selectable(this, tr("Selected file is not a valid skin"), message, QMessageBox::Critical)->show(); + return; + } +} + +QPixmap previewCape(QImage capeImage, bool elytra = false) +{ + if (elytra) { + auto wing = capeImage.copy(34, 2, 12, 20); + QImage mirrored = wing.mirrored(true, false); + + QImage combined(wing.width() * 2 + 1, wing.height() + 14, capeImage.format()); + combined.fill(Qt::transparent); + + QPainter painter(&combined); + painter.drawImage(0, 7, wing); + painter.drawImage(wing.width() + 1, 7, mirrored); + painter.end(); + return QPixmap::fromImage(combined.scaled(84, 128, Qt::KeepAspectRatio, Qt::FastTransformation)); + } + return QPixmap::fromImage(capeImage.copy(1, 1, 10, 16).scaled(80, 128, Qt::IgnoreAspectRatio, Qt::FastTransformation)); +} + +void SkinManageDialog::setupCapes() +{ + // FIXME: add a model for this, download/refresh the capes on demand + auto& accountData = *m_acct->accountData(); + int index = 0; + m_ui->capeCombo->addItem(tr("No Cape"), QVariant()); + auto currentCape = accountData.minecraftProfile.currentCape; + if (currentCape.isEmpty()) { + m_ui->capeCombo->setCurrentIndex(index); + } + + auto capesDir = FS::PathCombine(m_list.getDir(), "capes"); + NetJob::Ptr job{ new NetJob(tr("Download capes"), APPLICATION->network()) }; + bool needsToDownload = false; + for (auto& cape : accountData.minecraftProfile.capes) { + auto path = FS::PathCombine(capesDir, cape.id + ".png"); + if (cape.data.size()) { + QImage capeImage; + if (capeImage.loadFromData(cape.data, "PNG") && capeImage.save(path)) { + m_capes[cape.id] = capeImage; + continue; + } + } + if (QFileInfo(path).exists()) { + continue; + } + if (!cape.url.isEmpty()) { + needsToDownload = true; + job->addNetAction(Net::Download::makeFile(cape.url, path)); + } + } + if (needsToDownload) { + ProgressDialog dlg(this); + dlg.execWithTask(job.get()); + } + for (auto& cape : accountData.minecraftProfile.capes) { + index++; + QImage capeImage; + if (!m_capes.contains(cape.id)) { + auto path = FS::PathCombine(capesDir, cape.id + ".png"); + if (QFileInfo(path).exists() && capeImage.load(path)) { + m_capes[cape.id] = capeImage; + } + } + if (!capeImage.isNull()) { + m_ui->capeCombo->addItem(previewCape(capeImage, m_ui->elytraCB->isChecked()), cape.alias, cape.id); + } else { + m_ui->capeCombo->addItem(cape.alias, cape.id); + } + + m_capesIdx[cape.id] = index; + } +} + +void SkinManageDialog::on_capeCombo_currentIndexChanged(int index) +{ + auto id = m_ui->capeCombo->currentData(); + auto cape = m_capes.value(id.toString(), {}); + if (!cape.isNull()) { + m_ui->capeImage->setPixmap( + previewCape(cape, m_ui->elytraCB->isChecked()).scaled(size() * (1. / 3), Qt::KeepAspectRatio, Qt::FastTransformation)); + } else { + m_ui->capeImage->clear(); + } + if (m_skinPreview) { + m_skinPreview->updateCape(cape); + } + if (auto skin = getSelectedSkin(); skin) { + skin->setCapeId(id.toString()); + if (m_skinPreview) { + m_skinPreview->updateScene(skin); + } else { + m_skinPreviewLabel->setPixmap( + QPixmap::fromImage(skin->getPreview()).scaled(m_skinPreviewLabel->size(), Qt::KeepAspectRatio, Qt::FastTransformation)); + } + } +} + +void SkinManageDialog::on_steveBtn_toggled(bool checked) +{ + if (auto skin = getSelectedSkin(); skin) { + skin->setModel(checked ? SkinModel::CLASSIC : SkinModel::SLIM); + if (m_skinPreview) { + m_skinPreview->updateScene(skin); + } else { + m_skinPreviewLabel->setPixmap( + QPixmap::fromImage(skin->getPreview()).scaled(m_skinPreviewLabel->size(), Qt::KeepAspectRatio, Qt::FastTransformation)); + } + } +} + +void SkinManageDialog::accept() +{ + auto skin = m_list.skin(m_selectedSkinKey); + if (!skin) { + reject(); + return; + } + auto path = skin->getPath(); + + ProgressDialog prog(this); + NetJob::Ptr skinUpload{ new NetJob(tr("Change skin"), APPLICATION->network(), 1) }; + + if (!QFile::exists(path)) { + CustomMessageBox::selectable(this, tr("Skin Upload"), tr("Skin file does not exist!"), QMessageBox::Warning)->exec(); + reject(); + return; + } + + skinUpload->addNetAction(SkinUpload::make(m_acct->accessToken(), skin->getPath(), skin->getModelString())); + + auto selectedCape = skin->getCapeId(); + if (selectedCape != m_acct->accountData()->minecraftProfile.currentCape) { + skinUpload->addNetAction(CapeChange::make(m_acct->accessToken(), selectedCape)); + } + + skinUpload->addTask(m_acct->refresh().staticCast()); + if (prog.execWithTask(skinUpload.get()) != QDialog::Accepted) { + CustomMessageBox::selectable(this, tr("Skin Upload"), tr("Failed to upload skin!"), QMessageBox::Warning)->exec(); + reject(); + return; + } + skin->setURL(m_acct->accountData()->minecraftProfile.skin.url); + QDialog::accept(); +} + +void SkinManageDialog::on_resetBtn_clicked() +{ + ProgressDialog prog(this); + NetJob::Ptr skinReset{ new NetJob(tr("Reset skin"), APPLICATION->network(), 1) }; + skinReset->addNetAction(SkinDelete::make(m_acct->accessToken())); + skinReset->addTask(m_acct->refresh().staticCast()); + if (prog.execWithTask(skinReset.get()) != QDialog::Accepted) { + CustomMessageBox::selectable(this, tr("Skin Delete"), tr("Failed to delete current skin!"), QMessageBox::Warning)->exec(); + reject(); + return; + } + QDialog::accept(); +} + +void SkinManageDialog::show_context_menu(const QPoint& pos) +{ + QMenu myMenu(tr("Context menu"), this); + myMenu.addAction(m_ui->action_Rename_Skin); + myMenu.addAction(m_ui->action_Delete_Skin); + + myMenu.exec(m_ui->listView->mapToGlobal(pos)); +} + +bool SkinManageDialog::eventFilter(QObject* obj, QEvent* ev) +{ + if (obj == m_ui->listView) { + if (ev->type() == QEvent::KeyPress) { + QKeyEvent* keyEvent = static_cast(ev); + switch (keyEvent->key()) { + case Qt::Key_Delete: + on_action_Delete_Skin_triggered(false); + return true; + case Qt::Key_F2: + on_action_Rename_Skin_triggered(false); + return true; + default: + break; + } + } + } + return QDialog::eventFilter(obj, ev); +} + +void SkinManageDialog::on_action_Rename_Skin_triggered(bool) +{ + if (!m_selectedSkinKey.isEmpty()) { + m_ui->listView->edit(m_ui->listView->currentIndex()); + } +} + +void SkinManageDialog::on_action_Delete_Skin_triggered(bool) +{ + if (m_selectedSkinKey.isEmpty()) + return; + + if (m_list.getSkinIndex(m_selectedSkinKey) == m_list.getSelectedAccountSkin()) { + CustomMessageBox::selectable(this, tr("Delete error"), tr("Can not delete skin that is in use."), QMessageBox::Warning)->exec(); + return; + } + + auto skin = m_list.skin(m_selectedSkinKey); + if (!skin) + return; + + auto response = CustomMessageBox::selectable(this, tr("Confirm Deletion"), + tr("You are about to delete \"%1\".\n" + "Are you sure?") + .arg(skin->name()), + QMessageBox::Warning, QMessageBox::Yes | QMessageBox::No, QMessageBox::No) + ->exec(); + + if (response == QMessageBox::Yes) { + if (!m_list.deleteSkin(m_selectedSkinKey, true)) { + m_list.deleteSkin(m_selectedSkinKey, false); + } + } +} + +void SkinManageDialog::on_urlBtn_clicked() +{ + auto url = QUrl(m_ui->urlLine->text()); + if (!url.isValid()) { + CustomMessageBox::selectable(this, tr("Invalid url"), tr("Invalid url"), QMessageBox::Critical)->show(); + return; + } + + NetJob::Ptr job{ new NetJob(tr("Download skin"), APPLICATION->network()) }; + job->setAskRetry(false); + + auto path = FS::PathCombine(m_list.getDir(), url.fileName()); + job->addNetAction(Net::Download::makeFile(url, path)); + ProgressDialog dlg(this); + dlg.execWithTask(job.get()); + SkinModel s(path); + if (!s.isValid()) { + CustomMessageBox::selectable(this, tr("URL is not a valid skin"), + QFileInfo::exists(path) ? tr("Skin images must be 64x64 or 64x32 pixel PNG files.") + : tr("Unable to download the skin: '%1'.").arg(m_ui->urlLine->text()), + QMessageBox::Critical) + ->show(); + QFile::remove(path); + return; + } + m_ui->urlLine->setText(""); + if (QFileInfo(path).suffix().isEmpty()) { + QFile::rename(path, path + ".png"); + } +} + +class WaitTask : public Task { + public: + WaitTask() : m_loop(), m_done(false) {}; + virtual ~WaitTask() = default; + + public slots: + void quit() + { + m_done = true; + m_loop.quit(); + } + + protected: + virtual void executeTask() + { + if (!m_done) + m_loop.exec(); + emitSucceeded(); + }; + + private: + QEventLoop m_loop; + bool m_done; +}; + +void SkinManageDialog::on_userBtn_clicked() +{ + auto user = m_ui->urlLine->text(); + if (user.isEmpty()) { + return; + } + MinecraftProfile mcProfile; + auto path = FS::PathCombine(m_list.getDir(), user + ".png"); + + NetJob::Ptr job{ new NetJob(tr("Download user skin"), APPLICATION->network(), 1) }; + job->setAskRetry(false); + + auto uuidLoop = makeShared(); + auto profileLoop = makeShared(); + + auto [getUUID, uuidOut] = Net::Download::makeByteArray("https://api.minecraftservices.com/minecraft/profile/lookup/name/" + user); + auto [getProfile, profileOut] = Net::Download::makeByteArray(QUrl()); + auto downloadSkin = Net::Download::makeFile(QUrl(), path); + + QString failReason; + + connect(getUUID.get(), &Task::aborted, uuidLoop.get(), &WaitTask::quit); + connect(getUUID.get(), &Task::failed, this, [&failReason](QString reason) { + qCritical() << "Couldn't get user UUID:" << reason; + failReason = tr("failed to get user UUID"); + }); + connect(getUUID.get(), &Task::failed, uuidLoop.get(), &WaitTask::quit); + connect(getProfile.get(), &Task::aborted, profileLoop.get(), &WaitTask::quit); + connect(getProfile.get(), &Task::failed, profileLoop.get(), &WaitTask::quit); + connect(getProfile.get(), &Task::failed, this, [&failReason](QString reason) { + qCritical() << "Couldn't get user profile:" << reason; + failReason = tr("failed to get user profile"); + }); + connect(downloadSkin.get(), &Task::failed, this, [&failReason](QString reason) { + qCritical() << "Couldn't download skin:" << reason; + failReason = tr("failed to download skin"); + }); + + connect(getUUID.get(), &Task::succeeded, this, [uuidLoop, uuidOut, job, getProfile, &failReason] { + try { + QJsonParseError parse_error{}; + QJsonDocument doc = QJsonDocument::fromJson(*uuidOut, &parse_error); + if (parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from Minecraft skin service at" << parse_error.offset + << "reason:" << parse_error.errorString(); + failReason = tr("failed to parse get user UUID response"); + uuidLoop->quit(); + return; + } + const auto root = doc.object(); + auto id = root["id"].toString(); + if (!id.isEmpty()) { + getProfile->setUrl("https://sessionserver.mojang.com/session/minecraft/profile/" + id); + } else { + failReason = tr("user id is empty"); + job->abort(); + } + } catch (const Exception& e) { + qCritical() << "Couldn't load skin json:" << e.cause(); + failReason = tr("failed to parse get user UUID response"); + } + uuidLoop->quit(); + }); + + connect(getProfile.get(), &Task::succeeded, this, [profileLoop, profileOut, job, getProfile, &mcProfile, downloadSkin, &failReason] { + if (Parsers::parseMinecraftProfileMojang(*profileOut, mcProfile)) { + downloadSkin->setUrl(mcProfile.skin.url); + } else { + failReason = tr("failed to parse get user profile response"); + job->abort(); + } + profileLoop->quit(); + }); + + job->addNetAction(getUUID); + job->addTask(uuidLoop); + job->addNetAction(getProfile); + job->addTask(profileLoop); + job->addNetAction(downloadSkin); + ProgressDialog dlg(this); + dlg.execWithTask(job.get()); + + SkinModel s(path); + if (!s.isValid()) { + if (failReason.isEmpty()) { + failReason = tr("the skin is invalid"); + } + CustomMessageBox::selectable(this, tr("Username not found"), + tr("Unable to find the skin for '%1'\n because: %2.").arg(user, failReason), QMessageBox::Critical) + ->show(); + QFile::remove(path); + return; + } + m_ui->urlLine->setText(""); + s.setModel(mcProfile.skin.variant.toUpper() == "SLIM" ? SkinModel::SLIM : SkinModel::CLASSIC); + s.setURL(mcProfile.skin.url); + if (m_capes.contains(mcProfile.currentCape)) { + s.setCapeId(mcProfile.currentCape); + } + m_list.updateSkin(&s); +} + +void SkinManageDialog::resizeEvent(QResizeEvent* event) +{ + QWidget::resizeEvent(event); + QSize s = size() * (1. / 3); + + auto id = m_ui->capeCombo->currentData(); + auto cape = m_capes.value(id.toString(), {}); + if (!cape.isNull()) { + m_ui->capeImage->setPixmap(previewCape(cape, m_ui->elytraCB->isChecked()).scaled(s, Qt::KeepAspectRatio, Qt::FastTransformation)); + } else { + m_ui->capeImage->clear(); + } + if (auto skin = getSelectedSkin(); skin && !m_skinPreview) { + m_skinPreviewLabel->setPixmap( + QPixmap::fromImage(skin->getPreview()).scaled(m_skinPreviewLabel->size(), Qt::KeepAspectRatio, Qt::FastTransformation)); + } +} + +SkinModel* SkinManageDialog::getSelectedSkin() +{ + if (auto skin = m_list.skin(m_selectedSkinKey); skin && skin->isValid()) { + return skin; + } + return nullptr; +} + +QHash SkinManageDialog::capes() +{ + return m_capes; +} diff --git a/launcher/ui/dialogs/skins/SkinManageDialog.h b/launcher/ui/dialogs/skins/SkinManageDialog.h new file mode 100644 index 0000000..27bdb93 --- /dev/null +++ b/launcher/ui/dialogs/skins/SkinManageDialog.h @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023-2024 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include +#include +#include + +#include "minecraft/auth/MinecraftAccount.h" +#include "minecraft/skins/SkinList.h" +#include "minecraft/skins/SkinModel.h" +#include "ui/dialogs/skins/draw/SkinOpenGLWindow.h" + +namespace Ui { +class SkinManageDialog; +} +class SkinManageDialog : public QDialog, public SkinProvider { + Q_OBJECT + public: + explicit SkinManageDialog(QWidget* parent, MinecraftAccountPtr acct); + virtual ~SkinManageDialog(); + void resizeEvent(QResizeEvent* event) override; + + virtual SkinModel* getSelectedSkin() override; + virtual QHash capes() override; + + public slots: + void selectionChanged(QItemSelection, QItemSelection); + void activated(QModelIndex); + void delayed_scroll(QModelIndex); + void on_openDirBtn_clicked(); + void on_fileBtn_clicked(); + void on_urlBtn_clicked(); + void on_userBtn_clicked(); + void accept() override; + void on_capeCombo_currentIndexChanged(int index); + void on_steveBtn_toggled(bool checked); + void on_resetBtn_clicked(); + void show_context_menu(const QPoint& pos); + bool eventFilter(QObject* obj, QEvent* ev) override; + void on_action_Rename_Skin_triggered(bool checked); + void on_action_Delete_Skin_triggered(bool checked); + + private: + void setupCapes(); + + private: + MinecraftAccountPtr m_acct; + Ui::SkinManageDialog* m_ui; + SkinList m_list; + QString m_selectedSkinKey; + QHash m_capes; + QHash m_capesIdx; + SkinOpenGLWindow* m_skinPreview = nullptr; + QLabel* m_skinPreviewLabel = nullptr; +}; diff --git a/launcher/ui/dialogs/skins/SkinManageDialog.ui b/launcher/ui/dialogs/skins/SkinManageDialog.ui new file mode 100644 index 0000000..aeb5168 --- /dev/null +++ b/launcher/ui/dialogs/skins/SkinManageDialog.ui @@ -0,0 +1,223 @@ + + + SkinManageDialog + + + + 0 + 0 + 968 + 757 + + + + Skin Upload + + + + + + + + + + + + + + 0 + 0 + + + + Model + + + + + + Classic + + + true + + + + + + + Slim + + + + + + + + + + Cape + + + + + + Preview Elytra + + + + + + + + + + + + + false + + + Qt::AlignCenter + + + + + + + + + + + + Qt::CustomContextMenu + + + false + + + 0 + + + + + + + + + + + Open Folder + + + + + + + Reset Skin + + + + + + + + + + + + + + Import URL + + + + + + + Import user + + + + + + + Import File + + + + + + + + 0 + 0 + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + &Delete Skin + + + Deletes selected skin + + + Del + + + + + &Rename Skin + + + Rename selected skin + + + F2 + + + + + + + buttonBox + rejected() + SkinManageDialog + reject() + + + 617 + 736 + + + 483 + 378 + + + + + buttonBox + accepted() + SkinManageDialog + accept() + + + 617 + 736 + + + 483 + 378 + + + + + diff --git a/launcher/ui/dialogs/skins/draw/BoxGeometry.cpp b/launcher/ui/dialogs/skins/draw/BoxGeometry.cpp new file mode 100644 index 0000000..f1f7713 --- /dev/null +++ b/launcher/ui/dialogs/skins/draw/BoxGeometry.cpp @@ -0,0 +1,283 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2024 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "BoxGeometry.h" + +#include +#include +#include +#include + +struct VertexData { + QVector4D position; + QVector2D texCoord; + VertexData(const QVector4D& pos, const QVector2D& tex) : position(pos), texCoord(tex) {} +}; + +// For cube we would need only 8 vertices but we have to +// duplicate vertex for each face because texture coordinate +// is different. +static const QList vertices = { + // Vertex data for face 0 + QVector4D(-0.5f, -0.5f, 0.5f, 1.0f), // v0 + QVector4D(0.5f, -0.5f, 0.5f, 1.0f), // v1 + QVector4D(-0.5f, 0.5f, 0.5f, 1.0f), // v2 + QVector4D(0.5f, 0.5f, 0.5f, 1.0f), // v3 + // Vertex data for face 1 + QVector4D(0.5f, -0.5f, 0.5f, 1.0f), // v4 + QVector4D(0.5f, -0.5f, -0.5f, 1.0f), // v5 + QVector4D(0.5f, 0.5f, 0.5f, 1.0f), // v6 + QVector4D(0.5f, 0.5f, -0.5f, 1.0f), // v7 + + // Vertex data for face 2 + QVector4D(0.5f, -0.5f, -0.5f, 1.0f), // v8 + QVector4D(-0.5f, -0.5f, -0.5f, 1.0f), // v9 + QVector4D(0.5f, 0.5f, -0.5f, 1.0f), // v10 + QVector4D(-0.5f, 0.5f, -0.5f, 1.0f), // v11 + + // Vertex data for face 3 + QVector4D(-0.5f, -0.5f, -0.5f, 1.0f), // v12 + QVector4D(-0.5f, -0.5f, 0.5f, 1.0f), // v13 + QVector4D(-0.5f, 0.5f, -0.5f, 1.0f), // v14 + QVector4D(-0.5f, 0.5f, 0.5f, 1.0f), // v15 + + // Vertex data for face 4 + QVector4D(-0.5f, -0.5f, -0.5f, 1.0f), // v16 + QVector4D(0.5f, -0.5f, -0.5f, 1.0f), // v17 + QVector4D(-0.5f, -0.5f, 0.5f, 1.0f), // v18 + QVector4D(0.5f, -0.5f, 0.5f, 1.0f), // v19 + + // Vertex data for face 5 + QVector4D(-0.5f, 0.5f, 0.5f, 1.0f), // v20 + QVector4D(0.5f, 0.5f, 0.5f, 1.0f), // v21 + QVector4D(-0.5f, 0.5f, -0.5f, 1.0f), // v22 + QVector4D(0.5f, 0.5f, -0.5f, 1.0f), // v23 +}; + +// Indices for drawing cube faces using triangle strips. +// Triangle strips can be connected by duplicating indices +// between the strips. If connecting strips have opposite +// vertex order then last index of the first strip and first +// index of the second strip needs to be duplicated. If +// connecting strips have same vertex order then only last +// index of the first strip needs to be duplicated. +static const QList indices = { + 0, 1, 2, 3, 3, // Face 0 - triangle strip ( v0, v1, v2, v3) + 4, 4, 5, 6, 7, 7, // Face 1 - triangle strip ( v4, v5, v6, v7) + 8, 8, 9, 10, 11, 11, // Face 2 - triangle strip ( v8, v9, v10, v11) + 12, 12, 13, 14, 15, 15, // Face 3 - triangle strip (v12, v13, v14, v15) + 16, 16, 17, 18, 19, 19, // Face 4 - triangle strip (v16, v17, v18, v19) + 20, 20, 21, 22, 23 // Face 5 - triangle strip (v20, v21, v22, v23) +}; + +static const QList planeVertices = { + { QVector4D(-1.0f, -1.0f, -0.5f, 1.0f), QVector2D(0.0f, 0.0f) }, // Bottom-left + { QVector4D(1.0f, -1.0f, -0.5f, 1.0f), QVector2D(1.0f, 0.0f) }, // Bottom-right + { QVector4D(-1.0f, 1.0f, -0.5f, 1.0f), QVector2D(0.0f, 1.0f) }, // Top-left + { QVector4D(1.0f, 1.0f, -0.5f, 1.0f), QVector2D(1.0f, 1.0f) }, // Top-right +}; +static const QList planeIndices = { + 0, 1, 2, 3, 3 // Face 0 - triangle strip ( v0, v1, v2, v3) +}; + +QList transformVectors(const QMatrix4x4& matrix, const QList& vectors) +{ + QList transformedVectors; + transformedVectors.reserve(vectors.size()); + + for (const QVector4D& vec : vectors) { + if (!matrix.isIdentity()) { + transformedVectors.append(matrix * vec); + } else { + transformedVectors.append(vec); + } + } + + return transformedVectors; +} + +// Function to calculate UV coordinates +// this is pure magic (if something is wrong with textures this is at fault) +QList getCubeUVs(float u, float v, float width, float height, float depth, float textureWidth, float textureHeight) +{ + auto toFaceVertices = [textureHeight, textureWidth](float x1, float y1, float x2, float y2) -> QList { + return { + QVector2D(x1 / textureWidth, 1.0 - y2 / textureHeight), + QVector2D(x2 / textureWidth, 1.0 - y2 / textureHeight), + QVector2D(x2 / textureWidth, 1.0 - y1 / textureHeight), + QVector2D(x1 / textureWidth, 1.0 - y1 / textureHeight), + }; + }; + + auto top = toFaceVertices(u + depth, v, u + width + depth, v + depth); + auto bottom = toFaceVertices(u + width + depth, v, u + width * 2 + depth, v + depth); + auto left = toFaceVertices(u, v + depth, u + depth, v + depth + height); + auto front = toFaceVertices(u + depth, v + depth, u + width + depth, v + depth + height); + auto right = toFaceVertices(u + width + depth, v + depth, u + width + depth * 2, v + height + depth); + auto back = toFaceVertices(u + width + depth * 2, v + depth, u + width * 2 + depth * 2, v + height + depth); + + auto uvRight = { + right[0], + right[1], + right[3], + right[2], + }; + auto uvLeft = { + left[0], + left[1], + left[3], + left[2], + }; + auto uvTop = { + top[0], + top[1], + top[3], + top[2], + }; + auto uvBottom = { + bottom[3], + bottom[2], + bottom[0], + bottom[1], + }; + auto uvFront = { + front[0], + front[1], + front[3], + front[2], + }; + auto uvBack = { + back[0], + back[1], + back[3], + back[2], + }; + // Create a new array to hold the modified UV data + QList uvData; + uvData.reserve(24); + + // Iterate over the arrays and copy the data to newUVData + for (const auto& uvArray : { uvFront, uvRight, uvBack, uvLeft, uvBottom, uvTop }) { + uvData.append(uvArray); + } + + return uvData; +} + +namespace opengl { +BoxGeometry::BoxGeometry(QVector3D size, QVector3D position) + : QOpenGLFunctions(), m_indexBuf(QOpenGLBuffer::IndexBuffer), m_size(size), m_position(position) +{ + initializeOpenGLFunctions(); + + // Generate 2 VBOs + m_vertexBuf.create(); + m_indexBuf.create(); +} + +BoxGeometry::BoxGeometry(QVector3D size, QVector3D position, QPoint uv, QVector3D textureDim, QSize textureSize) + : BoxGeometry(size, position) +{ + initGeometry(uv.x(), uv.y(), textureDim.x(), textureDim.y(), textureDim.z(), textureSize.width(), textureSize.height()); +} + +BoxGeometry::~BoxGeometry() +{ + m_vertexBuf.destroy(); + m_indexBuf.destroy(); +} + +void BoxGeometry::draw(QOpenGLShaderProgram* program) +{ + // Tell OpenGL which VBOs to use + program->setUniformValue("model_matrix", m_matrix); + m_vertexBuf.bind(); + m_indexBuf.bind(); + + // Offset for position + quintptr offset = 0; + + // Tell OpenGL programmable pipeline how to locate vertex position data + int vertexLocation = program->attributeLocation("a_position"); + program->enableAttributeArray(vertexLocation); + program->setAttributeBuffer(vertexLocation, GL_FLOAT, offset, 4, sizeof(VertexData)); + + // Offset for texture coordinate + offset += sizeof(QVector4D); + // Tell OpenGL programmable pipeline how to locate vertex texture coordinate data + int texcoordLocation = program->attributeLocation("a_texcoord"); + program->enableAttributeArray(texcoordLocation); + program->setAttributeBuffer(texcoordLocation, GL_FLOAT, offset, 2, sizeof(VertexData)); + + // Draw cube geometry using indices from VBO 1 + glDrawElements(GL_TRIANGLE_STRIP, m_indecesCount, GL_UNSIGNED_SHORT, nullptr); +} + +void BoxGeometry::initGeometry(float u, float v, float width, float height, float depth, float textureWidth, float textureHeight) +{ + auto textureCord = getCubeUVs(u, v, width, height, depth, textureWidth, textureHeight); + + // this should not be needed to be done on each render for most of the objects + QMatrix4x4 transformation; + transformation.translate(m_position); + transformation.scale(m_size); + auto positions = transformVectors(transformation, vertices); + + QList verticesData; + verticesData.reserve(positions.size()); // Reserve space for efficiency + + for (int i = 0; i < positions.size(); ++i) { + verticesData.append(VertexData(positions[i], textureCord[i])); + } + + // Transfer vertex data to VBO 0 + m_vertexBuf.bind(); + m_vertexBuf.allocate(verticesData.constData(), static_cast(verticesData.size() * sizeof(VertexData))); + + // Transfer index data to VBO 1 + m_indexBuf.bind(); + m_indexBuf.allocate(indices.constData(), static_cast(indices.size() * sizeof(GLushort))); + m_indecesCount = indices.size(); +} + +void BoxGeometry::rotate(float angle, const QVector3D& vector) +{ + m_matrix.rotate(angle, vector); +} + +BoxGeometry* BoxGeometry::Plane() +{ + auto b = new BoxGeometry(QVector3D(), QVector3D()); + + // Transfer vertex data to VBO 0 + b->m_vertexBuf.bind(); + b->m_vertexBuf.allocate(planeVertices.constData(), static_cast(planeVertices.size() * sizeof(VertexData))); + + // Transfer index data to VBO 1 + b->m_indexBuf.bind(); + b->m_indexBuf.allocate(planeIndices.constData(), static_cast(planeIndices.size() * sizeof(GLushort))); + b->m_indecesCount = planeIndices.size(); + + return b; +} + +void BoxGeometry::scale(const QVector3D& vector) +{ + m_matrix.scale(vector); +} +} // namespace opengl diff --git a/launcher/ui/dialogs/skins/draw/BoxGeometry.h b/launcher/ui/dialogs/skins/draw/BoxGeometry.h new file mode 100644 index 0000000..fa1a4c6 --- /dev/null +++ b/launcher/ui/dialogs/skins/draw/BoxGeometry.h @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2024 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include +#include +#include +#include + +namespace opengl { +class BoxGeometry : protected QOpenGLFunctions { + public: + BoxGeometry(QVector3D size, QVector3D position); + BoxGeometry(QVector3D size, QVector3D position, QPoint uv, QVector3D textureDim, QSize textureSize = { 64, 64 }); + static BoxGeometry* Plane(); + virtual ~BoxGeometry(); + + void draw(QOpenGLShaderProgram* program); + + void initGeometry(float u, float v, float width, float height, float depth, float textureWidth = 64, float textureHeight = 64); + void rotate(float angle, const QVector3D& vector); + void scale(const QVector3D& vector); + + private: + QOpenGLBuffer m_vertexBuf; + QOpenGLBuffer m_indexBuf; + QVector3D m_size; + QVector3D m_position; + QMatrix4x4 m_matrix; + GLsizei m_indecesCount; +}; +} // namespace opengl diff --git a/launcher/ui/dialogs/skins/draw/Scene.cpp b/launcher/ui/dialogs/skins/draw/Scene.cpp new file mode 100644 index 0000000..1d06c69 --- /dev/null +++ b/launcher/ui/dialogs/skins/draw/Scene.cpp @@ -0,0 +1,184 @@ + +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2024 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "ui/dialogs/skins/draw/Scene.h" + +#include +#include +#include +#include + +namespace opengl { +Scene::Scene(const QImage& skin, bool slim, const QImage& cape) : QOpenGLFunctions(), m_slim(slim), m_capeVisible(!cape.isNull()) +{ + initializeOpenGLFunctions(); + m_staticComponents = { + // head + new opengl::BoxGeometry(QVector3D(8, 8, 8), QVector3D(0, 4, 0), QPoint(0, 0), QVector3D(8, 8, 8)), + // body + new opengl::BoxGeometry(QVector3D(8, 12, 4), QVector3D(0, -6, 0), QPoint(16, 16), QVector3D(8, 12, 4)), + // right leg + new opengl::BoxGeometry(QVector3D(4, 12, 4), QVector3D(-1.9f, -18, -0.1f), QPoint(0, 16), QVector3D(4, 12, 4)), + // left leg + new opengl::BoxGeometry(QVector3D(4, 12, 4), QVector3D(1.9f, -18, -0.1f), QPoint(16, 48), QVector3D(4, 12, 4)), + }; + + m_staticComponentsOverlay = { + // head + new opengl::BoxGeometry(QVector3D(9, 9, 9), QVector3D(0, 4, 0), QPoint(32, 0), QVector3D(8, 8, 8)), + // body + new opengl::BoxGeometry(QVector3D(8.5, 12.5, 4.5), QVector3D(0, -6, 0), QPoint(16, 32), QVector3D(8, 12, 4)), + // right leg + new opengl::BoxGeometry(QVector3D(4.5f, 12.5f, 4.5f), QVector3D(-1.9f, -18, -0.1f), QPoint(0, 32), QVector3D(4, 12, 4)), + // left leg + new opengl::BoxGeometry(QVector3D(4.5f, 12.5f, 4.5f), QVector3D(1.9f, -18, -0.1f), QPoint(0, 48), QVector3D(4, 12, 4)), + }; + + m_normalArms = { + // Right Arm + new opengl::BoxGeometry(QVector3D(4, 12, 4), QVector3D(-6, -6, 0), QPoint(40, 16), QVector3D(4, 12, 4)), + // Left Arm + new opengl::BoxGeometry(QVector3D(4, 12, 4), QVector3D(6, -6, 0), QPoint(32, 48), QVector3D(4, 12, 4)), + }; + + m_normalArmsOverlay = { + // Right Arm + new opengl::BoxGeometry(QVector3D(4.5, 12.5, 4.5), QVector3D(-6, -6, 0), QPoint(40, 32), QVector3D(4, 12, 4)), + // Left Arm + new opengl::BoxGeometry(QVector3D(4.5, 12.5, 4.5), QVector3D(6, -6, 0), QPoint(48, 48), QVector3D(4, 12, 4)), + }; + + m_slimArms = { + // Right Arm + new opengl::BoxGeometry(QVector3D(3, 12, 4), QVector3D(-5.5, -6, 0), QPoint(40, 16), QVector3D(3, 12, 4)), + // Left Arm + new opengl::BoxGeometry(QVector3D(3, 12, 4), QVector3D(5.5, -6, 0), QPoint(32, 48), QVector3D(3, 12, 4)), + }; + + m_slimArmsOverlay = { + // Right Arm + new opengl::BoxGeometry(QVector3D(3.5, 12.5, 4.5), QVector3D(-5.5, -6, 0), QPoint(40, 32), QVector3D(3, 12, 4)), + // Left Arm + new opengl::BoxGeometry(QVector3D(3.5, 12.5, 4.5), QVector3D(5.5, -6, 0), QPoint(48, 48), QVector3D(3, 12, 4)), + }; + + m_cape = new opengl::BoxGeometry(QVector3D(10, 16, 1), QVector3D(0, -8, 2.5), QPoint(0, 0), QVector3D(10, 16, 1), QSize(64, 32)); + m_cape->rotate(10.8f, QVector3D(1, 0, 0)); + m_cape->rotate(180, QVector3D(0, 1, 0)); + + auto leftWing = + new opengl::BoxGeometry(QVector3D(12, 22, 4), QVector3D(0, -13, -2), QPoint(22, 0), QVector3D(10, 20, 2), QSize(64, 32)); + leftWing->rotate(15, QVector3D(1, 0, 0)); + leftWing->rotate(15, QVector3D(0, 0, 1)); + leftWing->rotate(1, QVector3D(1, 0, 0)); + auto rightWing = + new opengl::BoxGeometry(QVector3D(12, 22, 4), QVector3D(0, -13, -2), QPoint(22, 0), QVector3D(10, 20, 2), QSize(64, 32)); + rightWing->scale(QVector3D(-1, 1, 1)); + rightWing->rotate(15, QVector3D(1, 0, 0)); + rightWing->rotate(15, QVector3D(0, 0, 1)); + rightWing->rotate(1, QVector3D(1, 0, 0)); + m_elytra << leftWing << rightWing; + + // texture init + m_skinTexture = new QOpenGLTexture(skin.mirrored()); + m_skinTexture->setMinificationFilter(QOpenGLTexture::Nearest); + m_skinTexture->setMagnificationFilter(QOpenGLTexture::Nearest); + + m_capeTexture = new QOpenGLTexture(cape.mirrored()); + m_capeTexture->setMinificationFilter(QOpenGLTexture::Nearest); + m_capeTexture->setMagnificationFilter(QOpenGLTexture::Nearest); +} +Scene::~Scene() +{ + for (auto array : + { m_staticComponents, m_normalArms, m_slimArms, m_elytra, m_staticComponentsOverlay, m_normalArmsOverlay, m_slimArmsOverlay }) { + for (auto g : array) { + delete g; + } + } + delete m_cape; + + m_skinTexture->destroy(); + delete m_skinTexture; + + m_capeTexture->destroy(); + delete m_capeTexture; +} + +void Scene::draw(QOpenGLShaderProgram* program) +{ + m_skinTexture->bind(); + program->setUniformValue("texture", 0); + for (auto toDraw : { m_staticComponents, m_slim ? m_slimArms : m_normalArms, m_staticComponentsOverlay, + m_slim ? m_slimArmsOverlay : m_normalArmsOverlay }) { + for (auto g : toDraw) { + g->draw(program); + } + } + m_skinTexture->release(); + if (m_capeVisible) { + m_capeTexture->bind(); + program->setUniformValue("texture", 0); + if (!m_elytraVisible) { + m_cape->draw(program); + } else { + for (auto e : m_elytra) { + e->draw(program); + } + } + m_capeTexture->release(); + } +} + +void updateTexture(QOpenGLTexture* texture, const QImage& img) +{ + if (texture) { + if (texture->isBound()) + texture->release(); + texture->destroy(); + texture->create(); + texture->setSize(img.width(), img.height()); + texture->setData(img); + texture->setMinificationFilter(QOpenGLTexture::Nearest); + texture->setMagnificationFilter(QOpenGLTexture::Nearest); + } +} + +void Scene::setSkin(const QImage& skin) +{ + updateTexture(m_skinTexture, skin.mirrored()); +} + +void Scene::setMode(bool slim) +{ + m_slim = slim; +} +void Scene::setCape(const QImage& cape) +{ + updateTexture(m_capeTexture, cape.mirrored()); +} +void Scene::setCapeVisible(bool visible) +{ + m_capeVisible = visible; +} +void Scene::setElytraVisible(bool elytraVisible) +{ + m_elytraVisible = elytraVisible; +} +} // namespace opengl diff --git a/launcher/ui/dialogs/skins/draw/Scene.h b/launcher/ui/dialogs/skins/draw/Scene.h new file mode 100644 index 0000000..897fbca --- /dev/null +++ b/launcher/ui/dialogs/skins/draw/Scene.h @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2024 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include "ui/dialogs/skins/draw/BoxGeometry.h" + +#include +namespace opengl { +class Scene : protected QOpenGLFunctions { + public: + Scene(const QImage& skin, bool slim, const QImage& cape); + virtual ~Scene(); + + void draw(QOpenGLShaderProgram* program); + void setSkin(const QImage& skin); + void setCape(const QImage& cape); + void setMode(bool slim); + void setCapeVisible(bool visible); + void setElytraVisible(bool elytraVisible); + + private: + QList m_staticComponents; + QList m_normalArms; + QList m_slimArms; + QList m_staticComponentsOverlay; + QList m_normalArmsOverlay; + QList m_slimArmsOverlay; + BoxGeometry* m_cape = nullptr; + QList m_elytra; + QOpenGLTexture* m_skinTexture = nullptr; + QOpenGLTexture* m_capeTexture = nullptr; + bool m_slim = false; + bool m_capeVisible = false; + bool m_elytraVisible = false; +}; +} // namespace opengl diff --git a/launcher/ui/dialogs/skins/draw/SkinOpenGLWindow.cpp b/launcher/ui/dialogs/skins/draw/SkinOpenGLWindow.cpp new file mode 100644 index 0000000..ca6d6ad --- /dev/null +++ b/launcher/ui/dialogs/skins/draw/SkinOpenGLWindow.cpp @@ -0,0 +1,343 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2024 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "ui/dialogs/skins/draw/SkinOpenGLWindow.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "BuildConfig.h" +#include "minecraft/skins/SkinModel.h" +#include "rainbow.h" +#include "ui/dialogs/skins/draw/BoxGeometry.h" +#include "ui/dialogs/skins/draw/Scene.h" + +SkinOpenGLWindow::SkinOpenGLWindow(SkinProvider* parent, QColor color) + : QOpenGLWindow(), QOpenGLFunctions(), m_baseColor(color), m_parent(parent) +{ + QSurfaceFormat format = QSurfaceFormat::defaultFormat(); + format.setDepthBufferSize(24); + setFormat(format); +} + +SkinOpenGLWindow::~SkinOpenGLWindow() +{ + // Make sure the context is current when deleting the texture + // and the buffers. + makeCurrent(); + // double check if resources were initialized because they are not + // initialized together with the object + if (m_scene) { + delete m_scene; + } + if (m_background) { + delete m_background; + } + if (m_backgroundTexture) { + if (m_backgroundTexture->isCreated()) { + m_backgroundTexture->destroy(); + } + delete m_backgroundTexture; + } + if (m_modelProgram) { + if (m_modelProgram->isLinked()) { + m_modelProgram->release(); + } + m_modelProgram->removeAllShaders(); + delete m_modelProgram; + } + if (m_backgroundProgram) { + if (m_backgroundProgram->isLinked()) { + m_backgroundProgram->release(); + } + m_backgroundProgram->removeAllShaders(); + delete m_backgroundProgram; + } + doneCurrent(); +} + +void SkinOpenGLWindow::mousePressEvent(QMouseEvent* e) +{ + // Save mouse press position + m_mousePosition = QVector2D(e->pos()); + m_isMousePressed = true; +} + +void SkinOpenGLWindow::mouseMoveEvent(QMouseEvent* event) +{ + // Prevents mouse sticking on Wayland compositors + if (!(event->buttons() & Qt::MouseButton::LeftButton)) { + m_isMousePressed = false; + return; + } + + if (m_isMousePressed) { + int dx = event->position().x() - m_mousePosition.x(); + int dy = event->position().y() - m_mousePosition.y(); + + m_yaw += dx * 0.5f; + m_pitch += dy * 0.5f; + + // Normalize yaw to keep it manageable + if (m_yaw > 360.0f) + m_yaw -= 360.0f; + else if (m_yaw < 0.0f) + m_yaw += 360.0f; + + m_mousePosition = QVector2D(event->pos()); + update(); // Trigger a repaint + } +} + +void SkinOpenGLWindow::mouseReleaseEvent([[maybe_unused]] QMouseEvent* e) +{ + m_isMousePressed = false; +} + +void SkinOpenGLWindow::initializeGL() +{ + initializeOpenGLFunctions(); + + glClearColor(0, 0, 1, 1); + + initShaders(); + + generateBackgroundTexture(32, 32, 1); + + QImage skin, cape; + bool slim = false; + if (m_parent) { + if (auto s = m_parent->getSelectedSkin()) { + skin = s->getTexture(); + slim = s->getModel() == SkinModel::SLIM; + cape = m_parent->capes().value(s->getCapeId(), {}); + } + } + + m_scene = new opengl::Scene(skin, slim, cape); + m_background = opengl::BoxGeometry::Plane(); + glEnable(GL_TEXTURE_2D); +} + +void SkinOpenGLWindow::initShaders() +{ + // Skin model shaders + m_modelProgram = new QOpenGLShaderProgram(this); + // Compile vertex shader + if (!m_modelProgram->addCacheableShaderFromSourceFile(QOpenGLShader::Vertex, ":/shaders/vshader_skin_model.glsl")) + close(); + + // Compile fragment shader + if (!m_modelProgram->addCacheableShaderFromSourceFile(QOpenGLShader::Fragment, ":/shaders/fshader.glsl")) + close(); + + // Link shader pipeline + if (!m_modelProgram->link()) + close(); + + // Bind shader pipeline for use + if (!m_modelProgram->bind()) + close(); + + // Background shaders + m_backgroundProgram = new QOpenGLShaderProgram(this); + // Compile vertex shader + if (!m_backgroundProgram->addCacheableShaderFromSourceFile(QOpenGLShader::Vertex, ":/shaders/vshader_skin_background.glsl")) + close(); + + // Compile fragment shader + if (!m_backgroundProgram->addCacheableShaderFromSourceFile(QOpenGLShader::Fragment, ":/shaders/fshader.glsl")) + close(); + + // Link shader pipeline + if (!m_backgroundProgram->link()) + close(); + + // Bind shader pipeline for use (verification) + if (!m_backgroundProgram->bind()) + close(); +} + +void SkinOpenGLWindow::resizeGL(int w, int h) +{ + // Calculate aspect ratio + qreal aspect = qreal(w) / qreal(h ? h : 1); + + const qreal zNear = 15., fov = 45; + + // Reset projection + m_projection.setToIdentity(); + + // Build the reverse z perspective projection matrix + double radians = qDegreesToRadians(fov / 2.); + double sine = std::sin(radians); + if (sine == 0) + return; + double cotan = std::cos(radians) / sine; + + m_projection(0, 0) = cotan / aspect; + m_projection(1, 1) = cotan; + m_projection(2, 2) = 0.; + m_projection(3, 2) = -1.; + m_projection(2, 3) = zNear; + m_projection(3, 3) = 0.; +} + +void SkinOpenGLWindow::paintGL() +{ + // Adjust the viewport to account for fractional scaling + qreal dpr = devicePixelRatio(); + if (dpr != 1.f) { + QSize scaledSize = size() * dpr; + glViewport(0, 0, scaledSize.width(), scaledSize.height()); + } + + // Clear color and depth buffer + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); + + // Enable depth buffer + glEnable(GL_DEPTH_TEST); + glDepthFunc(GL_LESS); + + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + + m_backgroundProgram->bind(); + renderBackground(); + m_backgroundProgram->release(); + + // Calculate model view transformation + QMatrix4x4 matrix; + float yawRad = qDegreesToRadians(m_yaw); + float pitchRad = qDegreesToRadians(m_pitch); + matrix.lookAt(QVector3D( // + m_distance * qCos(pitchRad) * qCos(yawRad), // + m_distance * qSin(pitchRad) - 8, // + m_distance * qCos(pitchRad) * qSin(yawRad)), + QVector3D(0, -8, 0), QVector3D(0, 1, 0)); + + // Set modelview-projection matrix + m_modelProgram->bind(); + m_modelProgram->setUniformValue("mvp_matrix", m_projection * matrix); + + m_scene->draw(m_modelProgram); + m_modelProgram->release(); + + // Redraw the first frame; this is necessary because the pixel ratio for Wayland fractional scaling is not negotiated properly on the + // first frame + if (m_isFirstFrame) { + m_isFirstFrame = false; + update(); + } +} + +void SkinOpenGLWindow::updateScene(SkinModel* skin) +{ + if (skin && m_scene) { + m_scene->setMode(skin->getModel() == SkinModel::SLIM); + m_scene->setSkin(skin->getTexture()); + update(); + } +} +void SkinOpenGLWindow::updateCape(const QImage& cape) +{ + if (m_scene) { + m_scene->setCapeVisible(!cape.isNull()); + m_scene->setCape(cape); + update(); + } +} + +QColor calculateContrastingColor(const QColor& color) +{ + auto luma = Rainbow::luma(color); + if (luma < 0.5) { + constexpr float contrast = 0.05f; + return Rainbow::lighten(color, contrast); + } else { + constexpr float contrast = 0.2f; + return Rainbow::darken(color, contrast); + } +} + +QImage generateChessboardImage(int width, int height, int tileSize, QColor baseColor) +{ + QImage image(width, height, QImage::Format_RGB888); + bool isDarkBase = Rainbow::luma(baseColor) < 0.5; + float contrast = isDarkBase ? 0.05 : 0.45; + auto contrastFunc = std::bind(isDarkBase ? Rainbow::lighten : Rainbow::darken, std::placeholders::_1, contrast, 1.0); + auto white = contrastFunc(baseColor); + auto black = contrastFunc(calculateContrastingColor(baseColor)); + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) { + bool isWhite = ((x / tileSize) + (y / tileSize)) % 2 == 0; + image.setPixelColor(x, y, isWhite ? white : black); + } + } + return image; +} + +void SkinOpenGLWindow::generateBackgroundTexture(int width, int height, int tileSize) +{ + m_backgroundTexture = new QOpenGLTexture(generateChessboardImage(width, height, tileSize, m_baseColor)); + m_backgroundTexture->setMinificationFilter(QOpenGLTexture::Nearest); + m_backgroundTexture->setMagnificationFilter(QOpenGLTexture::Nearest); +} + +void SkinOpenGLWindow::renderBackground() +{ + glDisable(GL_DEPTH_TEST); + glDepthMask(GL_FALSE); // Disable depth buffer writing + m_backgroundTexture->bind(); + m_backgroundProgram->setUniformValue("texture", 0); + m_background->draw(m_backgroundProgram); + m_backgroundTexture->release(); + glDepthMask(GL_TRUE); // Re-enable depth buffer writing + glEnable(GL_DEPTH_TEST); +} + +void SkinOpenGLWindow::wheelEvent(QWheelEvent* event) +{ + // Adjust distance based on scroll + int delta = event->angleDelta().y(); // Positive for scroll up, negative for scroll down + m_distance -= delta * 0.01f; // Adjust sensitivity factor + m_distance = qMax(16.f, m_distance); // Clamp distance + update(); // Trigger a repaint +} +void SkinOpenGLWindow::setElytraVisible(bool visible) +{ + if (m_scene) + m_scene->setElytraVisible(visible); +} + +bool SkinOpenGLWindow::hasOpenGL() +{ + if (!QProcessEnvironment::systemEnvironment() + .value(QStringLiteral("%1_DISABLE_GLVULKAN").arg(BuildConfig.LAUNCHER_ENVNAME)) + .isEmpty()) { + return false; + } + + QOpenGLContext ctx; + return ctx.create(); +} diff --git a/launcher/ui/dialogs/skins/draw/SkinOpenGLWindow.h b/launcher/ui/dialogs/skins/draw/SkinOpenGLWindow.h new file mode 100644 index 0000000..6ddc345 --- /dev/null +++ b/launcher/ui/dialogs/skins/draw/SkinOpenGLWindow.h @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2024 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include "minecraft/skins/SkinModel.h" +#include "ui/dialogs/skins/draw/BoxGeometry.h" +#include "ui/dialogs/skins/draw/Scene.h" + +class SkinProvider { + public: + virtual ~SkinProvider() = default; + virtual SkinModel* getSelectedSkin() = 0; + virtual QHash capes() = 0; +}; +class SkinOpenGLWindow : public QOpenGLWindow, protected QOpenGLFunctions { + Q_OBJECT + + public: + SkinOpenGLWindow(SkinProvider* parent, QColor color); + virtual ~SkinOpenGLWindow(); + + void updateScene(SkinModel* skin); + void updateCape(const QImage& cape); + void setElytraVisible(bool visible); + + static bool hasOpenGL(); + + protected: + void mousePressEvent(QMouseEvent* e) override; + void mouseReleaseEvent(QMouseEvent* e) override; + void mouseMoveEvent(QMouseEvent* event) override; + void wheelEvent(QWheelEvent* event) override; + + void initializeGL() override; + void resizeGL(int w, int h) override; + void paintGL() override; + + void initShaders(); + + void generateBackgroundTexture(int width, int height, int tileSize); + void renderBackground(); + + private: + QOpenGLShaderProgram* m_modelProgram; + QOpenGLShaderProgram* m_backgroundProgram; + opengl::Scene* m_scene = nullptr; + + QMatrix4x4 m_projection; + + QVector2D m_mousePosition; + + bool m_isMousePressed = false; + float m_distance = 48; + float m_yaw = 90; // Horizontal rotation angle + float m_pitch = 0; // Vertical rotation angle + + bool m_isFirstFrame = true; + + opengl::BoxGeometry* m_background = nullptr; + QOpenGLTexture* m_backgroundTexture = nullptr; + QColor m_baseColor; + SkinProvider* m_parent = nullptr; +}; diff --git a/launcher/ui/instanceview/AccessibleInstanceView.cpp b/launcher/ui/instanceview/AccessibleInstanceView.cpp new file mode 100644 index 0000000..5d2dfef --- /dev/null +++ b/launcher/ui/instanceview/AccessibleInstanceView.cpp @@ -0,0 +1,777 @@ +#include "AccessibleInstanceView.h" +#include "AccessibleInstanceView_p.h" +#include "InstanceView.h" + +#ifndef QT_NO_ACCESSIBILITY + +QAccessibleInterface* groupViewAccessibleFactory(const QString& classname, QObject* object) +{ + QAccessibleInterface* iface = 0; + if (!object || !object->isWidgetType()) + return iface; + + QWidget* widget = static_cast(object); + + if (classname == QLatin1String("InstanceView")) { + iface = new AccessibleInstanceView((InstanceView*)widget); + } + return iface; +} + +QAbstractItemView* AccessibleInstanceView::view() const +{ + return qobject_cast(object()); +} + +int AccessibleInstanceView::logicalIndex(const QModelIndex& index) const +{ + if (!view()->model() || !index.isValid()) + return -1; + return index.row() * (index.model()->columnCount()) + index.column(); +} + +AccessibleInstanceView::AccessibleInstanceView(QWidget* w) : QAccessibleObject(w) +{ + Q_ASSERT(view()); +} + +bool AccessibleInstanceView::isValid() const +{ + return view(); +} + +AccessibleInstanceView::~AccessibleInstanceView() +{ + for (QAccessible::Id id : childToId) { + QAccessible::deleteAccessibleInterface(id); + } +} + +QAccessibleInterface* AccessibleInstanceView::cellAt(int row, int column) const +{ + if (!view()->model()) { + return 0; + } + + QModelIndex index = view()->model()->index(row, column, view()->rootIndex()); + if (Q_UNLIKELY(!index.isValid())) { + qWarning() << "AccessibleInstanceView::cellAt: invalid index:" << index << "for" << view(); + return 0; + } + + return child(logicalIndex(index)); +} + +QAccessibleInterface* AccessibleInstanceView::caption() const +{ + return 0; +} + +QString AccessibleInstanceView::columnDescription(int column) const +{ + if (!view()->model()) + return QString(); + + return view()->model()->headerData(column, Qt::Horizontal).toString(); +} + +int AccessibleInstanceView::columnCount() const +{ + if (!view()->model()) + return 0; + return 1; +} + +int AccessibleInstanceView::rowCount() const +{ + if (!view()->model()) + return 0; + return view()->model()->rowCount(); +} + +int AccessibleInstanceView::selectedCellCount() const +{ + if (!view()->selectionModel()) + return 0; + return view()->selectionModel()->selectedIndexes().count(); +} + +int AccessibleInstanceView::selectedColumnCount() const +{ + if (!view()->selectionModel()) + return 0; + return view()->selectionModel()->selectedColumns().count(); +} + +int AccessibleInstanceView::selectedRowCount() const +{ + if (!view()->selectionModel()) + return 0; + return view()->selectionModel()->selectedRows().count(); +} + +QString AccessibleInstanceView::rowDescription(int row) const +{ + if (!view()->model()) + return QString(); + return view()->model()->headerData(row, Qt::Vertical).toString(); +} + +QList AccessibleInstanceView::selectedCells() const +{ + QList cells; + if (!view()->selectionModel()) + return cells; + const QModelIndexList selectedIndexes = view()->selectionModel()->selectedIndexes(); + cells.reserve(selectedIndexes.size()); + for (const QModelIndex& index : selectedIndexes) + cells.append(child(logicalIndex(index))); + return cells; +} + +QList AccessibleInstanceView::selectedColumns() const +{ + if (!view()->selectionModel()) { + return QList(); + } + + const QModelIndexList selectedColumns = view()->selectionModel()->selectedColumns(); + + QList columns; + columns.reserve(selectedColumns.size()); + for (const QModelIndex& index : selectedColumns) { + columns.append(index.column()); + } + + return columns; +} + +QList AccessibleInstanceView::selectedRows() const +{ + if (!view()->selectionModel()) { + return QList(); + } + + QList rows; + + const QModelIndexList selectedRows = view()->selectionModel()->selectedRows(); + + rows.reserve(selectedRows.size()); + for (const QModelIndex& index : selectedRows) { + rows.append(index.row()); + } + + return rows; +} + +QAccessibleInterface* AccessibleInstanceView::summary() const +{ + return 0; +} + +bool AccessibleInstanceView::isColumnSelected(int column) const +{ + if (!view()->selectionModel()) { + return false; + } + + return view()->selectionModel()->isColumnSelected(column, QModelIndex()); +} + +bool AccessibleInstanceView::isRowSelected(int row) const +{ + if (!view()->selectionModel()) { + return false; + } + + return view()->selectionModel()->isRowSelected(row, QModelIndex()); +} + +bool AccessibleInstanceView::selectRow(int row) +{ + if (!view()->model() || !view()->selectionModel()) { + return false; + } + QModelIndex index = view()->model()->index(row, 0, view()->rootIndex()); + + if (!index.isValid() || view()->selectionBehavior() == QAbstractItemView::SelectColumns) { + return false; + } + + switch (view()->selectionMode()) { + case QAbstractItemView::NoSelection: { + return false; + } + case QAbstractItemView::SingleSelection: { + if (view()->selectionBehavior() != QAbstractItemView::SelectRows && columnCount() > 1) + return false; + view()->clearSelection(); + break; + } + case QAbstractItemView::ContiguousSelection: { + if ((!row || !view()->selectionModel()->isRowSelected(row - 1, view()->rootIndex())) && + !view()->selectionModel()->isRowSelected(row + 1, view()->rootIndex())) { + view()->clearSelection(); + } + break; + } + default: { + break; + } + } + + view()->selectionModel()->select(index, QItemSelectionModel::Select | QItemSelectionModel::Rows); + return true; +} + +bool AccessibleInstanceView::selectColumn(int column) +{ + if (!view()->model() || !view()->selectionModel()) { + return false; + } + QModelIndex index = view()->model()->index(0, column, view()->rootIndex()); + + if (!index.isValid() || view()->selectionBehavior() == QAbstractItemView::SelectRows) { + return false; + } + + switch (view()->selectionMode()) { + case QAbstractItemView::NoSelection: { + return false; + } + case QAbstractItemView::SingleSelection: { + if (view()->selectionBehavior() != QAbstractItemView::SelectColumns && rowCount() > 1) { + return false; + } + } + /* fallthrough */ + case QAbstractItemView::ContiguousSelection: { + if ((!column || !view()->selectionModel()->isColumnSelected(column - 1, view()->rootIndex())) && + !view()->selectionModel()->isColumnSelected(column + 1, view()->rootIndex())) { + view()->clearSelection(); + } + break; + } + default: { + break; + } + } + + view()->selectionModel()->select(index, QItemSelectionModel::Select | QItemSelectionModel::Columns); + return true; +} + +bool AccessibleInstanceView::unselectRow(int row) +{ + if (!view()->model() || !view()->selectionModel()) { + return false; + } + + QModelIndex index = view()->model()->index(row, 0, view()->rootIndex()); + if (!index.isValid()) { + return false; + } + + QItemSelection selection(index, index); + auto selectionModel = view()->selectionModel(); + + switch (view()->selectionMode()) { + case QAbstractItemView::SingleSelection: + // no unselect + if (selectedRowCount() == 1) { + return false; + } + break; + case QAbstractItemView::ContiguousSelection: { + // no unselect + if (selectedRowCount() == 1) { + return false; + } + + if ((!row || selectionModel->isRowSelected(row - 1, view()->rootIndex())) && + selectionModel->isRowSelected(row + 1, view()->rootIndex())) { + // If there are rows selected both up the current row and down the current rown, + // the ones which are down the current row will be deselected + selection = QItemSelection(index, view()->model()->index(rowCount() - 1, 0, view()->rootIndex())); + } + } + default: { + break; + } + } + + selectionModel->select(selection, QItemSelectionModel::Deselect | QItemSelectionModel::Rows); + return true; +} + +bool AccessibleInstanceView::unselectColumn(int column) +{ + auto model = view()->model(); + if (!model || !view()->selectionModel()) { + return false; + } + + QModelIndex index = model->index(0, column, view()->rootIndex()); + if (!index.isValid()) { + return false; + } + + QItemSelection selection(index, index); + + switch (view()->selectionMode()) { + case QAbstractItemView::SingleSelection: { + // In SingleSelection and ContiguousSelection once an item + // is selected, there's no way for the user to unselect all items + if (selectedColumnCount() == 1) { + return false; + } + break; + } + case QAbstractItemView::ContiguousSelection: + if (selectedColumnCount() == 1) { + return false; + } + + if ((!column || view()->selectionModel()->isColumnSelected(column - 1, view()->rootIndex())) && + view()->selectionModel()->isColumnSelected(column + 1, view()->rootIndex())) { + // If there are columns selected both at the left of the current row and at the right + // of the current row, the ones which are at the right will be deselected + selection = QItemSelection(index, model->index(0, columnCount() - 1, view()->rootIndex())); + } + default: + break; + } + + view()->selectionModel()->select(selection, QItemSelectionModel::Deselect | QItemSelectionModel::Columns); + return true; +} + +QAccessible::Role AccessibleInstanceView::role() const +{ + return QAccessible::List; +} + +QAccessible::State AccessibleInstanceView::state() const +{ + return QAccessible::State(); +} + +QAccessibleInterface* AccessibleInstanceView::childAt(int x, int y) const +{ + QPoint viewportOffset = view()->viewport()->mapTo(view(), QPoint(0, 0)); + QPoint indexPosition = view()->mapFromGlobal(QPoint(x, y) - viewportOffset); + // FIXME: if indexPosition < 0 in one coordinate, return header + + QModelIndex index = view()->indexAt(indexPosition); + if (index.isValid()) { + return child(logicalIndex(index)); + } + return 0; +} + +int AccessibleInstanceView::childCount() const +{ + if (!view()->model()) { + return 0; + } + return (view()->model()->rowCount()) * (view()->model()->columnCount()); +} + +int AccessibleInstanceView::indexOfChild(const QAccessibleInterface* iface) const +{ + if (!view()->model()) + return -1; + QAccessibleInterface* parent = iface->parent(); + if (parent->object() != view()) + return -1; + + Q_ASSERT(iface->role() != QAccessible::TreeItem); // should be handled by tree class + if (iface->role() == QAccessible::Cell || iface->role() == QAccessible::ListItem) { + const AccessibleInstanceViewItem* cell = static_cast(iface); + return logicalIndex(cell->m_index); + } else if (iface->role() == QAccessible::Pane) { + return 0; // corner button + } else { + qWarning() << "AccessibleInstanceView::indexOfChild has a child with unknown role..." << iface->role() + << iface->text(QAccessible::Name); + } + // FIXME: we are in denial of our children. this should stop. + return -1; +} + +QString AccessibleInstanceView::text(QAccessible::Text t) const +{ + if (t == QAccessible::Description) + return view()->accessibleDescription(); + return view()->accessibleName(); +} + +QRect AccessibleInstanceView::rect() const +{ + if (!view()->isVisible()) + return QRect(); + QPoint pos = view()->mapToGlobal(QPoint(0, 0)); + return QRect(pos.x(), pos.y(), view()->width(), view()->height()); +} + +QAccessibleInterface* AccessibleInstanceView::parent() const +{ + if (view() && view()->parent()) { + if (qstrcmp("QComboBoxPrivateContainer", view()->parent()->metaObject()->className()) == 0) { + return QAccessible::queryAccessibleInterface(view()->parent()->parent()); + } + return QAccessible::queryAccessibleInterface(view()->parent()); + } + return 0; +} + +QAccessibleInterface* AccessibleInstanceView::child(int logicalIndex) const +{ + if (!view()->model()) + return 0; + + auto id = childToId.constFind(logicalIndex); + if (id != childToId.constEnd()) + return QAccessible::accessibleInterface(id.value()); + + int columns = view()->model()->columnCount(); + + int row = logicalIndex / columns; + int column = logicalIndex % columns; + + QAccessibleInterface* iface = 0; + + QModelIndex index = view()->model()->index(row, column, view()->rootIndex()); + if (Q_UNLIKELY(!index.isValid())) { + qWarning("AccessibleInstanceView::child: Invalid index at: %d %d", row, column); + return 0; + } + iface = new AccessibleInstanceViewItem(view(), index); + + QAccessible::registerAccessibleInterface(iface); + childToId.insert(logicalIndex, QAccessible::uniqueId(iface)); + return iface; +} + +void* AccessibleInstanceView::interface_cast(QAccessible::InterfaceType t) +{ + if (t == QAccessible::TableInterface) + return static_cast(this); + return 0; +} + +void AccessibleInstanceView::modelChange(QAccessibleTableModelChangeEvent* event) +{ + // if there is no cache yet, we don't update anything + if (childToId.isEmpty()) + return; + + switch (event->modelChangeType()) { + case QAccessibleTableModelChangeEvent::ModelReset: + for (QAccessible::Id id : childToId) + QAccessible::deleteAccessibleInterface(id); + childToId.clear(); + break; + + // rows are inserted: move every row after that + case QAccessibleTableModelChangeEvent::RowsInserted: + case QAccessibleTableModelChangeEvent::ColumnsInserted: { + ChildCache newCache; + ChildCache::ConstIterator iter = childToId.constBegin(); + + while (iter != childToId.constEnd()) { + QAccessible::Id id = iter.value(); + QAccessibleInterface* iface = QAccessible::accessibleInterface(id); + Q_ASSERT(iface); + if (indexOfChild(iface) >= 0) { + newCache.insert(indexOfChild(iface), id); + } else { + // ### This should really not happen, + // but it might if the view has a root index set. + // This needs to be fixed. + QAccessible::deleteAccessibleInterface(id); + } + ++iter; + } + childToId = newCache; + break; + } + + case QAccessibleTableModelChangeEvent::ColumnsRemoved: + case QAccessibleTableModelChangeEvent::RowsRemoved: { + ChildCache newCache; + ChildCache::ConstIterator iter = childToId.constBegin(); + while (iter != childToId.constEnd()) { + QAccessible::Id id = iter.value(); + QAccessibleInterface* iface = QAccessible::accessibleInterface(id); + Q_ASSERT(iface); + if (iface->role() == QAccessible::Cell || iface->role() == QAccessible::ListItem) { + Q_ASSERT(iface->tableCellInterface()); + AccessibleInstanceViewItem* cell = static_cast(iface->tableCellInterface()); + // Since it is a QPersistentModelIndex, we only need to check if it is valid + if (cell->m_index.isValid()) + newCache.insert(indexOfChild(cell), id); + else + QAccessible::deleteAccessibleInterface(id); + } + ++iter; + } + childToId = newCache; + break; + } + + case QAccessibleTableModelChangeEvent::DataChanged: + // nothing to do in this case + break; + } +} + +// TABLE CELL + +AccessibleInstanceViewItem::AccessibleInstanceViewItem(QAbstractItemView* view_, const QModelIndex& index_) : view(view_), m_index(index_) +{ + if (Q_UNLIKELY(!index_.isValid())) + qWarning() << "AccessibleInstanceViewItem::AccessibleInstanceViewItem with invalid index:" << index_; +} + +void* AccessibleInstanceViewItem::interface_cast(QAccessible::InterfaceType t) +{ + if (t == QAccessible::TableCellInterface) + return static_cast(this); + if (t == QAccessible::ActionInterface) + return static_cast(this); + return 0; +} + +int AccessibleInstanceViewItem::columnExtent() const +{ + return 1; +} +int AccessibleInstanceViewItem::rowExtent() const +{ + return 1; +} + +QList AccessibleInstanceViewItem::rowHeaderCells() const +{ + return {}; +} + +QList AccessibleInstanceViewItem::columnHeaderCells() const +{ + return {}; +} + +int AccessibleInstanceViewItem::columnIndex() const +{ + if (!isValid()) { + return -1; + } + + return m_index.column(); +} + +int AccessibleInstanceViewItem::rowIndex() const +{ + if (!isValid()) { + return -1; + } + + return m_index.row(); +} + +bool AccessibleInstanceViewItem::isSelected() const +{ + if (!isValid()) { + return false; + } + + return view->selectionModel()->isSelected(m_index); +} + +QStringList AccessibleInstanceViewItem::actionNames() const +{ + QStringList names; + names << toggleAction(); + return names; +} + +void AccessibleInstanceViewItem::doAction(const QString& actionName) +{ + if (actionName == toggleAction()) { + if (isSelected()) { + unselectCell(); + } else { + selectCell(); + } + } +} + +QStringList AccessibleInstanceViewItem::keyBindingsForAction(const QString&) const +{ + return QStringList(); +} + +void AccessibleInstanceViewItem::selectCell() +{ + if (!isValid()) { + return; + } + QAbstractItemView::SelectionMode selectionMode = view->selectionMode(); + if (selectionMode == QAbstractItemView::NoSelection) { + return; + } + + Q_ASSERT(table()); + QAccessibleTableInterface* cellTable = table()->tableInterface(); + + switch (view->selectionBehavior()) { + case QAbstractItemView::SelectItems: + break; + case QAbstractItemView::SelectColumns: + if (cellTable) + cellTable->selectColumn(m_index.column()); + return; + case QAbstractItemView::SelectRows: + if (cellTable) + cellTable->selectRow(m_index.row()); + return; + } + + if (selectionMode == QAbstractItemView::SingleSelection) { + view->clearSelection(); + } + + view->selectionModel()->select(m_index, QItemSelectionModel::Select); +} + +void AccessibleInstanceViewItem::unselectCell() +{ + if (!isValid()) + return; + QAbstractItemView::SelectionMode selectionMode = view->selectionMode(); + if (selectionMode == QAbstractItemView::NoSelection) + return; + + QAccessibleTableInterface* cellTable = table()->tableInterface(); + + switch (view->selectionBehavior()) { + case QAbstractItemView::SelectItems: + break; + case QAbstractItemView::SelectColumns: + if (cellTable) + cellTable->unselectColumn(m_index.column()); + return; + case QAbstractItemView::SelectRows: + if (cellTable) + cellTable->unselectRow(m_index.row()); + return; + } + + // If the mode is not MultiSelection or ExtendedSelection and only + // one cell is selected it cannot be unselected by the user + if ((selectionMode != QAbstractItemView::MultiSelection) && (selectionMode != QAbstractItemView::ExtendedSelection) && + (view->selectionModel()->selectedIndexes().count() <= 1)) + return; + + view->selectionModel()->select(m_index, QItemSelectionModel::Deselect); +} + +QAccessibleInterface* AccessibleInstanceViewItem::table() const +{ + return QAccessible::queryAccessibleInterface(view); +} + +QAccessible::Role AccessibleInstanceViewItem::role() const +{ + return QAccessible::ListItem; +} + +QAccessible::State AccessibleInstanceViewItem::state() const +{ + QAccessible::State st; + if (!isValid()) + return st; + + QRect globalRect = view->rect(); + globalRect.translate(view->mapToGlobal(QPoint(0, 0))); + if (!globalRect.intersects(rect())) + st.invisible = true; + + if (view->selectionModel()->isSelected(m_index)) + st.selected = true; + if (view->selectionModel()->currentIndex() == m_index) + st.focused = true; + if (m_index.model()->data(m_index, Qt::CheckStateRole).toInt() == Qt::Checked) + st.checked = true; + + Qt::ItemFlags flags = m_index.flags(); + if (flags & Qt::ItemIsSelectable) { + st.selectable = true; + st.focusable = true; + if (view->selectionMode() == QAbstractItemView::MultiSelection) + st.multiSelectable = true; + if (view->selectionMode() == QAbstractItemView::ExtendedSelection) + st.extSelectable = true; + } + return st; +} + +QRect AccessibleInstanceViewItem::rect() const +{ + QRect r; + if (!isValid()) + return r; + r = view->visualRect(m_index); + + if (!r.isNull()) { + r.translate(view->viewport()->mapTo(view, QPoint(0, 0))); + r.translate(view->mapToGlobal(QPoint(0, 0))); + } + return r; +} + +QString AccessibleInstanceViewItem::text(QAccessible::Text t) const +{ + QString value; + if (!isValid()) + return value; + QAbstractItemModel* model = view->model(); + switch (t) { + case QAccessible::Name: + value = model->data(m_index, Qt::AccessibleTextRole).toString(); + if (value.isEmpty()) + value = model->data(m_index, Qt::DisplayRole).toString(); + break; + case QAccessible::Description: + value = model->data(m_index, Qt::AccessibleDescriptionRole).toString(); + break; + default: + break; + } + return value; +} + +void AccessibleInstanceViewItem::setText(QAccessible::Text /*t*/, const QString& text) +{ + if (!isValid() || !(m_index.flags() & Qt::ItemIsEditable)) + return; + view->model()->setData(m_index, text); +} + +bool AccessibleInstanceViewItem::isValid() const +{ + return view && view->model() && m_index.isValid(); +} + +QAccessibleInterface* AccessibleInstanceViewItem::parent() const +{ + return QAccessible::queryAccessibleInterface(view); +} + +QAccessibleInterface* AccessibleInstanceViewItem::child(int) const +{ + return 0; +} + +#endif /* !QT_NO_ACCESSIBILITY */ diff --git a/launcher/ui/instanceview/AccessibleInstanceView.h b/launcher/ui/instanceview/AccessibleInstanceView.h new file mode 100644 index 0000000..1952280 --- /dev/null +++ b/launcher/ui/instanceview/AccessibleInstanceView.h @@ -0,0 +1,7 @@ +#pragma once + +#include +#include +class QAccessibleInterface; + +QAccessibleInterface* groupViewAccessibleFactory(const QString& classname, QObject* object); diff --git a/launcher/ui/instanceview/AccessibleInstanceView_p.h b/launcher/ui/instanceview/AccessibleInstanceView_p.h new file mode 100644 index 0000000..1a3a62d --- /dev/null +++ b/launcher/ui/instanceview/AccessibleInstanceView_p.h @@ -0,0 +1,116 @@ +#pragma once + +#include +#include +#include +#include "QtCore/qpointer.h" +#ifndef QT_NO_ACCESSIBILITY +#include "InstanceView.h" +// #include + +class QAccessibleTableCell; +class QAccessibleTableHeaderCell; + +class AccessibleInstanceView : public QAccessibleTableInterface, public QAccessibleObject { + public: + explicit AccessibleInstanceView(QWidget* w); + bool isValid() const override; + + QAccessible::Role role() const override; + QAccessible::State state() const override; + QString text(QAccessible::Text t) const override; + QRect rect() const override; + + QAccessibleInterface* childAt(int x, int y) const override; + int childCount() const override; + int indexOfChild(const QAccessibleInterface*) const override; + + QAccessibleInterface* parent() const override; + QAccessibleInterface* child(int index) const override; + + void* interface_cast(QAccessible::InterfaceType t) override; + + // table interface + QAccessibleInterface* cellAt(int row, int column) const override; + QAccessibleInterface* caption() const override; + QAccessibleInterface* summary() const override; + QString columnDescription(int column) const override; + QString rowDescription(int row) const override; + int columnCount() const override; + int rowCount() const override; + + // selection + int selectedCellCount() const override; + int selectedColumnCount() const override; + int selectedRowCount() const override; + QList selectedCells() const override; + QList selectedColumns() const override; + QList selectedRows() const override; + bool isColumnSelected(int column) const override; + bool isRowSelected(int row) const override; + bool selectRow(int row) override; + bool selectColumn(int column) override; + bool unselectRow(int row) override; + bool unselectColumn(int column) override; + + QAbstractItemView* view() const; + + void modelChange(QAccessibleTableModelChangeEvent* event) override; + + protected: + // maybe vector + using ChildCache = QHash; + mutable ChildCache childToId; + + virtual ~AccessibleInstanceView(); + + private: + inline int logicalIndex(const QModelIndex& index) const; +}; + +class AccessibleInstanceViewItem : public QAccessibleInterface, public QAccessibleTableCellInterface, public QAccessibleActionInterface { + public: + AccessibleInstanceViewItem(QAbstractItemView* view, const QModelIndex& m_index); + + void* interface_cast(QAccessible::InterfaceType t) override; + QObject* object() const override { return nullptr; } + QAccessible::Role role() const override; + QAccessible::State state() const override; + QRect rect() const override; + bool isValid() const override; + + QAccessibleInterface* childAt(int, int) const override { return nullptr; } + int childCount() const override { return 0; } + int indexOfChild(const QAccessibleInterface*) const override { return -1; } + + QString text(QAccessible::Text t) const override; + void setText(QAccessible::Text t, const QString& text) override; + + QAccessibleInterface* parent() const override; + QAccessibleInterface* child(int) const override; + + // cell interface + int columnExtent() const override; + QList columnHeaderCells() const override; + int columnIndex() const override; + int rowExtent() const override; + QList rowHeaderCells() const override; + int rowIndex() const override; + bool isSelected() const override; + QAccessibleInterface* table() const override; + + // action interface + QStringList actionNames() const override; + void doAction(const QString& actionName) override; + QStringList keyBindingsForAction(const QString& actionName) const override; + + private: + QPointer view; + QPersistentModelIndex m_index; + + void selectCell(); + void unselectCell(); + + friend class AccessibleInstanceView; +}; +#endif /* !QT_NO_ACCESSIBILITY */ diff --git a/launcher/ui/instanceview/InstanceDelegate.cpp b/launcher/ui/instanceview/InstanceDelegate.cpp new file mode 100644 index 0000000..c711580 --- /dev/null +++ b/launcher/ui/instanceview/InstanceDelegate.cpp @@ -0,0 +1,421 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "InstanceDelegate.h" +#include +#include +#include +#include +#include +#include + +#include +#include +#include "BaseInstance.h" +#include "InstanceList.h" +#include "InstanceView.h" + +// Origin: Qt +static void viewItemTextLayout(QTextLayout& textLayout, int lineWidth, qreal& height, qreal& widthUsed) +{ + height = 0; + widthUsed = 0; + textLayout.beginLayout(); + QString str = textLayout.text(); + while (true) { + QTextLine line = textLayout.createLine(); + if (!line.isValid()) + break; + if (line.textLength() == 0) + break; + line.setLineWidth(lineWidth); + line.setPosition(QPointF(0, height)); + height += line.height(); + widthUsed = qMax(widthUsed, line.naturalTextWidth()); + } + textLayout.endLayout(); +} + +ListViewDelegate::ListViewDelegate(QObject* parent) : QStyledItemDelegate(parent) {} + +void drawSelectionRect(QPainter* painter, const QStyleOptionViewItem& option, const QRect& rect) +{ + if ((option.state & QStyle::State_Selected)) + painter->fillRect(rect, option.palette.brush(QPalette::Highlight)); + else { + QColor backgroundColor = option.palette.color(QPalette::Window); + backgroundColor.setAlpha(160); + painter->fillRect(rect, QBrush(backgroundColor)); + } +} + +void drawFocusRect(QPainter* painter, const QStyleOptionViewItem& option, const QRect& rect) +{ + if (!(option.state & QStyle::State_HasFocus)) + return; + QStyleOptionFocusRect opt; + opt.direction = option.direction; + opt.fontMetrics = option.fontMetrics; + opt.palette = option.palette; + opt.rect = rect; + // opt.state = option.state | QStyle::State_KeyboardFocusChange | + // QStyle::State_Item; + auto col = option.state & QStyle::State_Selected ? QPalette::Highlight : QPalette::Base; + opt.backgroundColor = option.palette.color(col); + // Apparently some widget styles expect this hint to not be set + painter->setRenderHint(QPainter::Antialiasing, false); + + QStyle* style = option.widget ? option.widget->style() : QApplication::style(); + + style->drawPrimitive(QStyle::PE_FrameFocusRect, &opt, painter, option.widget); + + painter->setRenderHint(QPainter::Antialiasing); +} + +// TODO this can be made a lot prettier +void drawProgressOverlay(QPainter* painter, const QStyleOptionViewItem& option, const int value, const int maximum) +{ + if (maximum == 0 || value == maximum) { + return; + } + + painter->save(); + + qreal percent = (qreal)value / (qreal)maximum; + QColor color = option.palette.color(QPalette::Dark); + color.setAlphaF(0.70f); + painter->setBrush(color); + painter->setPen(QPen(QBrush(), 0)); + painter->drawPie(option.rect, 90 * 16, -percent * 360 * 16); + + painter->restore(); +} + +void drawBadges(QPainter* painter, const QStyleOptionViewItem& option, BaseInstance* instance, QIcon::Mode mode, QIcon::State state) +{ + QList pixmaps; + if (instance->isRunning()) { + pixmaps.append("status-running"); + } else if (instance->hasCrashed() || instance->hasVersionBroken()) { + pixmaps.append("status-bad"); + } + if (instance->hasUpdateAvailable()) { + pixmaps.append("checkupdate"); + } + + static const int itemSide = 24; + static const int spacing = 1; + const int itemsPerRow = qMax(1, qFloor(double(option.rect.width() + spacing) / double(itemSide + spacing))); + const int rows = qCeil((double)pixmaps.size() / (double)itemsPerRow); + QListIterator it(pixmaps); + painter->translate(option.rect.topLeft()); + for (int y = 0; y < rows; ++y) { + for (int x = 0; x < itemsPerRow; ++x) { + if (!it.hasNext()) { + return; + } + // FIXME: inject this. + auto icon = QIcon::fromTheme(it.next()); + // opt.icon.paint(painter, iconbox, Qt::AlignCenter, mode, state); + const QPixmap pixmap; + // itemSide + QRect badgeRect(option.rect.width() - x * itemSide + qMax(x - 1, 0) * spacing - itemSide, + y * itemSide + qMax(y - 1, 0) * spacing, itemSide, itemSide); + icon.paint(painter, badgeRect, Qt::AlignCenter, mode, state); + } + } + painter->translate(-option.rect.topLeft()); +} + +static QSize viewItemTextSize(const QStyleOptionViewItem* option) +{ + QStyle* style = option->widget ? option->widget->style() : QApplication::style(); + QTextOption textOption; + textOption.setWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere); + QTextLayout textLayout; + textLayout.setTextOption(textOption); + textLayout.setFont(option->font); + textLayout.setText(option->text); + const int textMargin = style->pixelMetric(QStyle::PM_FocusFrameHMargin, option, option->widget) + 1; + QRect bounds(0, 0, 100 - 2 * textMargin, 600); + qreal height = 0, widthUsed = 0; + viewItemTextLayout(textLayout, bounds.width(), height, widthUsed); + const QSize size(qCeil(widthUsed), qCeil(height)); + return QSize(size.width() + 2 * textMargin, size.height()); +} + +void ListViewDelegate::paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const +{ + QStyleOptionViewItem opt = option; + initStyleOption(&opt, index); + painter->save(); + painter->setClipRect(opt.rect); + + opt.features |= QStyleOptionViewItem::WrapText; + opt.text = index.data().toString(); + opt.textElideMode = Qt::ElideRight; + opt.displayAlignment = Qt::AlignTop | Qt::AlignHCenter; + + QStyle* style = opt.widget ? opt.widget->style() : QApplication::style(); + + // const int iconSize = style->pixelMetric(QStyle::PM_IconViewIconSize); + const int iconSize = 48; + QRect iconbox = opt.rect; + const int textMargin = style->pixelMetric(QStyle::PM_FocusFrameHMargin, 0, opt.widget) + 1; + QRect textRect = opt.rect; + QRect textHighlightRect = textRect; + // clip the decoration on top, remove width padding + textRect.adjust(textMargin, iconSize + textMargin + 5, -textMargin, 0); + + textHighlightRect.adjust(0, iconSize + 5, 0, 0); + + // draw background + { + // FIXME: unused + // QSize textSize = viewItemTextSize ( &opt ); + drawSelectionRect(painter, opt, textHighlightRect); + /* + QPalette::ColorGroup cg; + QStyleOptionViewItem opt2(opt); + + if ((opt.widget && opt.widget->isEnabled()) || (opt.state & QStyle::State_Enabled)) + { + if (!(opt.state & QStyle::State_Active)) + cg = QPalette::Inactive; + else + cg = QPalette::Normal; + } + else + { + cg = QPalette::Disabled; + } + */ + /* + opt2.palette.setCurrentColorGroup(cg); + + // fill in background, if any + + + if (opt.backgroundBrush.style() != Qt::NoBrush) + { + QPointF oldBO = painter->brushOrigin(); + painter->setBrushOrigin(opt.rect.topLeft()); + painter->fillRect(opt.rect, opt.backgroundBrush); + painter->setBrushOrigin(oldBO); + } + + drawSelectionRect(painter, opt2, textHighlightRect); + */ + + /* + if (opt.showDecorationSelected) + { + drawSelectionRect(painter, opt2, opt.rect); + drawFocusRect(painter, opt2, opt.rect); + // painter->fillRect ( opt.rect, opt.palette.brush ( cg, QPalette::Highlight ) ); + } + else + { + + // if ( opt.state & QStyle::State_Selected ) + { + // QRect textRect = subElementRect ( QStyle::SE_ItemViewItemText, opt, + // opt.widget ); + // painter->fillRect ( textHighlightRect, opt.palette.brush ( cg, + // QPalette::Highlight ) ); + drawSelectionRect(painter, opt2, textHighlightRect); + drawFocusRect(painter, opt2, textHighlightRect); + } + } + */ + } + + // icon mode and state, also used for badges + QIcon::Mode mode = QIcon::Normal; + if (!(opt.state & QStyle::State_Enabled)) + mode = QIcon::Disabled; + else if (opt.state & QStyle::State_Selected) + mode = QIcon::Selected; + QIcon::State state = opt.state & QStyle::State_Open ? QIcon::On : QIcon::Off; + + // draw the icon + { + iconbox.setHeight(iconSize); + opt.icon.paint(painter, iconbox, Qt::AlignCenter, mode, state); + } + // set the text colors + QPalette::ColorGroup cg = opt.state & QStyle::State_Enabled ? QPalette::Normal : QPalette::Disabled; + if (cg == QPalette::Normal && !(opt.state & QStyle::State_Active)) + cg = QPalette::Inactive; + if (opt.state & QStyle::State_Selected) { + painter->setPen(opt.palette.color(cg, QPalette::HighlightedText)); + } else { + painter->setPen(opt.palette.color(cg, QPalette::Text)); + } + + // draw the text + QTextOption textOption; + textOption.setWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere); + textOption.setTextDirection(opt.direction); + textOption.setAlignment(QStyle::visualAlignment(opt.direction, opt.displayAlignment)); + QTextLayout textLayout; + textLayout.setTextOption(textOption); + textLayout.setFont(opt.font); + textLayout.setText(opt.text); + + qreal width, height; + viewItemTextLayout(textLayout, textRect.width(), height, width); + + const int lineCount = textLayout.lineCount(); + + const QRect layoutRect = QStyle::alignedRect(opt.direction, opt.displayAlignment, QSize(textRect.width(), int(height)), textRect); + const QPointF position = layoutRect.topLeft(); + for (int i = 0; i < lineCount; ++i) { + const QTextLine line = textLayout.lineAt(i); + line.draw(painter, position); + } + + // FIXME: this really has no business of being here. Make generic. + auto instance = (BaseInstance*)index.data(InstanceList::InstancePointerRole).value(); + if (instance) { + drawBadges(painter, opt, instance, mode, state); + } + + drawProgressOverlay(painter, opt, index.data(InstanceViewRoles::ProgressValueRole).toInt(), + index.data(InstanceViewRoles::ProgressMaximumRole).toInt()); + + painter->restore(); +} + +QSize ListViewDelegate::sizeHint(const QStyleOptionViewItem& option, const QModelIndex& index) const +{ + QStyleOptionViewItem opt = option; + initStyleOption(&opt, index); + opt.features |= QStyleOptionViewItem::WrapText; + opt.text = index.data().toString(); + opt.textElideMode = Qt::ElideRight; + opt.displayAlignment = Qt::AlignTop | Qt::AlignHCenter; + + QStyle* style = opt.widget ? opt.widget->style() : QApplication::style(); + const int textMargin = style->pixelMetric(QStyle::PM_FocusFrameHMargin, &option, opt.widget) + 1; + int height = 48 + textMargin * 2 + 5; // TODO: turn constants into variables + QSize szz = viewItemTextSize(&opt); + height += szz.height(); + // FIXME: maybe the icon items could scale and keep proportions? + QSize sz(100, height); + return sz; +} + +class NoReturnTextEdit : public QTextEdit { + Q_OBJECT + public: + explicit NoReturnTextEdit(QWidget* parent) : QTextEdit(parent) + { + setTextInteractionFlags(Qt::TextEditorInteraction); + setHorizontalScrollBarPolicy(Qt::ScrollBarPolicy::ScrollBarAlwaysOff); + setVerticalScrollBarPolicy(Qt::ScrollBarPolicy::ScrollBarAlwaysOff); + } + bool event(QEvent* event) override + { + auto eventType = event->type(); + if (eventType == QEvent::KeyPress || eventType == QEvent::KeyRelease) { + QKeyEvent* keyEvent = static_cast(event); + auto key = keyEvent->key(); + if ((key == Qt::Key_Return || key == Qt::Key_Enter) && eventType == QEvent::KeyPress) { + emit editingDone(); + return true; + } + if (key == Qt::Key_Tab) { + return true; + } + } + return QTextEdit::event(event); + } + signals: + void editingDone(); +}; + +void ListViewDelegate::updateEditorGeometry(QWidget* editor, + const QStyleOptionViewItem& option, + [[maybe_unused]] const QModelIndex& index) const +{ + const int iconSize = 48; + QRect textRect = option.rect; + // QStyle *style = option.widget ? option.widget->style() : QApplication::style(); + textRect.adjust(0, iconSize + 5, 0, 0); + editor->setGeometry(textRect); +} + +void ListViewDelegate::setEditorData(QWidget* editor, const QModelIndex& index) const +{ + auto text = index.data(Qt::EditRole).toString(); + QTextEdit* realEditor = qobject_cast(editor); + realEditor->setAlignment(Qt::AlignHCenter | Qt::AlignTop); + realEditor->append(text); + realEditor->selectAll(); + realEditor->document()->clearUndoRedoStacks(); +} + +void ListViewDelegate::setModelData(QWidget* editor, QAbstractItemModel* model, const QModelIndex& index) const +{ + QTextEdit* realEditor = qobject_cast(editor); + QString text = realEditor->toPlainText(); + text.replace(QChar('\n'), QChar(' ')); + text = text.trimmed(); + // Prevent instance names longer than 128 chars + text.truncate(128); + if (text.size() != 0) { + emit textChanged(model->data(index).toString(), text); + model->setData(index, text); + } +} + +QWidget* ListViewDelegate::createEditor(QWidget* parent, + [[maybe_unused]] const QStyleOptionViewItem& option, + [[maybe_unused]] const QModelIndex& index) const +{ + auto editor = new NoReturnTextEdit(parent); + connect(editor, &NoReturnTextEdit::editingDone, this, &ListViewDelegate::editingDone); + return editor; +} + +void ListViewDelegate::editingDone() +{ + NoReturnTextEdit* editor = qobject_cast(sender()); + emit commitData(editor); + emit closeEditor(editor); +} + +#include "InstanceDelegate.moc" diff --git a/launcher/ui/instanceview/InstanceDelegate.h b/launcher/ui/instanceview/InstanceDelegate.h new file mode 100644 index 0000000..98ff9a2 --- /dev/null +++ b/launcher/ui/instanceview/InstanceDelegate.h @@ -0,0 +1,41 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +class ListViewDelegate : public QStyledItemDelegate { + Q_OBJECT + + public: + explicit ListViewDelegate(QObject* parent = 0); + virtual ~ListViewDelegate() {} + + void paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const override; + QSize sizeHint(const QStyleOptionViewItem& option, const QModelIndex& index) const override; + void updateEditorGeometry(QWidget* editor, const QStyleOptionViewItem& option, const QModelIndex& index) const override; + QWidget* createEditor(QWidget* parent, const QStyleOptionViewItem& option, const QModelIndex& index) const override; + + void setEditorData(QWidget* editor, const QModelIndex& index) const override; + void setModelData(QWidget* editor, QAbstractItemModel* model, const QModelIndex& index) const override; + + signals: + void textChanged(QString before, QString after) const; + + private slots: + void editingDone(); +}; diff --git a/launcher/ui/instanceview/InstanceProxyModel.cpp b/launcher/ui/instanceview/InstanceProxyModel.cpp new file mode 100644 index 0000000..ab6bef6 --- /dev/null +++ b/launcher/ui/instanceview/InstanceProxyModel.cpp @@ -0,0 +1,68 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "InstanceProxyModel.h" + +#include +#include +#include "Application.h" +#include "InstanceView.h" + +#include + +InstanceProxyModel::InstanceProxyModel(QObject* parent) : QSortFilterProxyModel(parent) +{ + m_naturalSort.setNumericMode(true); + m_naturalSort.setCaseSensitivity(Qt::CaseSensitivity::CaseInsensitive); + // FIXME: use loaded translation as source of locale instead, hook this up to translation changes + m_naturalSort.setLocale(QLocale::system()); +} + +QVariant InstanceProxyModel::data(const QModelIndex& index, int role) const +{ + QVariant data = QSortFilterProxyModel::data(index, role); + if (role == Qt::DecorationRole) { + return QVariant(APPLICATION->icons()->getIcon(data.toString())); + } + return data; +} + +bool InstanceProxyModel::lessThan(const QModelIndex& left, const QModelIndex& right) const +{ + const QString leftCategory = left.data(InstanceViewRoles::GroupRole).toString(); + const QString rightCategory = right.data(InstanceViewRoles::GroupRole).toString(); + if (leftCategory == rightCategory) { + return subSortLessThan(left, right); + } else { + // FIXME: real group sorting happens in InstanceView::updateGeometries(), see LocaleString + auto result = leftCategory.localeAwareCompare(rightCategory); + if (result == 0) { + return subSortLessThan(left, right); + } + return result < 0; + } +} + +bool InstanceProxyModel::subSortLessThan(const QModelIndex& left, const QModelIndex& right) const +{ + BaseInstance* pdataLeft = static_cast(left.internalPointer()); + BaseInstance* pdataRight = static_cast(right.internalPointer()); + QString sortMode = APPLICATION->settings()->get("InstSortMode").toString(); + if (sortMode == "LastLaunch") { + return pdataLeft->lastLaunch() > pdataRight->lastLaunch(); + } else { + return m_naturalSort.compare(pdataLeft->name(), pdataRight->name()) < 0; + } +} diff --git a/launcher/ui/instanceview/InstanceProxyModel.h b/launcher/ui/instanceview/InstanceProxyModel.h new file mode 100644 index 0000000..13fec1b --- /dev/null +++ b/launcher/ui/instanceview/InstanceProxyModel.h @@ -0,0 +1,34 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +class InstanceProxyModel : public QSortFilterProxyModel { + Q_OBJECT + + public: + InstanceProxyModel(QObject* parent = 0); + + protected: + QVariant data(const QModelIndex& index, int role) const override; + bool lessThan(const QModelIndex& left, const QModelIndex& right) const override; + bool subSortLessThan(const QModelIndex& left, const QModelIndex& right) const; + + private: + QCollator m_naturalSort; +}; diff --git a/launcher/ui/instanceview/InstanceView.cpp b/launcher/ui/instanceview/InstanceView.cpp new file mode 100644 index 0000000..aff61cb --- /dev/null +++ b/launcher/ui/instanceview/InstanceView.cpp @@ -0,0 +1,1012 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "InstanceView.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "VisualGroup.h" +#include "ui/themes/CatPainter.h" +#include "ui/themes/ThemeManager.h" + +#include +#include + +template +bool listsIntersect(const QList& l1, const QList t2) +{ + for (auto& item : l1) { + if (t2.contains(item)) { + return true; + } + } + return false; +} + +InstanceView::InstanceView(QWidget* parent) : QAbstractItemView(parent) +{ + setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + setAcceptDrops(true); + setAutoScroll(true); + setPaintCat(APPLICATION->settings()->get("TheCat").toBool()); + connect(verticalScrollBar(), &QScrollBar::valueChanged, viewport(), QOverload<>::of(&QWidget::update)); + connect(horizontalScrollBar(), &QScrollBar::valueChanged, viewport(), QOverload<>::of(&QWidget::update)); +} + +InstanceView::~InstanceView() +{ + qDeleteAll(m_groups); + m_groups.clear(); + if (m_cat) { + m_cat->deleteLater(); + } +} + +void InstanceView::setModel(QAbstractItemModel* model) +{ + QAbstractItemView::setModel(model); + connect(model, &QAbstractItemModel::modelReset, this, &InstanceView::modelReset); + connect(model, &QAbstractItemModel::rowsRemoved, this, &InstanceView::rowsRemoved); +} + +void InstanceView::dataChanged([[maybe_unused]] const QModelIndex& topLeft, + [[maybe_unused]] const QModelIndex& bottomRight, + [[maybe_unused]] const QList& roles) +{ + scheduleDelayedItemsLayout(); +} +void InstanceView::rowsInserted([[maybe_unused]] const QModelIndex& parent, [[maybe_unused]] int start, [[maybe_unused]] int end) +{ + scheduleDelayedItemsLayout(); +} + +void InstanceView::rowsAboutToBeRemoved([[maybe_unused]] const QModelIndex& parent, [[maybe_unused]] int start, [[maybe_unused]] int end) +{ + scheduleDelayedItemsLayout(); +} + +void InstanceView::modelReset() +{ + scheduleDelayedItemsLayout(); +} + +void InstanceView::rowsRemoved() +{ + scheduleDelayedItemsLayout(); +} + +void InstanceView::currentChanged(const QModelIndex& current, const QModelIndex& previous) +{ + QAbstractItemView::currentChanged(current, previous); + // TODO: for accessibility support, implement+register a factory, steal QAccessibleTable from Qt and return an instance of it for + // InstanceView. +#ifndef QT_NO_ACCESSIBILITY + if (QAccessible::isActive() && current.isValid()) { + QAccessibleEvent event(this, QAccessible::Focus); + event.setChild(current.row()); + QAccessible::updateAccessibility(&event); + } +#endif /* !QT_NO_ACCESSIBILITY */ +} + +class LocaleString : public QString { + public: + LocaleString(const char* s) : QString(s) {} + LocaleString(const QString& s) : QString(s) {} +}; + +inline bool operator<(const LocaleString& lhs, const LocaleString& rhs) +{ + return (QString::localeAwareCompare(lhs, rhs) < 0); +} + +void InstanceView::updateScrollbar() +{ + int previousScroll = verticalScrollBar()->value(); + if (m_groups.isEmpty()) { + verticalScrollBar()->setRange(0, 0); + } else { + int totalHeight = 0; + // top margin + totalHeight += m_categoryMargin; + int itemScroll = 0; + for (auto category : m_groups) { + category->m_verticalPosition = totalHeight; + totalHeight += category->totalHeight() + m_categoryMargin; + if (!itemScroll && category->totalHeight() != 0) { + itemScroll = category->contentHeight() / category->numRows(); + } + } + // do not divide by zero + if (itemScroll == 0) + itemScroll = 64; + + totalHeight += m_bottomMargin; + verticalScrollBar()->setSingleStep(itemScroll); + const int rowsPerPage = qMax(viewport()->height() / itemScroll, 1); + verticalScrollBar()->setPageStep(rowsPerPage * itemScroll); + + verticalScrollBar()->setRange(0, totalHeight - height()); + } + + verticalScrollBar()->setValue(qMin(previousScroll, verticalScrollBar()->maximum())); +} + +void InstanceView::updateGeometries() +{ + m_geometryCache.clear(); + + QMap cats; + + for (int i = 0; i < model()->rowCount(); ++i) { + const QString groupName = model()->index(i, 0).data(InstanceViewRoles::GroupRole).toString(); + if (!cats.contains(groupName)) { + VisualGroup* old = this->category(groupName); + if (old) { + auto cat = new VisualGroup(old); + cats.insert(groupName, cat); + cat->update(); + } else { + auto cat = new VisualGroup(groupName, this); + if (m_fVisibility) { + cat->collapsed = m_fVisibility(groupName); + } + cats.insert(groupName, cat); + cat->update(); + } + } + } + + qDeleteAll(m_groups); + m_groups = cats.values(); + updateScrollbar(); + viewport()->update(); +} + +bool InstanceView::isIndexHidden(const QModelIndex& index) const +{ + VisualGroup* cat = category(index); + if (cat) { + return cat->collapsed; + } else { + return false; + } +} + +VisualGroup* InstanceView::category(const QModelIndex& index) const +{ + return category(index.data(InstanceViewRoles::GroupRole).toString()); +} + +VisualGroup* InstanceView::category(const QString& cat) const +{ + for (auto group : m_groups) { + if (group->text == cat) { + return group; + } + } + return nullptr; +} + +VisualGroup* InstanceView::categoryAt(const QPoint& pos, VisualGroup::HitResults& result) const +{ + for (auto group : m_groups) { + result = group->hitScan(pos); + if (result != VisualGroup::NoHit) { + return group; + } + } + result = VisualGroup::NoHit; + return nullptr; +} + +QString InstanceView::groupNameAt(const QPoint& point) +{ + executeDelayedItemsLayout(); + + VisualGroup::HitResults hitResult; + auto group = categoryAt(point + offset(), hitResult); + if (group && (hitResult & (VisualGroup::HeaderHit | VisualGroup::BodyHit))) { + return group->text; + } + return QString(); +} + +int InstanceView::calculateItemsPerRow() const +{ + return qFloor((qreal)(contentWidth()) / (qreal)(itemWidth() + m_spacing)); +} + +int InstanceView::contentWidth() const +{ + return width() - m_leftMargin - m_rightMargin; +} + +int InstanceView::itemWidth() const +{ + return m_itemWidth; +} + +void InstanceView::mousePressEvent(QMouseEvent* event) +{ + executeDelayedItemsLayout(); + + QPoint visualPos = event->pos(); + QPoint geometryPos = event->pos() + offset(); + + QPersistentModelIndex index = indexAt(visualPos); + + m_pressedIndex = index; + m_pressedAlreadySelected = selectionModel()->isSelected(m_pressedIndex); + m_pressedPosition = geometryPos; + + if (event->button() == Qt::LeftButton) { + VisualGroup::HitResults hitResult; + m_pressedCategory = categoryAt(geometryPos, hitResult); + if (m_pressedCategory && hitResult & VisualGroup::CheckboxHit) { + setState(m_pressedCategory->collapsed ? ExpandingState : CollapsingState); + event->accept(); + return; + } + } + + if (index.isValid() && (index.flags() & Qt::ItemIsEnabled)) { + if (index != currentIndex()) { + // FIXME: better! + m_currentCursorColumn = -1; + } + // we disable scrollTo for mouse press so the item doesn't change position + // when the user is interacting with it (ie. clicking on it) + bool autoScroll = hasAutoScroll(); + setAutoScroll(false); + selectionModel()->setCurrentIndex(index, QItemSelectionModel::NoUpdate); + + setAutoScroll(autoScroll); + QRect rect(visualPos, visualPos); + setSelection(rect, QItemSelectionModel::ClearAndSelect); + + // signal handlers may change the model + emit pressed(index); + } else { + // Forces a finalize() even if mouse is pressed, but not on a item + selectionModel()->select(QModelIndex(), QItemSelectionModel::Select); + } +} + +void InstanceView::mouseMoveEvent(QMouseEvent* event) +{ + executeDelayedItemsLayout(); + + QPoint topLeft; + QPoint visualPos = event->pos(); + QPoint geometryPos = event->pos() + offset(); + + if (state() == ExpandingState || state() == CollapsingState) { + return; + } + + if (state() == DraggingState) { + topLeft = m_pressedPosition - offset(); + if ((topLeft - event->pos()).manhattanLength() > QApplication::startDragDistance()) { + m_pressedIndex = QModelIndex(); + startDrag(model()->supportedDragActions()); + setState(NoState); + stopAutoScroll(); + } + return; + } + + if (selectionMode() != SingleSelection) { + topLeft = m_pressedPosition - offset(); + } else { + topLeft = geometryPos; + } + + if (m_pressedIndex.isValid() && (state() != DragSelectingState) && (event->buttons() != Qt::NoButton) && !selectedIndexes().isEmpty()) { + setState(DraggingState); + return; + } + + if ((event->buttons() & Qt::LeftButton) && selectionModel()) { + setState(DragSelectingState); + + setSelection(QRect(visualPos, visualPos), QItemSelectionModel::ClearAndSelect); + QModelIndex index = indexAt(visualPos); + + // set at the end because it might scroll the view + if (index.isValid() && (index != selectionModel()->currentIndex()) && (index.flags() & Qt::ItemIsEnabled)) { + selectionModel()->setCurrentIndex(index, QItemSelectionModel::NoUpdate); + } + } +} + +void InstanceView::mouseReleaseEvent(QMouseEvent* event) +{ + executeDelayedItemsLayout(); + + QPoint visualPos = event->pos(); + QPoint geometryPos = event->pos() + offset(); + QPersistentModelIndex index = indexAt(visualPos); + + VisualGroup::HitResults hitResult; + + if (event->button() == Qt::LeftButton && m_pressedCategory != nullptr && m_pressedCategory == categoryAt(geometryPos, hitResult)) { + if (state() == ExpandingState) { + m_pressedCategory->collapsed = false; + emit groupStateChanged(m_pressedCategory->text, false); + + updateGeometries(); + viewport()->update(); + event->accept(); + m_pressedCategory = nullptr; + setState(NoState); + return; + } else if (state() == CollapsingState) { + m_pressedCategory->collapsed = true; + emit groupStateChanged(m_pressedCategory->text, true); + + updateGeometries(); + viewport()->update(); + event->accept(); + m_pressedCategory = nullptr; + setState(NoState); + return; + } + } + + m_ctrlDragSelectionFlag = QItemSelectionModel::NoUpdate; + + setState(NoState); + + if (index == m_pressedIndex && index.isValid()) { + if (event->button() == Qt::LeftButton) { + emit clicked(index); + } + QStyleOptionViewItem option; + initViewItemOption(&option); + if (m_pressedAlreadySelected) { + option.state |= QStyle::State_Selected; + } + if ((model()->flags(index) & Qt::ItemIsEnabled) && + style()->styleHint(QStyle::SH_ItemView_ActivateItemOnSingleClick, &option, this)) { + emit activated(index); + } + } +} + +void InstanceView::mouseDoubleClickEvent(QMouseEvent* event) +{ + executeDelayedItemsLayout(); + + QModelIndex index = indexAt(event->pos()); + if (!index.isValid() || !(index.flags() & Qt::ItemIsEnabled) || (m_pressedIndex != index)) { + QMouseEvent me(QEvent::MouseButtonPress, event->position(), event->scenePosition(), event->globalPosition(), event->button(), + event->buttons(), event->modifiers()); + mousePressEvent(&me); + return; + } + // signal handlers may change the model + QPersistentModelIndex persistent = index; + emit doubleClicked(persistent); + + QStyleOptionViewItem option; + initViewItemOption(&option); + if ((model()->flags(index) & Qt::ItemIsEnabled) && !style()->styleHint(QStyle::SH_ItemView_ActivateItemOnSingleClick, &option, this)) { + emit activated(index); + } +} + +void InstanceView::setPaintCat(bool visible) +{ + if (m_cat) { + disconnect(m_cat, &CatPainter::updateFrame, this, nullptr); + delete m_cat; + m_cat = nullptr; + } + if (visible) { + m_cat = new CatPainter(APPLICATION->themeManager()->getCatPack(), this); + connect(m_cat, &CatPainter::updateFrame, this, [this] { viewport()->update(); }); + } +} + +void InstanceView::paintEvent([[maybe_unused]] QPaintEvent* event) +{ + executeDelayedItemsLayout(); + + QPainter painter(this->viewport()); + + if (m_cat) { + m_cat->paint(&painter, this->viewport()->rect()); + } + + QStyleOptionViewItem option; + initViewItemOption(&option); + option.widget = this; + + if (model()->rowCount() == 0) { + painter.save(); + QString emptyString = tr("Welcome!") + "\n" + tr("Click \"Add Instance\" to get started."); + + // calculate the rect for the overlay + painter.setRenderHint(QPainter::Antialiasing, true); + QFont font("sans", 20); + font.setBold(true); + + QRect bounds = viewport()->geometry(); + bounds.moveTop(0); + auto innerBounds = bounds; + innerBounds.adjust(10, 10, -10, -10); + + QColor background = QApplication::palette().color(QPalette::WindowText); + QColor foreground = QApplication::palette().color(QPalette::Base); + foreground.setAlpha(190); + painter.setFont(font); + auto fontMetrics = painter.fontMetrics(); + auto textRect = fontMetrics.boundingRect(innerBounds, Qt::AlignHCenter | Qt::TextWordWrap, emptyString); + textRect.moveCenter(bounds.center()); + + auto wrapRect = textRect; + wrapRect.adjust(-10, -10, 10, 10); + + // check if we are allowed to draw in our area + if (!event->rect().intersects(wrapRect)) { + return; + } + + painter.setBrush(QBrush(background)); + painter.setPen(foreground); + painter.drawRoundedRect(wrapRect, 5.0, 5.0); + + painter.setPen(foreground); + painter.setFont(font); + painter.drawText(textRect, Qt::AlignHCenter | Qt::TextWordWrap, emptyString); + + painter.restore(); + return; + } + + int wpWidth = viewport()->width(); + option.rect.setWidth(wpWidth); + for (int i = 0; i < m_groups.size(); ++i) { + VisualGroup* category = m_groups.at(i); + int y = category->verticalPosition(); + y -= verticalOffset(); + QRect backup = option.rect; + int height = category->totalHeight(); + option.rect.setTop(y); + option.rect.setHeight(height); + option.rect.setLeft(m_leftMargin); + option.rect.setRight(wpWidth - m_rightMargin); + category->drawHeader(&painter, option); + y += category->totalHeight() + m_categoryMargin; + option.rect = backup; + } + + for (int i = 0; i < model()->rowCount(); ++i) { + const QModelIndex index = model()->index(i, 0); + if (isIndexHidden(index)) { + continue; + } + Qt::ItemFlags flags = index.flags(); + option.rect = visualRect(index); + option.features |= QStyleOptionViewItem::WrapText; + if (flags & Qt::ItemIsSelectable && selectionModel()->isSelected(index)) { + option.state |= selectionModel()->isSelected(index) ? QStyle::State_Selected : QStyle::State_None; + } else { + option.state &= ~QStyle::State_Selected; + } + option.state |= (index == currentIndex()) ? QStyle::State_HasFocus : QStyle::State_None; + if (!(flags & Qt::ItemIsEnabled)) { + option.state &= ~QStyle::State_Enabled; + } + itemDelegate()->paint(&painter, option, index); + } + + /* + * Drop indicators for manual reordering... + */ +#if 0 + if (!m_lastDragPosition.isNull()) + { + std::pair pair = rowDropPos(m_lastDragPosition); + VisualGroup *category = pair.first; + VisualGroup::HitResults row = pair.second; + if (category) + { + int internalRow = row - category->firstItemIndex; + QLine line; + if (internalRow >= category->numItems()) + { + QRect toTheRightOfRect = visualRect(category->lastItem()); + line = QLine(toTheRightOfRect.topRight(), toTheRightOfRect.bottomRight()); + } + else + { + QRect toTheLeftOfRect = visualRect(model()->index(row, 0)); + line = QLine(toTheLeftOfRect.topLeft(), toTheLeftOfRect.bottomLeft()); + } + painter.save(); + painter.setPen(QPen(Qt::black, 3)); + painter.drawLine(line); + painter.restore(); + } + } +#endif +} + +void InstanceView::resizeEvent([[maybe_unused]] QResizeEvent* event) +{ + int newItemsPerRow = calculateItemsPerRow(); + if (newItemsPerRow != m_currentItemsPerRow) { + m_currentCursorColumn = -1; + m_currentItemsPerRow = newItemsPerRow; + updateGeometries(); + } else { + updateScrollbar(); + } +} + +void InstanceView::dragEnterEvent(QDragEnterEvent* event) +{ + executeDelayedItemsLayout(); + + if (!isDragEventAccepted(event)) { + return; + } + m_lastDragPosition = event->position().toPoint() + offset(); + viewport()->update(); + event->accept(); +} + +void InstanceView::dragMoveEvent(QDragMoveEvent* event) +{ + executeDelayedItemsLayout(); + + if (!isDragEventAccepted(event)) { + return; + } + m_lastDragPosition = event->position().toPoint() + offset(); + viewport()->update(); + event->accept(); +} + +void InstanceView::dragLeaveEvent([[maybe_unused]] QDragLeaveEvent* event) +{ + executeDelayedItemsLayout(); + + m_lastDragPosition = QPoint(); + viewport()->update(); +} + +void InstanceView::dropEvent(QDropEvent* event) +{ + executeDelayedItemsLayout(); + + m_lastDragPosition = QPoint(); + + stopAutoScroll(); + setState(NoState); + + auto mimedata = event->mimeData(); + + if (event->source() == this) { + if (event->possibleActions() & Qt::MoveAction) { + std::pair dropPos = rowDropPos(event->position().toPoint()); + const VisualGroup* group = dropPos.first; + auto hitResult = dropPos.second; + + if (hitResult == VisualGroup::HitResult::NoHit) { + viewport()->update(); + return; + } + auto instanceId = QString::fromUtf8(mimedata->data("application/x-instanceid")); + auto instanceList = APPLICATION->instances(); + instanceList->setInstanceGroup(instanceId, group->text); + event->setDropAction(Qt::MoveAction); + event->accept(); + + updateGeometries(); + viewport()->update(); + } + return; + } + + // check if the action is supported + if (!mimedata) { + return; + } + + // files dropped from outside? + if (mimedata->hasUrls()) { + auto urls = mimedata->urls(); + event->accept(); + emit droppedURLs(urls); + } +} + +void InstanceView::startDrag(Qt::DropActions supportedActions) +{ + executeDelayedItemsLayout(); + + QModelIndexList indexes = selectionModel()->selectedIndexes(); + if (indexes.count() == 0) + return; + + QMimeData* mimeData = model()->mimeData(indexes); + if (!mimeData) { + return; + } + QRect rect; + QPixmap pixmap = renderToPixmap(indexes, &rect); + QDrag* drag = new QDrag(this); + drag->setPixmap(pixmap); + drag->setMimeData(mimeData); + drag->setHotSpot(m_pressedPosition - rect.topLeft()); + Qt::DropAction defaultDropAction = Qt::IgnoreAction; + if (this->defaultDropAction() != Qt::IgnoreAction && (supportedActions & this->defaultDropAction())) { + defaultDropAction = this->defaultDropAction(); + } + /*auto action = */ + drag->exec(supportedActions, defaultDropAction); +} + +QRect InstanceView::visualRect(const QModelIndex& index) const +{ + const_cast(this)->executeDelayedItemsLayout(); + + return geometryRect(index).translated(-offset()); +} + +QRect InstanceView::geometryRect(const QModelIndex& index) const +{ + const_cast(this)->executeDelayedItemsLayout(); + + if (!index.isValid() || isIndexHidden(index) || index.column() > 0) { + return QRect(); + } + + int row = index.row(); + if (m_geometryCache.contains(row)) { + return *m_geometryCache[row]; + } + + const VisualGroup* cat = category(index); + QPair pos = cat->positionOf(index); + int x = pos.first; + // int y = pos.second; + + QStyleOptionViewItem option; + initViewItemOption(&option); + + QRect out; + out.setTop(cat->verticalPosition() + cat->headerHeight() + 5 + cat->rowTopOf(index)); + out.setLeft(m_spacing + x * (itemWidth() + m_spacing)); + out.setSize(itemDelegate()->sizeHint(option, index)); + m_geometryCache.insert(row, new QRect(out)); + return out; +} + +QModelIndex InstanceView::indexAt(const QPoint& point) const +{ + const_cast(this)->executeDelayedItemsLayout(); + + for (int i = 0; i < model()->rowCount(); ++i) { + QModelIndex index = model()->index(i, 0); + if (visualRect(index).contains(point)) { + return index; + } + } + return QModelIndex(); +} + +void InstanceView::setSelection(const QRect& rect, const QItemSelectionModel::SelectionFlags commands) +{ + executeDelayedItemsLayout(); + + for (int i = 0; i < model()->rowCount(); ++i) { + QModelIndex index = model()->index(i, 0); + QRect itemRect = visualRect(index); + if (itemRect.intersects(rect)) { + selectionModel()->select(index, commands); + update(itemRect.translated(-offset())); + } + } +} + +QPixmap InstanceView::renderToPixmap(const QModelIndexList& indices, QRect* r) const +{ + Q_ASSERT(r); + auto paintPairs = draggablePaintPairs(indices, r); + if (paintPairs.isEmpty()) { + return QPixmap(); + } + QPixmap pixmap(r->size()); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + QStyleOptionViewItem option; + initViewItemOption(&option); + option.state |= QStyle::State_Selected; + for (int j = 0; j < paintPairs.count(); ++j) { + option.rect = paintPairs.at(j).first.translated(-r->topLeft()); + const QModelIndex& current = paintPairs.at(j).second; + itemDelegate()->paint(&painter, option, current); + } + return pixmap; +} + +QList> InstanceView::draggablePaintPairs(const QModelIndexList& indices, QRect* r) const +{ + Q_ASSERT(r); + QRect& rect = *r; + QList> ret; + for (int i = 0; i < indices.count(); ++i) { + const QModelIndex& index = indices.at(i); + const QRect current = geometryRect(index); + ret += std::make_pair(current, index); + rect |= current; + } + return ret; +} + +bool InstanceView::isDragEventAccepted([[maybe_unused]] QDropEvent* event) +{ + return true; +} + +std::pair InstanceView::rowDropPos(const QPoint& pos) +{ + VisualGroup::HitResults hitResult; + auto group = categoryAt(pos + offset(), hitResult); + return std::make_pair(group, hitResult); +} + +QPoint InstanceView::offset() const +{ + return QPoint(horizontalOffset(), verticalOffset()); +} + +QRegion InstanceView::visualRegionForSelection(const QItemSelection& selection) const +{ + QRegion region; + for (auto& range : selection) { + int start_row = range.top(); + int end_row = range.bottom(); + for (int row = start_row; row <= end_row; ++row) { + int start_column = range.left(); + int end_column = range.right(); + for (int column = start_column; column <= end_column; ++column) { + QModelIndex index = model()->index(row, column, rootIndex()); + region += visualRect(index); // OK + } + } + } + return region; +} + +QModelIndex InstanceView::moveCursor(QAbstractItemView::CursorAction cursorAction, Qt::KeyboardModifiers modifiers) +{ + auto current = currentIndex(); + if (!current.isValid()) { + return current; + } + auto cat = category(current); + int group_index = m_groups.indexOf(cat); + if (group_index < 0) + return current; + + QPair pos = cat->positionOf(current); + int column = pos.first; + int row = pos.second; + if (m_currentCursorColumn < 0) { + m_currentCursorColumn = column; + } + // Handle different movement actions. + switch (cursorAction) { + case MoveUp: { + if (row == 0) { + int prevGroupIndex = group_index - 1; + while (prevGroupIndex >= 0) { + auto prevGroup = m_groups[prevGroupIndex]; + if (prevGroup->collapsed) { + prevGroupIndex--; + continue; + } + int newRow = prevGroup->numRows() - 1; + int newRowSize = prevGroup->rows[newRow].size(); + int newColumn = m_currentCursorColumn; + if (m_currentCursorColumn >= newRowSize) { + newColumn = newRowSize - 1; + } + return prevGroup->rows[newRow][newColumn]; + } + } else { + int newRow = row - 1; + int newRowSize = cat->rows[newRow].size(); + int newColumn = m_currentCursorColumn; + if (m_currentCursorColumn >= newRowSize) { + newColumn = newRowSize - 1; + } + return cat->rows[newRow][newColumn]; + } + return current; + } + case MoveDown: { + if (row == cat->rows.size() - 1) { + int nextGroupIndex = group_index + 1; + while (nextGroupIndex < m_groups.size()) { + auto nextGroup = m_groups[nextGroupIndex]; + if (nextGroup->collapsed) { + nextGroupIndex++; + continue; + } + int newRowSize = nextGroup->rows[0].size(); + int newColumn = m_currentCursorColumn; + if (m_currentCursorColumn >= newRowSize) { + newColumn = newRowSize - 1; + } + return nextGroup->rows[0][newColumn]; + } + } else { + int newRow = row + 1; + int newRowSize = cat->rows[newRow].size(); + int newColumn = m_currentCursorColumn; + if (m_currentCursorColumn >= newRowSize) { + newColumn = newRowSize - 1; + } + return cat->rows[newRow][newColumn]; + } + return current; + } + case MoveLeft: { + if (column > 0) { + m_currentCursorColumn = column - 1; + return cat->rows[row][column - 1]; + } else if (row > 0) { + row -= 1; + int newRowSize = cat->rows[row].size(); + m_currentCursorColumn = newRowSize - 1; + return cat->rows[row][m_currentCursorColumn]; + } else { + int prevGroupIndex = group_index - 1; + while (prevGroupIndex >= 0) { + auto prevGroup = m_groups[prevGroupIndex]; + if (prevGroup->collapsed) { + prevGroupIndex--; + continue; + } + int lastRow = prevGroup->numRows() - 1; + int lastCol = prevGroup->rows[lastRow].size() - 1; + m_currentCursorColumn = lastCol; + return prevGroup->rows[lastRow][lastCol]; + } + } + return current; + } + case MoveRight: { + if (column < cat->rows[row].size() - 1) { + m_currentCursorColumn = column + 1; + return cat->rows[row][column + 1]; + } else if (row < cat->rows.size() - 1) { + row += 1; + m_currentCursorColumn = 0; + return cat->rows[row][m_currentCursorColumn]; + } else { + int nextGroupIndex = group_index + 1; + while (nextGroupIndex < m_groups.size()) { + auto nextGroup = m_groups[nextGroupIndex]; + if (nextGroup->collapsed) { + nextGroupIndex++; + continue; + } + m_currentCursorColumn = 0; + return nextGroup->rows[0][0]; + } + } + return current; + } + case MoveHome: { + m_currentCursorColumn = 0; + return cat->rows[row][0]; + } + case MoveEnd: { + auto last = cat->rows[row].size() - 1; + m_currentCursorColumn = last; + return cat->rows[row][last]; + } + default: + // For unsupported cursor actions, return the current index. + break; + } + return current; +} + +int InstanceView::horizontalOffset() const +{ + return horizontalScrollBar()->value(); +} + +int InstanceView::verticalOffset() const +{ + return verticalScrollBar()->value(); +} + +void InstanceView::scrollContentsBy(int dx, int dy) +{ + scrollDirtyRegion(dx, dy); + viewport()->scroll(dx, dy); +} + +void InstanceView::scrollTo(const QModelIndex& index, ScrollHint hint) +{ + if (!index.isValid()) + return; + + const QRect rect = visualRect(index); + if (hint == EnsureVisible && viewport()->rect().contains(rect)) { + viewport()->update(rect); + return; + } + + verticalScrollBar()->setValue(verticalScrollToValue(index, rect, hint)); +} + +int InstanceView::verticalScrollToValue([[maybe_unused]] const QModelIndex& index, const QRect& rect, QListView::ScrollHint hint) const +{ + const QRect area = viewport()->rect(); + const bool above = (hint == QListView::EnsureVisible && rect.top() < area.top()); + const bool below = (hint == QListView::EnsureVisible && rect.bottom() > area.bottom()); + + int verticalValue = verticalScrollBar()->value(); + QRect adjusted = rect.adjusted(-spacing(), -spacing(), spacing(), spacing()); + if (hint == QListView::PositionAtTop || above) + verticalValue += adjusted.top(); + else if (hint == QListView::PositionAtBottom || below) + verticalValue += qMin(adjusted.top(), adjusted.bottom() - area.height() + 1); + else if (hint == QListView::PositionAtCenter) + verticalValue += adjusted.top() - ((area.height() - adjusted.height()) / 2); + return verticalValue; +} diff --git a/launcher/ui/instanceview/InstanceView.h b/launcher/ui/instanceview/InstanceView.h new file mode 100644 index 0000000..5d9dbf7 --- /dev/null +++ b/launcher/ui/instanceview/InstanceView.h @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include "VisualGroup.h" +#include "ui/themes/CatPainter.h" + +struct InstanceViewRoles { + enum { GroupRole = Qt::UserRole, ProgressValueRole, ProgressMaximumRole }; +}; + +class InstanceView : public QAbstractItemView { + Q_OBJECT + + public: + InstanceView(QWidget* parent = 0); + ~InstanceView(); + + void setModel(QAbstractItemModel* model) override; + + using visibilityFunction = std::function; + void setSourceOfGroupCollapseStatus(visibilityFunction f) { m_fVisibility = f; } + + /// return geometry rectangle occupied by the specified model item + QRect geometryRect(const QModelIndex& index) const; + /// return visual rectangle occupied by the specified model item + virtual QRect visualRect(const QModelIndex& index) const override; + /// get the model index at the specified visual point + virtual QModelIndex indexAt(const QPoint& point) const override; + QString groupNameAt(const QPoint& point); + void setSelection(const QRect& rect, QItemSelectionModel::SelectionFlags commands) override; + + virtual int horizontalOffset() const override; + virtual int verticalOffset() const override; + virtual void scrollContentsBy(int dx, int dy) override; + virtual void scrollTo(const QModelIndex& index, ScrollHint hint = EnsureVisible) override; + + virtual QModelIndex moveCursor(CursorAction cursorAction, Qt::KeyboardModifiers modifiers) override; + + virtual QRegion visualRegionForSelection(const QItemSelection& selection) const override; + + int spacing() const { return m_spacing; }; + void setPaintCat(bool visible); + + public slots: + virtual void updateGeometries() override; + + protected slots: + virtual void dataChanged(const QModelIndex& topLeft, const QModelIndex& bottomRight, const QList& roles) override; + virtual void rowsInserted(const QModelIndex& parent, int start, int end) override; + virtual void rowsAboutToBeRemoved(const QModelIndex& parent, int start, int end) override; + void modelReset(); + void rowsRemoved(); + void currentChanged(const QModelIndex& current, const QModelIndex& previous) override; + + signals: + void droppedURLs(QList urls); + void groupStateChanged(QString group, bool collapsed); + + protected: + bool isIndexHidden(const QModelIndex& index) const override; + void mousePressEvent(QMouseEvent* event) override; + void mouseMoveEvent(QMouseEvent* event) override; + void mouseReleaseEvent(QMouseEvent* event) override; + void mouseDoubleClickEvent(QMouseEvent* event) override; + void paintEvent(QPaintEvent* event) override; + void resizeEvent(QResizeEvent* event) override; + + void dragEnterEvent(QDragEnterEvent* event) override; + void dragMoveEvent(QDragMoveEvent* event) override; + void dragLeaveEvent(QDragLeaveEvent* event) override; + void dropEvent(QDropEvent* event) override; + + void startDrag(Qt::DropActions supportedActions) override; + + void updateScrollbar(); + + private: + friend struct VisualGroup; + QList m_groups; + + visibilityFunction m_fVisibility; + + // geometry + int m_leftMargin = 5; + int m_rightMargin = 5; + int m_bottomMargin = 5; + int m_categoryMargin = 5; + int m_spacing = 5; + int m_itemWidth = 100; + int m_currentItemsPerRow = -1; + int m_currentCursorColumn = -1; + mutable QCache m_geometryCache; + CatPainter* m_cat = nullptr; + + // point where the currently active mouse action started in geometry coordinates + QPoint m_pressedPosition; + QPersistentModelIndex m_pressedIndex; + bool m_pressedAlreadySelected; + VisualGroup* m_pressedCategory; + QItemSelectionModel::SelectionFlag m_ctrlDragSelectionFlag; + QPoint m_lastDragPosition; + + VisualGroup* category(const QModelIndex& index) const; + VisualGroup* category(const QString& cat) const; + VisualGroup* categoryAt(const QPoint& pos, VisualGroup::HitResults& result) const; + + int itemsPerRow() const { return m_currentItemsPerRow; }; + int contentWidth() const; + + private: /* methods */ + int itemWidth() const; + int calculateItemsPerRow() const; + int verticalScrollToValue(const QModelIndex& index, const QRect& rect, QListView::ScrollHint hint) const; + QPixmap renderToPixmap(const QModelIndexList& indices, QRect* r) const; + QList> draggablePaintPairs(const QModelIndexList& indices, QRect* r) const; + + bool isDragEventAccepted(QDropEvent* event); + + std::pair rowDropPos(const QPoint& pos); + + QPoint offset() const; +}; diff --git a/launcher/ui/instanceview/VisualGroup.cpp b/launcher/ui/instanceview/VisualGroup.cpp new file mode 100644 index 0000000..b68c091 --- /dev/null +++ b/launcher/ui/instanceview/VisualGroup.cpp @@ -0,0 +1,265 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2023 Tayou + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "VisualGroup.h" + +#include +#include +#include +#include +#include +#include + +#include "InstanceView.h" + +VisualGroup::VisualGroup(QString text, InstanceView* view) : view(view), text(std::move(text)), collapsed(false) {} + +VisualGroup::VisualGroup(const VisualGroup* other) : view(other->view), text(other->text), collapsed(other->collapsed) {} + +void VisualGroup::update() +{ + auto temp_items = items(); + auto itemsPerRow = view->itemsPerRow(); + + int numRows = qMax(1, qCeil((qreal)temp_items.size() / (qreal)itemsPerRow)); + rows = QList(numRows); + + int maxRowHeight = 0; + int positionInRow = 0; + int currentRow = 0; + int offsetFromTop = 0; + for (auto item : temp_items) { + if (positionInRow == itemsPerRow) { + rows[currentRow].height = maxRowHeight; + rows[currentRow].top = offsetFromTop; + currentRow++; + if (currentRow >= rows.size()) { + currentRow = rows.size() - 1; + } + offsetFromTop += maxRowHeight + 5; + positionInRow = 0; + maxRowHeight = 0; + } + QStyleOptionViewItem viewItemOption; + view->initViewItemOption(&viewItemOption); + + auto itemHeight = view->itemDelegate()->sizeHint(viewItemOption, item).height(); + if (itemHeight > maxRowHeight) { + maxRowHeight = itemHeight; + } + rows[currentRow].items.append(item); + positionInRow++; + } + rows[currentRow].height = maxRowHeight; + rows[currentRow].top = offsetFromTop; +} + +QPair VisualGroup::positionOf(const QModelIndex& index) const +{ + int y = 0; + for (auto& row : rows) { + for (auto x = 0; x < row.items.size(); x++) { + if (row.items[x] == index) { + return qMakePair(x, y); + } + } + y++; + } + qWarning() << "Item" << index.row() << index.data(Qt::DisplayRole).toString() << "not found in visual group" << text; + return qMakePair(0, 0); +} + +int VisualGroup::rowTopOf(const QModelIndex& index) const +{ + auto position = positionOf(index); + return rows[position.second].top; +} + +int VisualGroup::rowHeightOf(const QModelIndex& index) const +{ + auto position = positionOf(index); + return rows[position.second].height; +} + +VisualGroup::HitResults VisualGroup::hitScan(const QPoint& pos) const +{ + VisualGroup::HitResults results = VisualGroup::NoHit; + int y_start = verticalPosition(); + int body_start = y_start + headerHeight(); + int body_end = body_start + contentHeight(); + int y = pos.y(); + // int x = pos.x(); + if (y < y_start) { + results = VisualGroup::NoHit; + } else if (y < body_start) { + results = VisualGroup::HeaderHit; + int collapseSize = headerHeight() - 4; + + // the icon + QRect iconRect = QRect(view->m_leftMargin + 2, 2 + y_start, view->width() - 4, collapseSize); + if (iconRect.contains(pos)) { + results |= VisualGroup::CheckboxHit; + } + } else if (y < body_end) { + results |= VisualGroup::BodyHit; + } + return results; +} + +void VisualGroup::drawHeader(QPainter* painter, const QStyleOptionViewItem& option) const +{ + QRect optRect = option.rect; + optRect.setTop(optRect.top() + 7); + QFont font(QApplication::font()); + font.setBold(true); + const QFontMetrics fontMetrics = QFontMetrics(font); + painter->setFont(font); + + QPen pen; + pen.setWidth(2); + QColor penColor = option.palette.text().color(); + penColor.setAlphaF(0.6f); + pen.setColor(penColor); + painter->setPen(pen); + painter->setRenderHint(QPainter::Antialiasing); + + // sizes and offsets, to keep things consistent below + const int arrowOffsetLeft = fontMetrics.height() / 2 + 7; + const int textOffsetLeft = arrowOffsetLeft * 2; + const int centerHeight = optRect.top() + fontMetrics.height() / 2; + const QString& textToDraw = text.isEmpty() ? QObject::tr("Ungrouped") : text; + + // BEGIN: arrow + { + constexpr int arrowSize = 6; + QPolygon arrowPolygon; + if (collapsed) { + arrowPolygon << QPoint(arrowOffsetLeft - arrowSize / 2, centerHeight - arrowSize) + << QPoint(arrowOffsetLeft + arrowSize / 2, centerHeight) + << QPoint(arrowOffsetLeft - arrowSize / 2, centerHeight + arrowSize); + painter->drawPolyline(arrowPolygon); + } else { + arrowPolygon << QPoint(arrowOffsetLeft - arrowSize, centerHeight - arrowSize / 2) + << QPoint(arrowOffsetLeft, centerHeight + arrowSize / 2) + << QPoint(arrowOffsetLeft + arrowSize, centerHeight - arrowSize / 2); + painter->drawPolyline(arrowPolygon); + } + } + // END: arrow + + // BEGIN: text + { + QRect textRect(optRect); + textRect.setTop(textRect.top()); + textRect.setLeft(textOffsetLeft); + textRect.setHeight(fontMetrics.height()); + textRect.setRight(textRect.right() - 7); + + painter->drawText(textRect, Qt::AlignLeft | Qt::AlignVCenter, textToDraw); + } + // END: text + + // BEGIN: horizontal line + { + penColor.setAlphaF(0.05f); + pen.setColor(penColor); + painter->setPen(pen); + // startPoint is left + arrow + text + space + const int startPoint = + optRect.left() + fontMetrics.height() + fontMetrics.size(Qt::AlignLeft | Qt::AlignVCenter, textToDraw).width() + 20; + painter->setRenderHint(QPainter::Antialiasing, false); + QPolygon polygon; + // for some reason the height (yPos) doesn't look centered, so we are adding 1 to the center height + const int lineHeight = centerHeight + 1; + polygon << QPoint(startPoint, lineHeight) << QPoint(optRect.right() - 3, lineHeight); + painter->drawPolyline(polygon); + } + // END: horizontal line +} + +int VisualGroup::totalHeight() const +{ + return headerHeight() + contentHeight(); +} + +int VisualGroup::headerHeight() +{ + QFont font(QApplication::font()); + font.setBold(true); + QFontMetrics fontMetrics(font); + + const int height = fontMetrics.height() + 1 /* 1 pixel-width gradient */ + + 11 /* top and bottom separation */; + return height; + /* + int raw = view->viewport()->fontMetrics().height() + 4; + // add english. maybe. depends on font height. + if (raw % 2 == 0) + raw++; + return std::min(raw, 25); + */ +} + +int VisualGroup::contentHeight() const +{ + if (collapsed) { + return 0; + } + auto last = rows[numRows() - 1]; + return last.top + last.height; +} + +int VisualGroup::numRows() const +{ + return (int)rows.size(); +} + +int VisualGroup::verticalPosition() const +{ + return m_verticalPosition; +} + +QList VisualGroup::items() const +{ + QList indices; + for (int i = 0; i < view->model()->rowCount(); ++i) { + const QModelIndex index = view->model()->index(i, 0); + if (index.data(InstanceViewRoles::GroupRole).toString() == text) { + indices.append(index); + } + } + return indices; +} diff --git a/launcher/ui/instanceview/VisualGroup.h b/launcher/ui/instanceview/VisualGroup.h new file mode 100644 index 0000000..7210e0d --- /dev/null +++ b/launcher/ui/instanceview/VisualGroup.h @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 Tayou + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include + +class InstanceView; +class QPainter; +class QModelIndex; + +struct VisualRow { + QList items; + int height = 0; + int top = 0; + inline int size() const { return items.size(); } + inline QModelIndex& operator[](int i) { return items[i]; } +}; + +struct VisualGroup { + /* constructors */ + VisualGroup(QString text, InstanceView* view); + explicit VisualGroup(const VisualGroup* other); + + /* data */ + InstanceView* view = nullptr; + QString text; + bool collapsed = false; + QList rows; + int firstItemIndex = 0; + int m_verticalPosition = 0; + + /* logic */ + /// update the internal list of items and flow them into the rows. + void update(); + + /// draw the header at y-position. + void drawHeader(QPainter* painter, const QStyleOptionViewItem& option) const; + + /// height of the group, in total. includes a small bit of padding. + int totalHeight() const; + + /// height of the group header, in pixels + static int headerHeight(); + + /// height of the group content, in pixels + int contentHeight() const; + + /// the number of visual rows this group has + int numRows() const; + + /// actually calculate the above value + int calculateNumRows() const; + + /// the height at which this group starts, in pixels + int verticalPosition() const; + + /// relative geometry - top of the row of the given item + int rowTopOf(const QModelIndex& index) const; + + /// height of the row of the given item + int rowHeightOf(const QModelIndex& index) const; + + /// x/y position of the given item inside the group (in items!) + QPair positionOf(const QModelIndex& index) const; + + enum HitResult { NoHit = 0x0, TextHit = 0x1, CheckboxHit = 0x2, HeaderHit = 0x4, BodyHit = 0x8 }; + Q_DECLARE_FLAGS(HitResults, HitResult) + + /// shoot! BANG! what did we hit? + HitResults hitScan(const QPoint& pos) const; + + QList items() const; +}; + +Q_DECLARE_OPERATORS_FOR_FLAGS(VisualGroup::HitResults) diff --git a/launcher/ui/java/InstallJavaDialog.cpp b/launcher/ui/java/InstallJavaDialog.cpp new file mode 100644 index 0000000..81ffcfa --- /dev/null +++ b/launcher/ui/java/InstallJavaDialog.cpp @@ -0,0 +1,360 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2024 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "InstallJavaDialog.h" + +#include +#include +#include +#include +#include +#include + +#include "Application.h" +#include "BaseVersionList.h" +#include "FileSystem.h" +#include "Filter.h" +#include "java/download/ArchiveDownloadTask.h" +#include "java/download/ManifestDownloadTask.h" +#include "java/download/SymlinkTask.h" +#include "meta/Index.h" +#include "meta/VersionList.h" +#include "minecraft/MinecraftInstance.h" +#include "minecraft/PackProfile.h" +#include "tasks/SequentialTask.h" +#include "ui/dialogs/CustomMessageBox.h" +#include "ui/dialogs/ProgressDialog.h" +#include "ui/java/VersionList.h" +#include "ui/widgets/PageContainer.h" +#include "ui/widgets/VersionSelectWidget.h" + +class InstallJavaPage : public QWidget, public BasePage { + public: + Q_OBJECT + public: + explicit InstallJavaPage(const QString& id, const QString& iconName, const QString& name, QWidget* parent = nullptr) + : QWidget(parent), uid(id), iconName(iconName), name(name) + { + setObjectName(QStringLiteral("VersionSelectWidget")); + horizontalLayout = new QHBoxLayout(this); + horizontalLayout->setObjectName(QStringLiteral("horizontalLayout")); + horizontalLayout->setContentsMargins(0, 0, 0, 0); + majorVersionSelect = new VersionSelectWidget(this); + majorVersionSelect->selectCurrent(); + majorVersionSelect->setEmptyString(tr("No Java versions are currently available in the meta.")); + majorVersionSelect->setEmptyErrorString(tr("Couldn't load or download the Java version lists!")); + horizontalLayout->addWidget(majorVersionSelect, 1); + + javaVersionSelect = new VersionSelectWidget(this); + javaVersionSelect->setEmptyString(tr("No Java versions are currently available for your OS.")); + javaVersionSelect->setEmptyErrorString(tr("Couldn't load or download the Java version lists!")); + horizontalLayout->addWidget(javaVersionSelect, 4); + connect(majorVersionSelect, &VersionSelectWidget::selectedVersionChanged, this, &InstallJavaPage::setSelectedVersion); + connect(majorVersionSelect, &VersionSelectWidget::selectedVersionChanged, this, &InstallJavaPage::selectionChanged); + connect(javaVersionSelect, &VersionSelectWidget::selectedVersionChanged, this, &InstallJavaPage::selectionChanged); + + QMetaObject::connectSlotsByName(this); + } + ~InstallJavaPage() + { + delete horizontalLayout; + delete majorVersionSelect; + delete javaVersionSelect; + } + + //! loads the list if needed. + void initialize(Meta::VersionList::Ptr vlist) + { + vlist->setProvidedRoles({ BaseVersionList::JavaMajorRole, BaseVersionList::RecommendedRole, BaseVersionList::VersionPointerRole }); + majorVersionSelect->initialize(vlist.get()); + } + + void setSelectedVersion(BaseVersion::Ptr version) + { + auto dcast = std::dynamic_pointer_cast(version); + if (!dcast) { + return; + } + javaVersionSelect->initialize(new Java::VersionList(dcast, this)); + javaVersionSelect->selectCurrent(); + } + + QString id() const override { return uid; } + QString displayName() const override { return name; } + QIcon icon() const override { return QIcon::fromTheme(iconName); } + + void openedImpl() override + { + if (loaded) + return; + + const auto versions = APPLICATION->metadataIndex()->get(uid); + if (!versions) + return; + + initialize(versions); + loaded = true; + } + + void setParentContainer(BasePageContainer* container) override + { + auto dialog = dynamic_cast(dynamic_cast(container)->parent()); + connect(javaVersionSelect->view(), &QAbstractItemView::doubleClicked, dialog, &QDialog::accept); + } + + BaseVersion::Ptr selectedVersion() const { return javaVersionSelect->selectedVersion(); } + void selectSearch() { javaVersionSelect->selectSearch(); } + void loadList() + { + majorVersionSelect->loadList(); + javaVersionSelect->loadList(); + } + + public slots: + void setRecommendedMajors(const QStringList& majors) + { + m_recommended_majors = majors; + recommendedFilterChanged(); + } + void setRecommend(bool recommend) + { + m_recommend = recommend; + recommendedFilterChanged(); + } + void recommendedFilterChanged() + { + if (m_recommend) { + majorVersionSelect->setFilter(BaseVersionList::ModelRoles::JavaMajorRole, Filters::equalsAny(m_recommended_majors)); + } else { + majorVersionSelect->setFilter(BaseVersionList::ModelRoles::JavaMajorRole, Filters::equalsAny()); + } + } + + signals: + void selectionChanged(); + + private: + const QString uid; + const QString iconName; + const QString name; + bool loaded = false; + + QHBoxLayout* horizontalLayout = nullptr; + VersionSelectWidget* majorVersionSelect = nullptr; + VersionSelectWidget* javaVersionSelect = nullptr; + + QStringList m_recommended_majors; + bool m_recommend; +}; + +static InstallJavaPage* pageCast(BasePage* page) +{ + auto result = dynamic_cast(page); + Q_ASSERT(result != nullptr); + return result; +} +namespace Java { +QStringList getRecommendedJavaVersionsFromVersionList(Meta::VersionList::Ptr list) +{ + QStringList recommendedJavas; + for (auto ver : list->versions()) { + auto major = ver->version(); + if (major.startsWith("java")) { + major = "Java " + major.mid(4); + } + recommendedJavas.append(major); + } + return recommendedJavas; +} + +InstallDialog::InstallDialog(const QString& uid, BaseInstance* instance, QWidget* parent) + : QDialog(parent), container(new PageContainer(this, QString(), this)), buttons(new QDialogButtonBox(this)) +{ + auto layout = new QVBoxLayout(this); + // small margins look ugly on macOS on modal windows + #ifndef Q_OS_MACOS + layout->setContentsMargins(0, 0, 0, 0); + #endif + container->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding); + layout->addWidget(container); + + auto buttonLayout = new QHBoxLayout(this); + // small margins look ugly on macOS on modal windows + #ifndef Q_OS_MACOS + buttonLayout->setContentsMargins(0, 0, 6, 6); + #endif + + auto refreshLayout = new QHBoxLayout(this); + + auto refreshButton = new QPushButton(tr("&Refresh"), this); + connect(refreshButton, &QPushButton::clicked, this, [this] { pageCast(container->selectedPage())->loadList(); }); + refreshLayout->addWidget(refreshButton); + + auto recommendedCheckBox = new QCheckBox("Recommended", this); + recommendedCheckBox->setCheckState(Qt::CheckState::Checked); + connect(recommendedCheckBox, &QCheckBox::stateChanged, this, [this](int state) { + for (BasePage* page : container->getPages()) { + pageCast(page)->setRecommend(state == Qt::Checked); + } + }); + + refreshLayout->addWidget(recommendedCheckBox); + buttonLayout->addLayout(refreshLayout); + + buttons->setOrientation(Qt::Horizontal); + buttons->setStandardButtons(QDialogButtonBox::Cancel | QDialogButtonBox::Ok); + buttons->button(QDialogButtonBox::Ok)->setText(tr("Download")); + buttons->button(QDialogButtonBox::Cancel)->setText(tr("Cancel")); + connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + buttonLayout->addWidget(buttons); + + container->addButtons(buttonLayout); + + setWindowTitle(dialogTitle()); + setWindowModality(Qt::WindowModal); + resize(840, 480); + + QStringList recommendedJavas; + if (auto mcInst = dynamic_cast(instance); mcInst) { + auto mc = mcInst->getPackProfile()->getComponent("net.minecraft"); + if (mc) { + auto file = mc->getVersionFile(); // no need for load as it should already be loaded + if (file) { + for (auto major : file->compatibleJavaMajors) { + recommendedJavas.append(QString("Java %1").arg(major)); + } + } + } + } else { + const auto versions = APPLICATION->metadataIndex()->get("net.minecraft.java"); + if (versions) { + if (versions->isLoaded()) { + recommendedJavas = getRecommendedJavaVersionsFromVersionList(versions); + } else { + auto newTask = versions->getLoadTask(); + if (newTask) { + connect(newTask.get(), &Task::succeeded, this, [this, versions] { + auto recommendedJavas = getRecommendedJavaVersionsFromVersionList(versions); + for (BasePage* page : container->getPages()) { + pageCast(page)->setRecommendedMajors(recommendedJavas); + } + }); + if (!newTask->isRunning()) + newTask->start(); + } else { + recommendedJavas = getRecommendedJavaVersionsFromVersionList(versions); + } + } + } + } + for (BasePage* page : container->getPages()) { + if (page->id() == uid) + container->selectPage(page->id()); + + auto cast = pageCast(page); + cast->setRecommend(true); + connect(cast, &InstallJavaPage::selectionChanged, this, [this, cast] { validate(cast); }); + if (!recommendedJavas.isEmpty()) { + cast->setRecommendedMajors(recommendedJavas); + } + } + connect(container, &PageContainer::selectedPageChanged, this, [this](BasePage* previous, BasePage* selected) { validate(selected); }); + pageCast(container->selectedPage())->selectSearch(); + validate(container->selectedPage()); +} + +QList InstallDialog::getPages() +{ + return { + // Mojang + new InstallJavaPage("net.minecraft.java", "mojang", tr("Mojang")), + // Adoptium + new InstallJavaPage("net.adoptium.java", "adoptium", tr("Adoptium")), + // Azul + new InstallJavaPage("com.azul.java", "azul", tr("Azul Zulu")), + // IBM + /* Must watch out in case the AdoptOpenJDK infrastructure is deprecated. + In case of happening, IBM does not seem to provide as of today (03/2026) an API like Adoptium does and rather uses GitHub directly in its website: `developer.ibm.com`. + GitHub is known for rate limiting requests that do not use an API key from an account. */ + new InstallJavaPage("com.ibm.java", "openj9_hex_custom", tr("IBM Semeru Open")), + }; +} + +QString InstallDialog::dialogTitle() +{ + return tr("Install Java"); +} + +void InstallDialog::validate(BasePage* selected) +{ + buttons->button(QDialogButtonBox::Ok)->setEnabled(!!std::dynamic_pointer_cast(pageCast(selected)->selectedVersion())); +} + +void InstallDialog::done(int result) +{ + if (result == Accepted) { + auto* page = pageCast(container->selectedPage()); + if (page->selectedVersion()) { + auto meta = std::dynamic_pointer_cast(page->selectedVersion()); + if (meta) { + Task::Ptr task; + auto final_path = FS::PathCombine(APPLICATION->javaPath(), meta->m_name); + auto deletePath = [final_path] { FS::deletePath(final_path); }; + switch (meta->downloadType) { + case Java::DownloadType::Manifest: + task = makeShared(meta->url, final_path, meta->checksumType, meta->checksumHash); + break; + case Java::DownloadType::Archive: + task = makeShared(meta->url, final_path, meta->checksumType, meta->checksumHash); + break; + case Java::DownloadType::Unknown: + QString error = QString(tr("Could not determine Java download type!")); + CustomMessageBox::selectable(this, tr("Error"), error, QMessageBox::Warning)->show(); + deletePath(); + return; + } +#if defined(Q_OS_MACOS) + auto seq = makeShared(tr("Install Java")); + seq->addTask(task); + seq->addTask(makeShared(final_path)); + task = seq; +#endif + connect(task.get(), &Task::failed, this, [this, &deletePath](QString reason) { + QString error = QString("Java download failed: %1").arg(reason); + CustomMessageBox::selectable(this, tr("Error"), error, QMessageBox::Warning)->show(); + deletePath(); + }); + connect(task.get(), &Task::aborted, this, deletePath); + ProgressDialog pg(this); + pg.setSkipButton(true, tr("Abort")); + pg.execWithTask(task.get()); + } else { + return; + } + } else { + return; + } + } + + QDialog::done(result); +} + +} // namespace Java + +#include "InstallJavaDialog.moc" diff --git a/launcher/ui/java/InstallJavaDialog.h b/launcher/ui/java/InstallJavaDialog.h new file mode 100644 index 0000000..7d0edbf --- /dev/null +++ b/launcher/ui/java/InstallJavaDialog.h @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2024 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include "BaseInstance.h" +#include "ui/pages/BasePageProvider.h" + +class MinecraftInstance; +class PageContainer; +class PackProfile; +class QDialogButtonBox; + +namespace Java { +class InstallDialog final : public QDialog, private BasePageProvider { + Q_OBJECT + + public: + explicit InstallDialog(const QString& uid = QString(), BaseInstance* instance = nullptr, QWidget* parent = nullptr); + + QList getPages() override; + QString dialogTitle() override; + + void validate(BasePage* selected); + void done(int result) override; + + private: + PageContainer* container; + QDialogButtonBox* buttons; +}; +} // namespace Java diff --git a/launcher/ui/java/VersionList.cpp b/launcher/ui/java/VersionList.cpp new file mode 100644 index 0000000..f958f06 --- /dev/null +++ b/launcher/ui/java/VersionList.cpp @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2024 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "VersionList.h" + +#include + +#include "BaseVersionList.h" +#include "SysInfo.h" +#include "java/JavaMetadata.h" +#include "meta/VersionList.h" + +namespace Java { + +VersionList::VersionList(Meta::Version::Ptr version, QObject* parent) : BaseVersionList(parent), m_version(version) +{ + if (version->isLoaded()) + sortVersions(); +} + +Task::Ptr VersionList::getLoadTask() +{ + auto task = m_version->loadTask(Net::Mode::Online); + connect(task.get(), &Task::finished, this, &VersionList::sortVersions); + return task; +} + +const BaseVersion::Ptr VersionList::at(int i) const +{ + return m_vlist.at(i); +} + +bool VersionList::isLoaded() +{ + return m_version->isLoaded(); +} + +int VersionList::count() const +{ + return m_vlist.count(); +} + +QVariant VersionList::data(const QModelIndex& index, int role) const +{ + if (!index.isValid()) + return QVariant(); + + if (index.row() > count()) + return QVariant(); + + auto version = (m_vlist[index.row()]); + switch (role) { + case SortRole: + return -index.row(); + case VersionPointerRole: + return QVariant::fromValue(std::dynamic_pointer_cast(m_vlist[index.row()])); + case VersionIdRole: + return version->descriptor(); + case VersionRole: + return version->version.toString(); + case RecommendedRole: + return false; // do not recommend any version + case JavaNameRole: + return version->name(); + case JavaMajorRole: { + auto major = version->version.toString(); + if (major.startsWith("java")) { + major = "Java " + major.mid(4); + } + return major; + } + case TypeRole: + return version->packageType; + case Meta::VersionList::TimeRole: + return version->releaseTime; + default: + return QVariant(); + } +} + +BaseVersionList::RoleList VersionList::providesRoles() const +{ + return { VersionPointerRole, VersionIdRole, VersionRole, RecommendedRole, JavaNameRole, TypeRole, Meta::VersionList::TimeRole }; +} + +bool sortJavas(BaseVersion::Ptr left, BaseVersion::Ptr right) +{ + auto rleft = std::dynamic_pointer_cast(right); + auto rright = std::dynamic_pointer_cast(left); + return (*rleft) < (*rright); +} + +void VersionList::sortVersions() +{ + if (!m_version || !m_version->data()) + return; + QString versionStr = SysInfo::getSupportedJavaArchitecture(); + beginResetModel(); + auto runtimes = m_version->data()->runtimes; + m_vlist = {}; + if (!versionStr.isEmpty() && !runtimes.isEmpty()) { + std::copy_if(runtimes.begin(), runtimes.end(), std::back_inserter(m_vlist), + [versionStr](Java::MetadataPtr val) { return val->runtimeOS == versionStr; }); + std::sort(m_vlist.begin(), m_vlist.end(), sortJavas); + } else { + qWarning() << "No Java versions found for your operating system:" << SysInfo::currentSystem() << SysInfo::useQTForArch(); + } + endResetModel(); +} + +} // namespace Java diff --git a/launcher/ui/java/VersionList.h b/launcher/ui/java/VersionList.h new file mode 100644 index 0000000..d334ed5 --- /dev/null +++ b/launcher/ui/java/VersionList.h @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2024 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include "BaseVersionList.h" +#include "java/JavaMetadata.h" +#include "meta/Version.h" + +namespace Java { + +class VersionList : public BaseVersionList { + Q_OBJECT + + public: + explicit VersionList(Meta::Version::Ptr m_version, QObject* parent = 0); + + Task::Ptr getLoadTask() override; + bool isLoaded() override; + const BaseVersion::Ptr at(int i) const override; + int count() const override; + void sortVersions() override; + + QVariant data(const QModelIndex& index, int role) const override; + RoleList providesRoles() const override; + + protected slots: + void updateListData(QList) override {} + + protected: + Meta::Version::Ptr m_version; + QList m_vlist; +}; + +} // namespace Java diff --git a/launcher/ui/pagedialog/PageDialog.cpp b/launcher/ui/pagedialog/PageDialog.cpp new file mode 100644 index 0000000..0cd8052 --- /dev/null +++ b/launcher/ui/pagedialog/PageDialog.cpp @@ -0,0 +1,84 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "PageDialog.h" + +#include +#include +#include +#include + +#include "Application.h" +#include "settings/SettingsObject.h" + +#include "ui/widgets/PageContainer.h" + +PageDialog::PageDialog(BasePageProvider* pageProvider, QString defaultId, QWidget* parent) : QDialog(parent) +{ + setWindowTitle(pageProvider->dialogTitle()); + m_container = new PageContainer(pageProvider, std::move(defaultId), this); + + auto* mainLayout = new QVBoxLayout(this); + + auto* focusStealer = new QPushButton(this); + mainLayout->addWidget(focusStealer); + focusStealer->setDefault(true); + focusStealer->hide(); + + mainLayout->addWidget(m_container); + mainLayout->setSpacing(0); + mainLayout->setContentsMargins(0, 0, 0, 0); + + setLayout(mainLayout); + + auto* buttons = new QDialogButtonBox(QDialogButtonBox::Help | QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + buttons->button(QDialogButtonBox::Ok)->setText(tr("&OK")); + buttons->button(QDialogButtonBox::Cancel)->setText(tr("&Cancel")); + buttons->button(QDialogButtonBox::Help)->setText(tr("Help")); + buttons->setContentsMargins(0, 0, 6, 6); + m_container->addButtons(buttons); + + connect(buttons->button(QDialogButtonBox::Ok), &QPushButton::clicked, this, &PageDialog::accept); + connect(buttons->button(QDialogButtonBox::Cancel), &QPushButton::clicked, this, &PageDialog::reject); + connect(buttons->button(QDialogButtonBox::Help), &QPushButton::clicked, m_container, &PageContainer::help); + + restoreGeometry(QByteArray::fromBase64(APPLICATION->settings()->get("PagedGeometry").toString().toUtf8())); +} + +void PageDialog::accept() +{ + if (handleClose()) + QDialog::accept(); +} + +void PageDialog::closeEvent(QCloseEvent* event) +{ + if (handleClose()) + QDialog::closeEvent(event); +} + +bool PageDialog::handleClose() +{ + qDebug() << "Paged dialog close requested"; + if (!m_container->prepareToClose()) + return false; + + qDebug() << "Paged dialog close approved"; + APPLICATION->settings()->set("PagedGeometry", QString::fromUtf8(saveGeometry().toBase64())); + qDebug() << "Paged dialog geometry saved"; + + emit applied(); + return true; +} diff --git a/launcher/ui/pagedialog/PageDialog.h b/launcher/ui/pagedialog/PageDialog.h new file mode 100644 index 0000000..9a8a3cc --- /dev/null +++ b/launcher/ui/pagedialog/PageDialog.h @@ -0,0 +1,38 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include "ui/pages/BasePageProvider.h" + +class PageContainer; +class PageDialog : public QDialog { + Q_OBJECT + public: + explicit PageDialog(BasePageProvider* pageProvider, QString defaultId = QString(), QWidget* parent = 0); + virtual ~PageDialog() {} + + signals: + void applied(); + + private: + void accept() override; + void closeEvent(QCloseEvent* event) override; + bool handleClose(); + + private: + PageContainer* m_container; +}; diff --git a/launcher/ui/pages/BasePage.h b/launcher/ui/pages/BasePage.h new file mode 100644 index 0000000..cb3a763 --- /dev/null +++ b/launcher/ui/pages/BasePage.h @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include + +#include "BasePageContainer.h" + +class BasePage { + public: + using updateExtraInfoFunc = std::function; + virtual ~BasePage() {} + virtual QString id() const = 0; + virtual QString displayName() const = 0; + virtual QIcon icon() const = 0; + virtual bool apply() { return true; } + virtual bool shouldDisplay() const { return true; } + virtual QString helpPage() const { return QString(); } + void opened() + { + isOpened = true; + openedImpl(); + } + void closed() + { + isOpened = false; + closedImpl(); + } + virtual void openedImpl() {} + virtual void closedImpl() {} + virtual void setParentContainer(BasePageContainer* container) { m_container = container; }; + virtual void retranslate() {} + + public: + int stackIndex = -1; + int listIndex = -1; + updateExtraInfoFunc updateExtraInfo; + + protected: + BasePageContainer* m_container = nullptr; + bool isOpened = false; +}; + +using BasePagePtr = std::shared_ptr; diff --git a/launcher/ui/pages/BasePageContainer.h b/launcher/ui/pages/BasePageContainer.h new file mode 100644 index 0000000..671c273 --- /dev/null +++ b/launcher/ui/pages/BasePageContainer.h @@ -0,0 +1,13 @@ +#pragma once + +class BasePage; + +class BasePageContainer { + public: + virtual ~BasePageContainer() {}; + virtual bool selectPage(QString pageId) = 0; + virtual BasePage* selectedPage() const = 0; + virtual BasePage* getPage(QString pageId) { return nullptr; }; + virtual void refreshContainer() = 0; + virtual bool requestClose() = 0; +}; diff --git a/launcher/ui/pages/BasePageProvider.h b/launcher/ui/pages/BasePageProvider.h new file mode 100644 index 0000000..ef3c1cd --- /dev/null +++ b/launcher/ui/pages/BasePageProvider.h @@ -0,0 +1,56 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include "ui/pages/BasePage.h" + +class BasePageProvider { + public: + virtual QList getPages() = 0; + virtual QString dialogTitle() = 0; +}; + +class GenericPageProvider : public BasePageProvider { + using PageCreator = std::function; + + public: + explicit GenericPageProvider(const QString& dialogTitle) : m_dialogTitle(dialogTitle) {} + virtual ~GenericPageProvider() {} + + QList getPages() override + { + QList pages; + for (PageCreator creator : m_creators) { + pages.append(creator()); + } + return pages; + } + QString dialogTitle() override { return m_dialogTitle; } + + void setDialogTitle(const QString& title) { m_dialogTitle = title; } + void addPageCreator(PageCreator page) { m_creators.append(page); } + + template + void addPage() + { + addPageCreator([]() { return new PageClass(); }); + } + + private: + QList m_creators; + QString m_dialogTitle; +}; diff --git a/launcher/ui/pages/global/APIPage.cpp b/launcher/ui/pages/global/APIPage.cpp new file mode 100644 index 0000000..c399df4 --- /dev/null +++ b/launcher/ui/pages/global/APIPage.cpp @@ -0,0 +1,216 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (c) 2022 Jamie Mansfield + * Copyright (c) 2022 Lenny McLennington + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "APIPage.h" +#include "ui_APIPage.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "Application.h" +#include "BuildConfig.h" +#include "net/PasteUpload.h" +#include "settings/SettingsObject.h" +#include "tools/BaseProfiler.h" + +APIPage::APIPage(QWidget* parent) : QWidget(parent), ui(new Ui::APIPage) +{ + // This is here so you can reorder the entries in the combobox without messing stuff up + int comboBoxEntries[] = { PasteUpload::PasteType::Mclogs, PasteUpload::PasteType::NullPointer, PasteUpload::PasteType::PasteGG, + PasteUpload::PasteType::Hastebin }; + + static const QRegularExpression s_validUrlRegExp("https?://.+"); + static const QRegularExpression s_validMSAClientID( + QRegularExpression::anchoredPattern("[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}")); + + ui->setupUi(this); + + for (auto pasteType : comboBoxEntries) { + ui->pasteTypeComboBox->addItem(PasteUpload::PasteTypes.at(pasteType).name, pasteType); + } + + void (QComboBox::*currentIndexChangedSignal)(int)(&QComboBox::currentIndexChanged); + connect(ui->pasteTypeComboBox, currentIndexChangedSignal, this, &APIPage::updateBaseURLPlaceholder); + // This function needs to be called even when the ComboBox's index is still in its default state. + updateBaseURLPlaceholder(ui->pasteTypeComboBox->currentIndex()); + // NOTE: this allows http://, but we replace that with https later anyway + ui->metaURL->setValidator(new QRegularExpressionValidator(s_validUrlRegExp, ui->metaURL)); + ui->resourceURL->setValidator(new QRegularExpressionValidator(s_validUrlRegExp, ui->resourceURL)); + ui->baseURLEntry->setValidator(new QRegularExpressionValidator(s_validUrlRegExp, ui->baseURLEntry)); + ui->legacyFMLLibsURL->setValidator(new QRegularExpressionValidator(s_validUrlRegExp, ui->legacyFMLLibsURL)); + ui->msaClientID->setValidator(new QRegularExpressionValidator(s_validMSAClientID, ui->msaClientID)); + + ui->metaURL->setPlaceholderText(BuildConfig.META_URL); + ui->resourceURL->setPlaceholderText(BuildConfig.DEFAULT_RESOURCE_BASE); + ui->legacyFMLLibsURL->setPlaceholderText(BuildConfig.LEGACY_FMLLIBS_BASE_URL); + ui->userAgentLineEdit->setPlaceholderText(BuildConfig.USER_AGENT); + + loadSettings(); + + resetBaseURLNote(); + connect(ui->pasteTypeComboBox, currentIndexChangedSignal, this, &APIPage::updateBaseURLNote); + connect(ui->baseURLEntry, &QLineEdit::textEdited, this, &APIPage::resetBaseURLNote); +} + +APIPage::~APIPage() +{ + delete ui; +} + +void APIPage::resetBaseURLNote() +{ + ui->baseURLNote->hide(); + baseURLPasteType = ui->pasteTypeComboBox->currentIndex(); +} + +void APIPage::updateBaseURLNote(int index) +{ + if (baseURLPasteType == index) { + ui->baseURLNote->hide(); + } else if (!ui->baseURLEntry->text().isEmpty()) { + ui->baseURLNote->show(); + } +} + +void APIPage::updateBaseURLPlaceholder(int index) +{ + int pasteType = ui->pasteTypeComboBox->itemData(index).toInt(); + QString pasteDefaultURL = PasteUpload::PasteTypes.at(pasteType).defaultBase; + ui->baseURLEntry->setPlaceholderText(pasteDefaultURL); +} + +void APIPage::loadSettings() +{ + auto s = APPLICATION->settings(); + + int pasteType = s->get("PastebinType").toInt(); + QString pastebinURL = s->get("PastebinCustomAPIBase").toString(); + + ui->baseURLEntry->setText(pastebinURL); + int pasteTypeIndex = ui->pasteTypeComboBox->findData(pasteType); + if (pasteTypeIndex == -1) { + pasteTypeIndex = ui->pasteTypeComboBox->findData(PasteUpload::PasteType::Mclogs); + ui->baseURLEntry->clear(); + } + + ui->pasteTypeComboBox->setCurrentIndex(pasteTypeIndex); + + if (bool fallbackMRBlockedMods = s->get("FallbackMRBlockedMods").toBool()) { + ui->FallbackMRBlockedMods->setChecked(fallbackMRBlockedMods); + } + + QString msaClientID = s->get("MSAClientIDOverride").toString(); + ui->msaClientID->setText(msaClientID); + QString metaURL = s->get("MetaURLOverride").toString(); + ui->metaURL->setText(metaURL); + QString resourceURL = s->get("ResourceURLOverride").toString(); + ui->resourceURL->setText(resourceURL); + QString fmlLibsURL = s->get("LegacyFMLLibsURLOverride").toString(); + ui->legacyFMLLibsURL->setText(fmlLibsURL); + QString flameKey = s->get("FlameKeyOverride").toString(); + ui->flameKey->setText(flameKey); + QString modrinthToken = s->get("ModrinthToken").toString(); + ui->modrinthToken->setText(modrinthToken); + QString customUserAgent = s->get("UserAgentOverride").toString(); + ui->userAgentLineEdit->setText(customUserAgent); + ui->technicClientID->setText(s->get("TechnicClientID").toString()); +} + +void APIPage::applySettings() +{ + auto s = APPLICATION->settings(); + + s->set("PastebinType", ui->pasteTypeComboBox->currentData().toInt()); + s->set("PastebinCustomAPIBase", ui->baseURLEntry->text()); + + QString msaClientID = ui->msaClientID->text(); + s->set("MSAClientIDOverride", msaClientID); + QUrl metaURL(ui->metaURL->text()); + QUrl resourceURL(ui->resourceURL->text()); + QUrl fmlLibsURL(ui->legacyFMLLibsURL->text()); + + auto addRequiredTrailingSlash = [](QUrl& url) { + if (!url.isEmpty() && !url.path().endsWith('/')) { + QString path = url.path(); + path.append('/'); + url.setPath(path); + } + }; + addRequiredTrailingSlash(metaURL); + addRequiredTrailingSlash(resourceURL); + addRequiredTrailingSlash(fmlLibsURL); + + auto isLocalhost = [](const QUrl& url) { return url.host() == "localhost" || url.host() == "127.0.0.1" || url.host() == "::1"; }; + auto isUnsafe = [isLocalhost](const QUrl& url) { return !url.isEmpty() && url.scheme() == "http" && !isLocalhost(url); }; + auto upgradeToHTTPS = [isUnsafe](QUrl& url) { + if (isUnsafe(url)) { + url.setScheme("https"); + } + }; + + upgradeToHTTPS(metaURL); + upgradeToHTTPS(resourceURL); + upgradeToHTTPS(fmlLibsURL); + + s->set("FallbackMRBlockedMods", ui->FallbackMRBlockedMods->checkState()); + s->set("MetaURLOverride", metaURL.toString()); + s->set("ResourceURLOverride", resourceURL.toString()); + s->set("LegacyFMLLibsURLOverride", fmlLibsURL.toString()); + QString flameKey = ui->flameKey->text(); + s->set("FlameKeyOverride", flameKey); + QString modrinthToken = ui->modrinthToken->text(); + s->set("ModrinthToken", modrinthToken); + s->set("UserAgentOverride", ui->userAgentLineEdit->text()); + s->set("TechnicClientID", ui->technicClientID->text()); +} + +bool APIPage::apply() +{ + applySettings(); + return true; +} + +void APIPage::retranslate() +{ + ui->retranslateUi(this); +} diff --git a/launcher/ui/pages/global/APIPage.h b/launcher/ui/pages/global/APIPage.h new file mode 100644 index 0000000..7a22aa0 --- /dev/null +++ b/launcher/ui/pages/global/APIPage.h @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (c) 2022 Jamie Mansfield + * Copyright (c) 2022 Lenny McLennington + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +#include "ui/pages/BasePage.h" + +namespace Ui { +class APIPage; +} + +class APIPage : public QWidget, public BasePage { + Q_OBJECT + + public: + explicit APIPage(QWidget* parent = 0); + ~APIPage(); + + QString displayName() const override { return tr("Services"); } + QIcon icon() const override { return QIcon::fromTheme("worlds"); } + QString id() const override { return "apis"; } + QString helpPage() const override { return "APIs"; } + virtual bool apply() override; + void retranslate() override; + + private: + int baseURLPasteType; + void resetBaseURLNote(); + void updateBaseURLNote(int index); + void updateBaseURLPlaceholder(int index); + void loadSettings(); + void applySettings(); + + private: + Ui::APIPage* ui; +}; diff --git a/launcher/ui/pages/global/APIPage.ui b/launcher/ui/pages/global/APIPage.ui new file mode 100644 index 0000000..7d759d2 --- /dev/null +++ b/launcher/ui/pages/global/APIPage.ui @@ -0,0 +1,456 @@ + + + APIPage + + + + 0 + 0 + 841 + 620 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + true + + + + + 0 + -262 + 820 + 908 + + + + + + + &Pastebin Service + + + + + + Paste Service &Type + + + pasteTypeComboBox + + + + + + + + 0 + 0 + + + + + + + + Base &URL + + + baseURLEntry + + + + + + + Use Default + + + true + + + + + + + Note: you probably want to change or clear the Base URL after changing the paste service type. + + + true + + + + + + + + + + Meta&data Server + + + + + + You can set this to a third-party metadata server to use patched libraries or other hacks. + + + Qt::RichText + + + true + + + true + + + + + + + Use Default + + + + + + + + + + Assets Server + + + + + + You can set this to another server if you have problems with downloading assets. + + + Qt::RichText + + + true + + + true + + + + + + + Use Default + + + + + + + + + + Legacy FML Libraries Server + + + + + + You can set this to another server if you have problems with downloading legacy FML libraries (Minecraft 1.5.2 and earlier). + + + Qt::RichText + + + true + + + true + + + + + + + + + + + + + + 0 + 0 + + + + User Agent + + + + + + Use Default + + + + + + + Enter a custom User Agent here. The special string $LAUNCHER_VER will be replaced with the version of the launcher. + + + true + + + + + + + + + + &API Keys + + + + + + &Microsoft Authentication + + + Qt::RichText + + + true + + + true + + + msaClientID + + + + + + + Use Default + + + + + + + Note: you probably don't need to set this if logging in via Microsoft Authentication already works. + + + Qt::RichText + + + true + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 0 + 6 + + + + + + + + Mod&rinth + + + Qt::RichText + + + true + + + true + + + modrinthToken + + + + + + + true + + + Use None + + + + + + + <html><head/><body><p>Note: you only need to set this to access private data. Read the <a href="https://docs.modrinth.com/api/#authentication">documentation</a> for more information.</p></body></html> + + + true + + + true + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 0 + 6 + + + + + + + + &CurseForge + + + Qt::RichText + + + true + + + true + + + flameKey + + + + + + + true + + + Use Default + + + + + + + Note: you probably don't need to set this if CurseForge already works. + + + true + + + + + + + Enable fallback to Modrinth for blocked mods + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 0 + 6 + + + + + + + + &Technic + + + technicClientID + + + + + + + Use Default + + + + + + + <html><head/><body><p>Note: you only need to set this to access private data.</p></body></html> + + + true + + + + + + + + + + Qt::Vertical + + + + 0 + 0 + + + + + + + + + + + + + diff --git a/launcher/ui/pages/global/AccountListPage.cpp b/launcher/ui/pages/global/AccountListPage.cpp new file mode 100644 index 0000000..06c40f1 --- /dev/null +++ b/launcher/ui/pages/global/AccountListPage.cpp @@ -0,0 +1,268 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "AccountListPage.h" +#include "ui/dialogs/skins/SkinManageDialog.h" +#include "ui_AccountListPage.h" + +#include +#include +#include + +#include + +#include "ui/dialogs/ChooseOfflineNameDialog.h" +#include "ui/dialogs/CustomMessageBox.h" +#include "ui/dialogs/MSALoginDialog.h" + +#include "Application.h" + +AccountListPage::AccountListPage(QWidget* parent) : QMainWindow(parent), ui(new Ui::AccountListPage) +{ + ui->setupUi(this); + ui->listView->setEmptyString( + tr("Welcome!\n" + "If you're new here, you can select the \"Add Microsoft\" button to link your Microsoft account.")); + ui->listView->setEmptyMode(VersionListView::String); + ui->listView->setContextMenuPolicy(Qt::CustomContextMenu); + + m_accounts = APPLICATION->accounts(); + + ui->listView->setModel(m_accounts); + ui->listView->header()->setSectionResizeMode(AccountList::VListColumns::ProfileNameColumn, QHeaderView::Stretch); + ui->listView->header()->setSectionResizeMode(AccountList::VListColumns::TypeColumn, QHeaderView::ResizeToContents); + ui->listView->header()->setSectionResizeMode(AccountList::VListColumns::StatusColumn, QHeaderView::ResizeToContents); + ui->listView->setSelectionMode(QAbstractItemView::SingleSelection); + + // Expand the account column + + QItemSelectionModel* selectionModel = ui->listView->selectionModel(); + + connect(selectionModel, &QItemSelectionModel::selectionChanged, + [this]([[maybe_unused]] const QItemSelection& sel, [[maybe_unused]] const QItemSelection& dsel) { updateButtonStates(); }); + connect(ui->listView, &VersionListView::customContextMenuRequested, this, &AccountListPage::ShowContextMenu); + connect(ui->listView, &VersionListView::activated, this, + [this](const QModelIndex& index) { m_accounts->setDefaultAccount(m_accounts->at(index.row())); }); + + connect(m_accounts, &AccountList::listChanged, this, &AccountListPage::listChanged); + connect(m_accounts, &AccountList::listActivityChanged, this, &AccountListPage::listChanged); + connect(m_accounts, &AccountList::defaultAccountChanged, this, &AccountListPage::listChanged); + + updateButtonStates(); + + // Xbox authentication won't work without a client identifier, so disable the button if it is missing + if (~APPLICATION->capabilities() & Application::SupportsMSA) { + ui->actionAddMicrosoft->setVisible(false); + ui->actionAddMicrosoft->setToolTip(tr("No Microsoft Authentication client ID was set.")); + } +} + +AccountListPage::~AccountListPage() +{ + delete ui; +} + +void AccountListPage::retranslate() +{ + ui->retranslateUi(this); +} + +void AccountListPage::ShowContextMenu(const QPoint& pos) +{ + auto menu = ui->toolBar->createContextMenu(this, tr("Context menu")); + menu->exec(ui->listView->mapToGlobal(pos)); + delete menu; +} + +void AccountListPage::changeEvent(QEvent* event) +{ + if (event->type() == QEvent::LanguageChange) { + ui->retranslateUi(this); + } + QMainWindow::changeEvent(event); +} + +QMenu* AccountListPage::createPopupMenu() +{ + QMenu* filteredMenu = QMainWindow::createPopupMenu(); + filteredMenu->removeAction(ui->toolBar->toggleViewAction()); + return filteredMenu; +} + +void AccountListPage::listChanged() +{ + updateButtonStates(); +} + +void AccountListPage::on_actionAddMicrosoft_triggered() +{ + auto account = MSALoginDialog::newAccount(this); + if (account) { + m_accounts->addAccount(account); + if (m_accounts->count() == 1) { + m_accounts->setDefaultAccount(account); + } + } +} + +void AccountListPage::on_actionAddOffline_triggered() +{ + if (!m_accounts->anyAccountIsValid()) { + QMessageBox::warning(this, tr("Error"), + tr("You must add a Microsoft account that owns Minecraft before you can add an offline account." + "

    " + "If you have lost your account you can contact Microsoft for support.")); + return; + } + + ChooseOfflineNameDialog dialog(tr("Please enter your desired username to add your offline account."), this); + if (dialog.exec() != QDialog::Accepted) { + return; + } + + if (const MinecraftAccountPtr account = MinecraftAccount::createOffline(dialog.getUsername())) { + account->login()->start(); // The task will complete here. + m_accounts->addAccount(account); + if (m_accounts->count() == 1) { + m_accounts->setDefaultAccount(account); + } + } +} + +void AccountListPage::on_actionRemove_triggered() +{ + auto response = CustomMessageBox::selectable(this, tr("Remove account?"), tr("Do you really want to delete this account?"), + QMessageBox::Question, QMessageBox::Yes | QMessageBox::No, QMessageBox::No) + ->exec(); + if (response != QMessageBox::Yes) { + return; + } + QModelIndexList selection = ui->listView->selectionModel()->selectedIndexes(); + if (selection.size() > 0) { + QModelIndex selected = selection.first(); + m_accounts->removeAccount(selected); + } +} + +void AccountListPage::on_actionRefresh_triggered() +{ + QModelIndexList selection = ui->listView->selectionModel()->selectedIndexes(); + if (selection.size() > 0) { + QModelIndex selected = selection.first(); + MinecraftAccountPtr account = selected.data(AccountList::PointerRole).value(); + m_accounts->requestRefresh(account->internalId()); + } +} + +void AccountListPage::on_actionSetDefault_triggered() +{ + QModelIndexList selection = ui->listView->selectionModel()->selectedIndexes(); + if (selection.size() > 0) { + QModelIndex selected = selection.first(); + MinecraftAccountPtr account = selected.data(AccountList::PointerRole).value(); + m_accounts->setDefaultAccount(account); + } +} + +void AccountListPage::on_actionNoDefault_triggered() +{ + m_accounts->setDefaultAccount(nullptr); +} + +void AccountListPage::updateButtonStates() +{ + // If there is no selection, disable buttons that require something selected. + QModelIndexList selection = ui->listView->selectionModel()->selectedIndexes(); + bool hasSelection = !selection.empty(); + bool accountIsReady = false; + bool accountIsOnline = false; + bool accountCanMoveUp = false; + bool accountCanMoveDown = false; + if (hasSelection) { + QModelIndex selected = selection.first(); + MinecraftAccountPtr account = selected.data(AccountList::PointerRole).value(); + accountIsReady = !account->isActive(); + accountIsOnline = account->accountType() != AccountType::Offline; + + accountCanMoveUp = selected.row() > 0; + int indexOfLast = m_accounts->count() - 1; + accountCanMoveDown = selected.row() < indexOfLast; + } + ui->actionRemove->setEnabled(accountIsReady); + ui->actionSetDefault->setEnabled(accountIsReady); + ui->actionManageSkins->setEnabled(accountIsReady && accountIsOnline); + ui->actionRefresh->setEnabled(accountIsReady && accountIsOnline); + + if (m_accounts->defaultAccount().get() == nullptr) { + ui->actionNoDefault->setEnabled(false); + ui->actionNoDefault->setChecked(true); + } else { + ui->actionNoDefault->setEnabled(true); + ui->actionNoDefault->setChecked(false); + } + ui->actionMoveUp->setEnabled(accountCanMoveUp); + ui->actionMoveDown->setEnabled(accountCanMoveDown); + ui->listView->resizeColumnToContents(3); +} + +void AccountListPage::on_actionManageSkins_triggered() +{ + QModelIndexList selection = ui->listView->selectionModel()->selectedIndexes(); + if (selection.size() > 0) { + QModelIndex selected = selection.first(); + MinecraftAccountPtr account = selected.data(AccountList::PointerRole).value(); + SkinManageDialog dialog(this, account); + dialog.exec(); + } +} + +void AccountListPage::on_actionMoveUp_triggered() +{ + QModelIndexList selection = ui->listView->selectionModel()->selectedIndexes(); + if (selection.size() > 0) { + QModelIndex selected = selection.first(); + m_accounts->moveAccount(selected, -1); + } +} + +void AccountListPage::on_actionMoveDown_triggered() +{ + QModelIndexList selection = ui->listView->selectionModel()->selectedIndexes(); + if (selection.size() > 0) { + QModelIndex selected = selection.first(); + m_accounts->moveAccount(selected, 1); + } +} diff --git a/launcher/ui/pages/global/AccountListPage.h b/launcher/ui/pages/global/AccountListPage.h new file mode 100644 index 0000000..bee56cb --- /dev/null +++ b/launcher/ui/pages/global/AccountListPage.h @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +#include "ui/pages/BasePage.h" + +#include "minecraft/auth/AccountList.h" + +namespace Ui { +class AccountListPage; +} + +class AuthenticateTask; + +class AccountListPage : public QMainWindow, public BasePage { + Q_OBJECT + public: + explicit AccountListPage(QWidget* parent = 0); + ~AccountListPage(); + + QString displayName() const override { return tr("Accounts"); } + QIcon icon() const override + { + auto icon = QIcon::fromTheme("accounts"); + if (icon.isNull()) { + icon = QIcon::fromTheme("noaccount"); + } + return icon; + } + QString id() const override { return "accounts"; } + QString helpPage() const override { return "getting-started/adding-an-account"; } + void retranslate() override; + + public slots: + void on_actionAddMicrosoft_triggered(); + void on_actionAddOffline_triggered(); + void on_actionRemove_triggered(); + void on_actionRefresh_triggered(); + void on_actionSetDefault_triggered(); + void on_actionNoDefault_triggered(); + void on_actionManageSkins_triggered(); + void on_actionMoveUp_triggered(); + void on_actionMoveDown_triggered(); + + void listChanged(); + + //! Updates the states of the dialog's buttons. + void updateButtonStates(); + + protected slots: + void ShowContextMenu(const QPoint& pos); + + private: + void changeEvent(QEvent* event) override; + QMenu* createPopupMenu() override; + AccountList* m_accounts; + Ui::AccountListPage* ui; +}; diff --git a/launcher/ui/pages/global/AccountListPage.ui b/launcher/ui/pages/global/AccountListPage.ui new file mode 100644 index 0000000..6fa004e --- /dev/null +++ b/launcher/ui/pages/global/AccountListPage.ui @@ -0,0 +1,135 @@ + + + AccountListPage + + + + 0 + 0 + 800 + 600 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + true + + + false + + + false + + + true + + + false + + + + + + + + RightToolBarArea + + + false + + + + + + + + + + + + + + + &Set Default + + + + + true + + + &No Default + + + + + &Manage Skins + + + Manage Skins + + + + + &Add Microsoft + + + + + Add &Offline + + + + + &Refresh + + + Refresh the account tokens + + + + + Remo&ve + + + + + Move &Up + + + + + Move &Down + + + + + + VersionListView + QTreeView +
    ui/widgets/VersionListView.h
    +
    + + WideBar + QToolBar +
    ui/widgets/WideBar.h
    +
    +
    + + +
    diff --git a/launcher/ui/pages/global/AppearancePage.h b/launcher/ui/pages/global/AppearancePage.h new file mode 100644 index 0000000..2220db2 --- /dev/null +++ b/launcher/ui/pages/global/AppearancePage.h @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include "java/JavaChecker.h" +#include "translations/TranslationsModel.h" +#include "ui/pages/BasePage.h" +#include "ui/widgets/AppearanceWidget.h" + +class QTextCharFormat; +class SettingsObject; + +class AppearancePage : public AppearanceWidget, public BasePage { + Q_OBJECT + + public: + explicit AppearancePage(QWidget* parent = nullptr) : AppearanceWidget(false, parent) { layout()->setContentsMargins(0, 0, 6, 0); } + + QString displayName() const override { return tr("Appearance"); } + QIcon icon() const override { return QIcon::fromTheme("appearance"); } + QString id() const override { return "appearance-settings"; } + QString helpPage() const override { return "Launcher-settings"; } + + bool apply() override + { + applySettings(); + return true; + } + + void retranslate() override { retranslateUi(); } +}; diff --git a/launcher/ui/pages/global/ExternalToolsPage.cpp b/launcher/ui/pages/global/ExternalToolsPage.cpp new file mode 100644 index 0000000..470704d --- /dev/null +++ b/launcher/ui/pages/global/ExternalToolsPage.cpp @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ExternalToolsPage.h" +#include "ui_ExternalToolsPage.h" + +#include +#include +#include +#include + +#include +#include +#include "Application.h" +#include "settings/SettingsObject.h" +#include "tools/BaseProfiler.h" + +ExternalToolsPage::ExternalToolsPage(QWidget* parent) : QWidget(parent), ui(new Ui::ExternalToolsPage) +{ + ui->setupUi(this); + + ui->jsonEditorTextBox->setClearButtonEnabled(true); + + ui->mceditLink->setOpenExternalLinks(true); + ui->jvisualvmLink->setOpenExternalLinks(true); + ui->jprofilerLink->setOpenExternalLinks(true); + loadSettings(); +} + +ExternalToolsPage::~ExternalToolsPage() +{ + delete ui; +} + +void ExternalToolsPage::loadSettings() +{ + auto s = APPLICATION->settings(); + ui->jprofilerPathEdit->setText(s->get("JProfilerPath").toString()); + ui->jvisualvmPathEdit->setText(s->get("JVisualVMPath").toString()); + ui->mceditPathEdit->setText(s->get("MCEditPath").toString()); + + // Editors + ui->jsonEditorTextBox->setText(s->get("JsonEditor").toString()); +} +void ExternalToolsPage::applySettings() +{ + auto s = APPLICATION->settings(); + + s->set("JProfilerPath", ui->jprofilerPathEdit->text()); + s->set("JVisualVMPath", ui->jvisualvmPathEdit->text()); + s->set("MCEditPath", ui->mceditPathEdit->text()); + + // Editors + QString jsonEditor = ui->jsonEditorTextBox->text(); + if (!jsonEditor.isEmpty() && (!QFileInfo(jsonEditor).exists() || !QFileInfo(jsonEditor).isExecutable())) { + QString found = QStandardPaths::findExecutable(jsonEditor); + if (!found.isEmpty()) { + jsonEditor = found; + } + } + s->set("JsonEditor", jsonEditor); +} + +void ExternalToolsPage::on_jprofilerPathBtn_clicked() +{ + QString raw_dir = ui->jprofilerPathEdit->text(); + QString error; + do { + raw_dir = QFileDialog::getExistingDirectory(this, tr("JProfiler Folder"), raw_dir); + if (raw_dir.isEmpty()) { + break; + } + QString cooked_dir = FS::NormalizePath(raw_dir); + if (!APPLICATION->profilers()["jprofiler"]->check(cooked_dir, &error)) { + QMessageBox::critical(this, tr("Error"), tr("Error while checking JProfiler install:\n%1").arg(error)); + continue; + } else { + ui->jprofilerPathEdit->setText(cooked_dir); + break; + } + } while (1); +} +void ExternalToolsPage::on_jprofilerCheckBtn_clicked() +{ + QString error; + if (!APPLICATION->profilers()["jprofiler"]->check(ui->jprofilerPathEdit->text(), &error)) { + QMessageBox::critical(this, tr("Error"), tr("Error while checking JProfiler install:\n%1").arg(error)); + } else { + QMessageBox::information(this, tr("OK"), tr("JProfiler setup seems to be OK")); + } +} + +void ExternalToolsPage::on_jvisualvmPathBtn_clicked() +{ + QString raw_dir = ui->jvisualvmPathEdit->text(); + QString error; + do { + raw_dir = QFileDialog::getOpenFileName(this, tr("VisualVM Executable"), raw_dir); + if (raw_dir.isEmpty()) { + break; + } + QString cooked_dir = FS::NormalizePath(raw_dir); + if (!APPLICATION->profilers()["jvisualvm"]->check(cooked_dir, &error)) { + QMessageBox::critical(this, tr("Error"), tr("Error while checking VisualVM install:\n%1").arg(error)); + continue; + } else { + ui->jvisualvmPathEdit->setText(cooked_dir); + break; + } + } while (1); +} +void ExternalToolsPage::on_jvisualvmCheckBtn_clicked() +{ + QString error; + if (!APPLICATION->profilers()["jvisualvm"]->check(ui->jvisualvmPathEdit->text(), &error)) { + QMessageBox::critical(this, tr("Error"), tr("Error while checking VisualVM install:\n%1").arg(error)); + } else { + QMessageBox::information(this, tr("OK"), tr("VisualVM setup seems to be OK")); + } +} + +void ExternalToolsPage::on_mceditPathBtn_clicked() +{ + QString raw_dir = ui->mceditPathEdit->text(); + QString error; + do { +#ifdef Q_OS_MACOS + raw_dir = QFileDialog::getOpenFileName(this, tr("MCEdit Application"), raw_dir); +#else + raw_dir = QFileDialog::getExistingDirectory(this, tr("MCEdit Folder"), raw_dir); +#endif + if (raw_dir.isEmpty()) { + break; + } + QString cooked_dir = FS::NormalizePath(raw_dir); + if (!APPLICATION->mcedit()->check(cooked_dir, error)) { + QMessageBox::critical(this, tr("Error"), tr("Error while checking MCEdit install:\n%1").arg(error)); + continue; + } else { + ui->mceditPathEdit->setText(cooked_dir); + break; + } + } while (1); +} +void ExternalToolsPage::on_mceditCheckBtn_clicked() +{ + QString error; + if (!APPLICATION->mcedit()->check(ui->mceditPathEdit->text(), error)) { + QMessageBox::critical(this, tr("Error"), tr("Error while checking MCEdit install:\n%1").arg(error)); + } else { + QMessageBox::information(this, tr("OK"), tr("MCEdit setup seems to be OK")); + } +} + +void ExternalToolsPage::on_jsonEditorBrowseBtn_clicked() +{ + QString raw_file = QFileDialog::getOpenFileName(this, tr("Text Editor"), + ui->jsonEditorTextBox->text().isEmpty() +#if defined(Q_OS_LINUX) + ? QString("/usr/bin") +#else + ? QStandardPaths::standardLocations(QStandardPaths::ApplicationsLocation).first() +#endif + : ui->jsonEditorTextBox->text()); + + if (raw_file.isEmpty()) { + return; + } + QString cooked_file = FS::NormalizePath(raw_file); + + // it has to exist and be an executable + if (QFileInfo(cooked_file).exists() && QFileInfo(cooked_file).isExecutable()) { + ui->jsonEditorTextBox->setText(cooked_file); + } else { + QMessageBox::warning(this, tr("Invalid"), tr("The file chosen does not seem to be an executable")); + } +} + +bool ExternalToolsPage::apply() +{ + applySettings(); + return true; +} + +void ExternalToolsPage::retranslate() +{ + ui->retranslateUi(this); +} diff --git a/launcher/ui/pages/global/ExternalToolsPage.h b/launcher/ui/pages/global/ExternalToolsPage.h new file mode 100644 index 0000000..702ace5 --- /dev/null +++ b/launcher/ui/pages/global/ExternalToolsPage.h @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +#include "ui/pages/BasePage.h" + +namespace Ui { +class ExternalToolsPage; +} + +class ExternalToolsPage : public QWidget, public BasePage { + Q_OBJECT + + public: + explicit ExternalToolsPage(QWidget* parent = 0); + ~ExternalToolsPage(); + + QString displayName() const override { return tr("Tools"); } + QIcon icon() const override + { + auto icon = QIcon::fromTheme("externaltools"); + if (icon.isNull()) { + icon = QIcon::fromTheme("loadermods"); + } + return icon; + } + QString id() const override { return "external-tools"; } + QString helpPage() const override { return "Tools"; } + virtual bool apply() override; + void retranslate() override; + + private: + void loadSettings(); + void applySettings(); + + private: + Ui::ExternalToolsPage* ui; + + private slots: + void on_jprofilerPathBtn_clicked(); + void on_jprofilerCheckBtn_clicked(); + void on_jvisualvmPathBtn_clicked(); + void on_jvisualvmCheckBtn_clicked(); + void on_mceditPathBtn_clicked(); + void on_mceditCheckBtn_clicked(); + void on_jsonEditorBrowseBtn_clicked(); +}; diff --git a/launcher/ui/pages/global/ExternalToolsPage.ui b/launcher/ui/pages/global/ExternalToolsPage.ui new file mode 100644 index 0000000..b094e36 --- /dev/null +++ b/launcher/ui/pages/global/ExternalToolsPage.ui @@ -0,0 +1,301 @@ + + + ExternalToolsPage + + + + 0 + 0 + 673 + 823 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + true + + + + + 0 + 0 + 669 + 819 + + + + + + + &Editors + + + + + + &Text Editor + + + jsonEditorTextBox + + + + + + + + + + + + Browse + + + + + + + + + Used to edit component JSON files. + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 0 + 6 + + + + + + + + &MCEdit + + + mceditPathEdit + + + + + + + + + + + + Browse + + + + + + + + + + 0 + 0 + + + + Check + + + + + + + <html><head/><body><p><a href="https://www.mcedit.net/">MCEdit Website</a> - Used as world editor in the instance Worlds menu.</p></body></html> + + + + + + + + + + &Profilers + + + + + + Profilers are accessible through the Launch dropdown menu. + + + jsonEditorTextBox + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 0 + 6 + + + + + + + + J&Profiler + + + jprofilerPathEdit + + + + + + + + + + + + Browse + + + + + + + + + + 0 + 0 + + + + Check + + + + + + + <html><head/><body><p><a href="https://www.ej-technologies.com/products/jprofiler/overview.html">JProfiler Website</a></p></body></html> + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 0 + 6 + + + + + + + + &VisualVM + + + jvisualvmPathEdit + + + + + + + + + + + + Browse + + + + + + + + + + 0 + 0 + + + + Check + + + + + + + <html><head/><body><p><a href="https://visualvm.github.io/">VisualVM Website</a></p></body></html> + + + + + + + + + + Qt::Vertical + + + + 0 + 0 + + + + + + + + + + + + + diff --git a/launcher/ui/pages/global/JavaPage.cpp b/launcher/ui/pages/global/JavaPage.cpp new file mode 100644 index 0000000..d780ad5 --- /dev/null +++ b/launcher/ui/pages/global/JavaPage.cpp @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "JavaPage.h" +#include "BuildConfig.h" +#include "JavaCommon.h" +#include "java/JavaInstall.h" +#include "ui/dialogs/CustomMessageBox.h" +#include "ui/java/InstallJavaDialog.h" +#include "ui_JavaPage.h" + +#include +#include +#include +#include +#include +#include + +#include "ui/dialogs/VersionSelectDialog.h" + +#include "java/JavaInstallList.h" +#include "java/JavaUtils.h" + +#include +#include "Application.h" +#include "settings/SettingsObject.h" + +JavaPage::JavaPage(QWidget* parent) : QWidget(parent), ui(new Ui::JavaPage) +{ + ui->setupUi(this); + + if (BuildConfig.JAVA_DOWNLOADER_ENABLED) { + ui->managedJavaList->initialize(new JavaInstallList(this, true)); + ui->managedJavaList->setResizeOn(2); + ui->managedJavaList->selectCurrent(); + ui->managedJavaList->setEmptyString(tr("No managed Java versions are installed")); + ui->managedJavaList->setEmptyErrorString(tr("Couldn't load the managed Java list!")); + } else + ui->tabWidget->tabBar()->hide(); +} + +JavaPage::~JavaPage() +{ + delete ui; +} + +void JavaPage::retranslate() +{ + ui->retranslateUi(this); +} + +bool JavaPage::apply() +{ + ui->javaSettings->saveSettings(); + JavaCommon::checkJVMArgs(APPLICATION->settings()->get("JvmArgs").toString(), this); + return true; +} + +void JavaPage::on_downloadJavaButton_clicked() +{ + auto jdialog = new Java::InstallDialog({}, nullptr, this); + jdialog->exec(); + ui->managedJavaList->loadList(); +} + +void JavaPage::on_removeJavaButton_clicked() +{ + auto version = ui->managedJavaList->selectedVersion(); + auto dcast = std::dynamic_pointer_cast(version); + if (!dcast) { + return; + } + QDir dir(APPLICATION->javaPath()); + + auto entries = dir.entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot); + for (auto& entry : entries) { + if (dcast->path.startsWith(entry.canonicalFilePath())) { + auto response = CustomMessageBox::selectable(this, tr("Confirm Deletion"), + tr("You are about to remove the Java installation named \"%1\".\n" + "Are you sure?") + .arg(entry.fileName()), + QMessageBox::Warning, QMessageBox::Yes | QMessageBox::No, QMessageBox::No) + ->exec(); + + if (response == QMessageBox::Yes) { + FS::deletePath(entry.canonicalFilePath()); + ui->managedJavaList->loadList(); + } + break; + } + } +} +void JavaPage::on_refreshJavaButton_clicked() +{ + ui->managedJavaList->loadList(); +} diff --git a/launcher/ui/pages/global/JavaPage.h b/launcher/ui/pages/global/JavaPage.h new file mode 100644 index 0000000..79a3d1b --- /dev/null +++ b/launcher/ui/pages/global/JavaPage.h @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include "JavaCommon.h" +#include "ui/pages/BasePage.h" +#include "ui/widgets/JavaSettingsWidget.h" + +class SettingsObject; + +namespace Ui { +class JavaPage; +} + +class JavaPage : public QWidget, public BasePage { + Q_OBJECT + + public: + explicit JavaPage(QWidget* parent = 0); + ~JavaPage(); + + QString displayName() const override { return tr("Java"); } + QIcon icon() const override { return QIcon::fromTheme("java"); } + QString id() const override { return "java-settings"; } + QString helpPage() const override { return "Java-settings"; } + void retranslate() override; + + bool apply() override; + + private slots: + void on_downloadJavaButton_clicked(); + void on_removeJavaButton_clicked(); + void on_refreshJavaButton_clicked(); + + private: + Ui::JavaPage* ui; +}; diff --git a/launcher/ui/pages/global/JavaPage.ui b/launcher/ui/pages/global/JavaPage.ui new file mode 100644 index 0000000..3ed28cf --- /dev/null +++ b/launcher/ui/pages/global/JavaPage.ui @@ -0,0 +1,153 @@ + + + JavaPage + + + + 0 + 0 + 559 + 659 + + + + + 0 + 0 + + + + + 0 + + + 0 + + + 6 + + + 0 + + + + + 0 + + + + General + + + + + + true + + + + + 0 + 0 + 535 + 612 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + + + + Installations + + + + + + + + Download + + + + + + + Remove + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Refresh + + + + + + + + + + 0 + 0 + + + + + + + + + + + + + VersionSelectWidget + QWidget +
    ui/widgets/VersionSelectWidget.h
    + 1 +
    + + JavaSettingsWidget + QWidget +
    ui/widgets/JavaSettingsWidget.h
    + 1 +
    +
    + + +
    diff --git a/launcher/ui/pages/global/LanguagePage.cpp b/launcher/ui/pages/global/LanguagePage.cpp new file mode 100644 index 0000000..94c5827 --- /dev/null +++ b/launcher/ui/pages/global/LanguagePage.cpp @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "LanguagePage.h" + +#include +#include "Application.h" +#include "settings/SettingsObject.h" +#include "ui/widgets/LanguageSelectionWidget.h" + +LanguagePage::LanguagePage(QWidget* parent) : QWidget(parent) +{ + setObjectName(QStringLiteral("languagePage")); + auto layout = new QVBoxLayout(this); + mainWidget = new LanguageSelectionWidget(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->addWidget(mainWidget); + retranslate(); +} + +LanguagePage::~LanguagePage() {} + +bool LanguagePage::apply() +{ + applySettings(); + return true; +} + +void LanguagePage::applySettings() +{ + auto settings = APPLICATION->settings(); + QString key = mainWidget->getSelectedLanguageKey(); + settings->set("Language", key); +} + +void LanguagePage::loadSettings() +{ + // NIL +} + +void LanguagePage::retranslate() +{ + mainWidget->retranslate(); +} diff --git a/launcher/ui/pages/global/LanguagePage.h b/launcher/ui/pages/global/LanguagePage.h new file mode 100644 index 0000000..b376e1c --- /dev/null +++ b/launcher/ui/pages/global/LanguagePage.h @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include "ui/pages/BasePage.h" + +class LanguageSelectionWidget; + +class LanguagePage : public QWidget, public BasePage { + Q_OBJECT + + public: + explicit LanguagePage(QWidget* parent = 0); + virtual ~LanguagePage(); + + QString displayName() const override { return tr("Language"); } + QIcon icon() const override { return QIcon::fromTheme("language"); } + QString id() const override { return "language-settings"; } + QString helpPage() const override { return "Language-settings"; } + bool apply() override; + + void retranslate() override; + + private: + void applySettings(); + void loadSettings(); + + private: + LanguageSelectionWidget* mainWidget; +}; diff --git a/launcher/ui/pages/global/LauncherPage.cpp b/launcher/ui/pages/global/LauncherPage.cpp new file mode 100644 index 0000000..d6d15a2 --- /dev/null +++ b/launcher/ui/pages/global/LauncherPage.cpp @@ -0,0 +1,304 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * Copyright (c) 2022 dada513 + * Copyright (C) 2022 Tayou + * Copyright (C) 2024 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "LauncherPage.h" +#include "ui_LauncherPage.h" + +#include +#include +#include +#include +#include + +#include +#include "Application.h" +#include "BuildConfig.h" +#include "DesktopServices.h" +#include "settings/SettingsObject.h" +#include "ui/themes/ITheme.h" +#include "ui/themes/ThemeManager.h" +#include "updater/ExternalUpdater.h" + +#include +#include + +// FIXME: possibly move elsewhere +enum InstSortMode { + // Sort alphabetically by name. + Sort_Name, + // Sort by which instance was launched most recently. + Sort_LastLaunch +}; + +LauncherPage::LauncherPage(QWidget* parent) : QWidget(parent), ui(new Ui::LauncherPage) +{ + ui->setupUi(this); + + ui->sortingModeGroup->setId(ui->sortByNameBtn, Sort_Name); + ui->sortingModeGroup->setId(ui->sortLastLaunchedBtn, Sort_LastLaunch); + + loadSettings(); + + ui->updateSettingsBox->setHidden(!APPLICATION->updater()); +} + +LauncherPage::~LauncherPage() +{ + delete ui; +} + +bool LauncherPage::apply() +{ + applySettings(); + return true; +} + +void LauncherPage::on_instDirBrowseBtn_clicked() +{ + QString raw_dir = QFileDialog::getExistingDirectory(this, tr("Instance Folder"), ui->instDirTextBox->text()); + + // do not allow current dir - it's dirty. Do not allow dirs that don't exist + if (!raw_dir.isEmpty() && QDir(raw_dir).exists()) { + QString cooked_dir = FS::NormalizePath(raw_dir); + if (FS::checkProblemticPathJava(QDir(cooked_dir))) { + QMessageBox warning; + warning.setText( + tr("You're trying to specify an instance folder which\'s path " + "contains at least one \'!\'. " + "Java is known to cause problems if that is the case, your " + "instances (probably) won't start!")); + warning.setInformativeText( + tr("Do you really want to use this path? " + "Selecting \"No\" will close this and not alter your instance path.")); + warning.setStandardButtons(QMessageBox::Ok | QMessageBox::Cancel); + int result = warning.exec(); + if (result == QMessageBox::Ok) { + ui->instDirTextBox->setText(cooked_dir); + } + } else if (DesktopServices::isFlatpak() && raw_dir.startsWith("/run/user")) { + QMessageBox warning; + warning.setText(tr("You're trying to specify an instance folder " + "which was granted temporarily via Flatpak.\n" + "This is known to cause problems. " + "After a restart the launcher might break, " + "because it will no longer have access to that directory.\n\n" + "Granting %1 access to it via Flatseal is recommended.") + .arg(BuildConfig.LAUNCHER_DISPLAYNAME)); + warning.setInformativeText(tr("Do you want to proceed anyway?")); + warning.setStandardButtons(QMessageBox::Ok | QMessageBox::Cancel); + int result = warning.exec(); + if (result == QMessageBox::Ok) { + ui->instDirTextBox->setText(cooked_dir); + } + } else { + ui->instDirTextBox->setText(cooked_dir); + } + } +} + +void LauncherPage::on_iconsDirBrowseBtn_clicked() +{ + QString raw_dir = QFileDialog::getExistingDirectory(this, tr("Icons Folder"), ui->iconsDirTextBox->text()); + + // do not allow current dir - it's dirty. Do not allow dirs that don't exist + if (!raw_dir.isEmpty() && QDir(raw_dir).exists()) { + QString cooked_dir = FS::NormalizePath(raw_dir); + ui->iconsDirTextBox->setText(cooked_dir); + } +} + +void LauncherPage::on_modsDirBrowseBtn_clicked() +{ + QString raw_dir = QFileDialog::getExistingDirectory(this, tr("Mods Folder"), ui->modsDirTextBox->text()); + + // do not allow current dir - it's dirty. Do not allow dirs that don't exist + if (!raw_dir.isEmpty() && QDir(raw_dir).exists()) { + QString cooked_dir = FS::NormalizePath(raw_dir); + ui->modsDirTextBox->setText(cooked_dir); + } +} + +void LauncherPage::on_downloadsDirBrowseBtn_clicked() +{ + QString raw_dir = QFileDialog::getExistingDirectory(this, tr("Downloads Folder"), ui->downloadsDirTextBox->text()); + + if (!raw_dir.isEmpty() && QDir(raw_dir).exists()) { + QString cooked_dir = FS::NormalizePath(raw_dir); + ui->downloadsDirTextBox->setText(cooked_dir); + } +} + +void LauncherPage::on_javaDirBrowseBtn_clicked() +{ + QString raw_dir = QFileDialog::getExistingDirectory(this, tr("Java Folder"), ui->javaDirTextBox->text()); + + if (!raw_dir.isEmpty() && QDir(raw_dir).exists()) { + QString cooked_dir = FS::NormalizePath(raw_dir); + ui->javaDirTextBox->setText(cooked_dir); + } +} + +void LauncherPage::on_skinsDirBrowseBtn_clicked() +{ + QString raw_dir = QFileDialog::getExistingDirectory(this, tr("Skins Folder"), ui->skinsDirTextBox->text()); + + // do not allow current dir - it's dirty. Do not allow dirs that don't exist + if (!raw_dir.isEmpty() && QDir(raw_dir).exists()) { + QString cooked_dir = FS::NormalizePath(raw_dir); + ui->skinsDirTextBox->setText(cooked_dir); + } +} + +void LauncherPage::on_metadataEnableBtn_clicked() +{ + ui->metadataWarningLabel->setHidden(ui->metadataEnableBtn->isChecked()); +} + +void LauncherPage::applySettings() +{ + auto s = APPLICATION->settings(); + + // Updates + if (APPLICATION->updater()) { + APPLICATION->updater()->setAutomaticallyChecksForUpdates(ui->autoUpdateCheckBox->isChecked()); + APPLICATION->updater()->setUpdateCheckInterval(ui->updateIntervalSpinBox->value() * 3600); + } + + s->set("MenuBarInsteadOfToolBar", ui->preferMenuBarCheckBox->isChecked()); + + s->set("NumberOfConcurrentTasks", ui->numberOfConcurrentTasksSpinBox->value()); + s->set("NumberOfConcurrentDownloads", ui->numberOfConcurrentDownloadsSpinBox->value()); + s->set("NumberOfManualRetries", ui->numberOfManualRetriesSpinBox->value()); + s->set("RequestTimeout", ui->timeoutSecondsSpinBox->value()); + + // Console settings + s->set("ConsoleMaxLines", ui->lineLimitSpinBox->value()); + s->set("ConsoleOverflowStop", ui->checkStopLogging->checkState() != Qt::Unchecked); + + // Folders + // TODO: Offer to move instances to new instance folder. + s->set("InstanceDir", ui->instDirTextBox->text()); + s->set("CentralModsDir", ui->modsDirTextBox->text()); + s->set("IconsDir", ui->iconsDirTextBox->text()); + s->set("DownloadsDir", ui->downloadsDirTextBox->text()); + s->set("SkinsDir", ui->skinsDirTextBox->text()); + s->set("JavaDir", ui->javaDirTextBox->text()); + s->set("DownloadsDirWatchRecursive", ui->downloadsDirWatchRecursiveCheckBox->isChecked()); + s->set("MoveModsFromDownloadsDir", ui->downloadsDirMoveCheckBox->isChecked()); + + // Instance + auto sortMode = (InstSortMode)ui->sortingModeGroup->checkedId(); + switch (sortMode) { + case Sort_LastLaunch: + s->set("InstSortMode", "LastLaunch"); + break; + case Sort_Name: + default: + s->set("InstSortMode", "Name"); + break; + } + + if (ui->askToRenameDirBtn->isChecked()) { + s->set("InstRenamingMode", "AskEverytime"); + } else if (ui->alwaysRenameDirBtn->isChecked()) { + s->set("InstRenamingMode", "PhysicalDir"); + } else if (ui->neverRenameDirBtn->isChecked()) { + s->set("InstRenamingMode", "MetadataOnly"); + } + + // Mods + s->set("ModMetadataDisabled", !ui->metadataEnableBtn->isChecked()); + s->set("ModDependenciesDisabled", !ui->dependenciesEnableBtn->isChecked()); + s->set("ShowModIncompat", ui->showModIncompatCheckBox->isChecked()); + s->set("SkipModpackUpdatePrompt", !ui->modpackUpdatePromptBtn->isChecked()); +} +void LauncherPage::loadSettings() +{ + auto s = APPLICATION->settings(); + // Updates + if (APPLICATION->updater()) { + ui->autoUpdateCheckBox->setChecked(APPLICATION->updater()->getAutomaticallyChecksForUpdates()); + ui->updateIntervalSpinBox->setValue(APPLICATION->updater()->getUpdateCheckInterval() / 3600); + } + + ui->preferMenuBarCheckBox->setChecked(s->get("MenuBarInsteadOfToolBar").toBool()); + + ui->numberOfConcurrentTasksSpinBox->setValue(s->get("NumberOfConcurrentTasks").toInt()); + ui->numberOfConcurrentDownloadsSpinBox->setValue(s->get("NumberOfConcurrentDownloads").toInt()); + ui->numberOfManualRetriesSpinBox->setValue(s->get("NumberOfManualRetries").toInt()); + ui->timeoutSecondsSpinBox->setValue(s->get("RequestTimeout").toInt()); + + // Console settings + ui->lineLimitSpinBox->setValue(s->get("ConsoleMaxLines").toInt()); + ui->checkStopLogging->setChecked(s->get("ConsoleOverflowStop").toBool()); + + // Folders + ui->instDirTextBox->setText(s->get("InstanceDir").toString()); + ui->modsDirTextBox->setText(s->get("CentralModsDir").toString()); + ui->iconsDirTextBox->setText(s->get("IconsDir").toString()); + ui->downloadsDirTextBox->setText(s->get("DownloadsDir").toString()); + ui->skinsDirTextBox->setText(s->get("SkinsDir").toString()); + ui->javaDirTextBox->setText(s->get("JavaDir").toString()); + ui->downloadsDirWatchRecursiveCheckBox->setChecked(s->get("DownloadsDirWatchRecursive").toBool()); + ui->downloadsDirMoveCheckBox->setChecked(s->get("MoveModsFromDownloadsDir").toBool()); + + // Instance + QString sortMode = s->get("InstSortMode").toString(); + if (sortMode == "LastLaunch") { + ui->sortLastLaunchedBtn->setChecked(true); + } else { + ui->sortByNameBtn->setChecked(true); + } + + QString renamingMode = s->get("InstRenamingMode").toString(); + ui->askToRenameDirBtn->setChecked(renamingMode == "AskEverytime"); + ui->alwaysRenameDirBtn->setChecked(renamingMode == "PhysicalDir"); + ui->neverRenameDirBtn->setChecked(renamingMode == "MetadataOnly"); + + // Mods + ui->metadataEnableBtn->setChecked(!s->get("ModMetadataDisabled").toBool()); + ui->metadataWarningLabel->setHidden(ui->metadataEnableBtn->isChecked()); + ui->dependenciesEnableBtn->setChecked(!s->get("ModDependenciesDisabled").toBool()); + ui->showModIncompatCheckBox->setChecked(s->get("ShowModIncompat").toBool()); + ui->modpackUpdatePromptBtn->setChecked(!s->get("SkipModpackUpdatePrompt").toBool()); +} + +void LauncherPage::retranslate() +{ + ui->retranslateUi(this); +} diff --git a/launcher/ui/pages/global/LauncherPage.h b/launcher/ui/pages/global/LauncherPage.h new file mode 100644 index 0000000..263bf08 --- /dev/null +++ b/launcher/ui/pages/global/LauncherPage.h @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +#include +#include "java/JavaChecker.h" +#include "ui/pages/BasePage.h" + +class QTextCharFormat; +class SettingsObject; + +namespace Ui { +class LauncherPage; +} + +class LauncherPage : public QWidget, public BasePage { + Q_OBJECT + + public: + explicit LauncherPage(QWidget* parent = 0); + ~LauncherPage(); + + QString displayName() const override { return tr("General"); } + QIcon icon() const override { return QIcon::fromTheme("settings"); } + QString id() const override { return "launcher-settings"; } + QString helpPage() const override { return "Launcher-settings"; } + bool apply() override; + void retranslate() override; + + private: + void applySettings(); + void loadSettings(); + + private slots: + void on_instDirBrowseBtn_clicked(); + void on_modsDirBrowseBtn_clicked(); + void on_iconsDirBrowseBtn_clicked(); + void on_downloadsDirBrowseBtn_clicked(); + void on_javaDirBrowseBtn_clicked(); + void on_skinsDirBrowseBtn_clicked(); + void on_metadataEnableBtn_clicked(); + + private: + Ui::LauncherPage* ui; +}; diff --git a/launcher/ui/pages/global/LauncherPage.ui b/launcher/ui/pages/global/LauncherPage.ui new file mode 100644 index 0000000..f5cfacf --- /dev/null +++ b/launcher/ui/pages/global/LauncherPage.ui @@ -0,0 +1,690 @@ + + + LauncherPage + + + + 0 + 0 + 767 + 796 + + + + + 0 + 0 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::ScrollBarPolicy::ScrollBarAsNeeded + + + true + + + + + 0 + -149 + 746 + 1222 + + + + + + + true + + + User Interface + + + + + + Instance Sorting + + + + + + + By &name + + + sortingModeGroup + + + + + + + &By last launched + + + sortingModeGroup + + + + + + + Qt::Orientation::Vertical + + + QSizePolicy::Policy::Fixed + + + + 0 + 6 + + + + + + + + Instance Renaming + + + + + + + Ask what to do + + + renamingBehaviorGroup + + + + + + + Always rename the folder + + + renamingBehaviorGroup + + + + + + + Never rename the folder + + + renamingBehaviorGroup + + + + + + + Qt::Orientation::Vertical + + + QSizePolicy::Policy::Fixed + + + + 0 + 6 + + + + + + + + The menubar is more friendly for keyboard-driven interaction. + + + &Replace toolbar with menubar + + + + + + + + + + Updater + + + + + + + + How Often? + + + + + + + + 0 + 0 + + + + Set to 0 to only check on launch + + + On Launch + + + hours + + + Every + + + 0 + + + 168 + + + + + + + Qt::Orientation::Horizontal + + + + 0 + 0 + + + + + + + + + + Check for updates automatically + + + + + + + + + + Folders + + + + + + + + + Browse + + + + + + + &Auto Java Download: + + + Folder where Prism Launcher stores automatically downloaded Java versions. Do NOT set this to your system Java installation. + + + javaDirTextBox + + + + + + + Browse + + + + + + + &Skins: + + + skinsDirTextBox + + + + + + + &Mods: + + + modsDirTextBox + + + + + + + Browse + + + + + + + &Downloads: + + + downloadsDirTextBox + + + + + + + + + + + + + I&nstances: + + + instDirTextBox + + + + + + + Browse + + + + + + + Browse + + + + + + + + + + Browse + + + + + + + + + + + + + &Icons: + + + iconsDirTextBox + + + + + + + + + + Mods and Modpacks + + + + + + When enabled, in addition to the downloads folder, its sub folders will also be searched when looking for resources (e.g. when looking for blocked mods on CurseForge). + + + Check &subfolders for blocked mods + + + + + + + When enabled, it will move blocked resources instead of copying them. + + + Move blocked mods instead of copying them + + + + + + + Store version information provided by mod providers (like Modrinth or CurseForge) for mods. + + + Keep track of mod metadata + + + + + + + <html><head/><body><p><span style=" font-weight:600; color:#f5c211;">Warning</span><span style=" color:#f5c211;">: Disabling mod metadata may also disable some QoL features, such as mod updating!</span></p></body></html> + + + true + + + + + + + Automatically detect, install, and update mod dependencies. + + + Install dependencies automatically + + + + + + + Currently this just shows mods which are not marked as compatible with the current Minecraft version. + + + Detect and show mod incompatibilities (experimental) + + + + + + + When creating a new modpack instance, suggest updating an existing instance instead. + + + Suggest to update an existing instance during modpack installation + + + + + + + + + + Console + + + + Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignVCenter + + + + + + 0 + 0 + + + + Log History &Limit: + + + lineLimitSpinBox + + + + + + + + 0 + 0 + + + + lines + + + 10000 + + + 1000000 + + + 10000 + + + 100000 + + + + + + + &Stop logging when log overflows + + + + + + + + + + Tasks + + + + + + + 0 + 0 + + + + + 60 + 0 + + + + 0 + + + + + + + + 0 + 0 + + + + + 60 + 0 + + + + 1 + + + + + + + + 0 + 0 + + + + + 60 + 0 + + + + s + + + + + + + Retry Limit: + + + + + + + Concurrent Download Limit: + + + + + + + Seconds to wait until the requests are terminated + + + HTTP Timeout: + + + + + + + + 0 + 0 + + + + + 60 + 0 + + + + 1 + + + + + + + Concurrent Task Limit: + + + + + + + Qt::Orientation::Horizontal + + + + 0 + 0 + + + + + + + + + + + Qt::Orientation::Vertical + + + + 0 + 0 + + + + + + + + + + + + scrollArea + preferMenuBarCheckBox + autoUpdateCheckBox + updateIntervalSpinBox + instDirTextBox + instDirBrowseBtn + modsDirTextBox + modsDirBrowseBtn + iconsDirTextBox + iconsDirBrowseBtn + javaDirTextBox + javaDirBrowseBtn + skinsDirTextBox + skinsDirBrowseBtn + downloadsDirTextBox + downloadsDirBrowseBtn + downloadsDirWatchRecursiveCheckBox + downloadsDirMoveCheckBox + metadataEnableBtn + dependenciesEnableBtn + modpackUpdatePromptBtn + lineLimitSpinBox + checkStopLogging + numberOfConcurrentTasksSpinBox + numberOfConcurrentDownloadsSpinBox + numberOfManualRetriesSpinBox + timeoutSecondsSpinBox + + + + + + + + diff --git a/launcher/ui/pages/global/MinecraftPage.h b/launcher/ui/pages/global/MinecraftPage.h new file mode 100644 index 0000000..c21d59a --- /dev/null +++ b/launcher/ui/pages/global/MinecraftPage.h @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +#include "java/JavaChecker.h" +#include "ui/pages/BasePage.h" +#include "ui/widgets/MinecraftSettingsWidget.h" + +class SettingsObject; + +class MinecraftPage : public MinecraftSettingsWidget, public BasePage { + Q_OBJECT + + public: + explicit MinecraftPage(QWidget* parent = nullptr) : MinecraftSettingsWidget(nullptr, parent) {} + ~MinecraftPage() override {} + + QString displayName() const override { return tr("Minecraft"); } + QIcon icon() const override { return QIcon::fromTheme("minecraft"); } + QString id() const override { return "minecraft-settings"; } + QString helpPage() const override { return "Minecraft-settings"; } + bool apply() override + { + saveSettings(); + return true; + } +}; diff --git a/launcher/ui/pages/global/ProxyPage.cpp b/launcher/ui/pages/global/ProxyPage.cpp new file mode 100644 index 0000000..0629cc6 --- /dev/null +++ b/launcher/ui/pages/global/ProxyPage.cpp @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ProxyPage.h" +#include "ui_ProxyPage.h" + +#include +#include + +#include "Application.h" +#include "settings/SettingsObject.h" + +ProxyPage::ProxyPage(QWidget* parent) : QWidget(parent), ui(new Ui::ProxyPage) +{ + ui->setupUi(this); + loadSettings(); + updateCheckboxStuff(); + + connect(ui->proxyGroup, &QButtonGroup::buttonClicked, this, &ProxyPage::proxyGroupChanged); +} + +ProxyPage::~ProxyPage() +{ + delete ui; +} + +bool ProxyPage::apply() +{ + applySettings(); + return true; +} + +void ProxyPage::updateCheckboxStuff() +{ + bool enableEditing = ui->proxyHTTPBtn->isChecked() || ui->proxySOCKS5Btn->isChecked(); + ui->proxyAddrBox->setEnabled(enableEditing); + ui->proxyAuthBox->setEnabled(enableEditing); +} + +void ProxyPage::proxyGroupChanged([[maybe_unused]] QAbstractButton* button) +{ + updateCheckboxStuff(); +} + +void ProxyPage::applySettings() +{ + auto s = APPLICATION->settings(); + + // Proxy + QString proxyType = "None"; + if (ui->proxyDefaultBtn->isChecked()) + proxyType = "Default"; + else if (ui->proxyNoneBtn->isChecked()) + proxyType = "None"; + else if (ui->proxySOCKS5Btn->isChecked()) + proxyType = "SOCKS5"; + else if (ui->proxyHTTPBtn->isChecked()) + proxyType = "HTTP"; + + s->set("ProxyType", proxyType); + s->set("ProxyAddr", ui->proxyAddrEdit->text()); + s->set("ProxyPort", ui->proxyPortEdit->value()); + s->set("ProxyUser", ui->proxyUserEdit->text()); + s->set("ProxyPass", ui->proxyPassEdit->text()); + + APPLICATION->updateProxySettings(proxyType, ui->proxyAddrEdit->text(), ui->proxyPortEdit->value(), ui->proxyUserEdit->text(), + ui->proxyPassEdit->text()); +} +void ProxyPage::loadSettings() +{ + auto s = APPLICATION->settings(); + // Proxy + QString proxyType = s->get("ProxyType").toString(); + if (proxyType == "Default") + ui->proxyDefaultBtn->setChecked(true); + else if (proxyType == "None") + ui->proxyNoneBtn->setChecked(true); + else if (proxyType == "SOCKS5") + ui->proxySOCKS5Btn->setChecked(true); + else if (proxyType == "HTTP") + ui->proxyHTTPBtn->setChecked(true); + + ui->proxyAddrEdit->setText(s->get("ProxyAddr").toString()); + ui->proxyPortEdit->setValue(s->get("ProxyPort").value()); + ui->proxyUserEdit->setText(s->get("ProxyUser").toString()); + ui->proxyPassEdit->setText(s->get("ProxyPass").toString()); +} + +void ProxyPage::retranslate() +{ + ui->retranslateUi(this); +} diff --git a/launcher/ui/pages/global/ProxyPage.h b/launcher/ui/pages/global/ProxyPage.h new file mode 100644 index 0000000..8689a5c --- /dev/null +++ b/launcher/ui/pages/global/ProxyPage.h @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +#include "ui/pages/BasePage.h" + +namespace Ui { +class ProxyPage; +} + +class ProxyPage : public QWidget, public BasePage { + Q_OBJECT + + public: + explicit ProxyPage(QWidget* parent = 0); + ~ProxyPage(); + + QString displayName() const override { return tr("Proxy"); } + QIcon icon() const override { return QIcon::fromTheme("proxy"); } + QString id() const override { return "proxy-settings"; } + QString helpPage() const override { return "Proxy-settings"; } + bool apply() override; + void retranslate() override; + + private slots: + void proxyGroupChanged(QAbstractButton* button); + + private: + void updateCheckboxStuff(); + void applySettings(); + void loadSettings(); + + private: + Ui::ProxyPage* ui; +}; diff --git a/launcher/ui/pages/global/ProxyPage.ui b/launcher/ui/pages/global/ProxyPage.ui new file mode 100644 index 0000000..436a90a --- /dev/null +++ b/launcher/ui/pages/global/ProxyPage.ui @@ -0,0 +1,230 @@ + + + ProxyPage + + + + 0 + 0 + 598 + 617 + + + + + 0 + 0 + + + + + 0 + + + 0 + + + 0 + + + + + This only applies to the launcher. Minecraft does not accept proxy settings. + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter + + + true + + + + + + + Type + + + + + + Uses your system's default proxy settings. + + + Use s&ystem settings + + + proxyGroup + + + + + + + &None + + + proxyGroup + + + + + + + &SOCKS5 + + + proxyGroup + + + + + + + &HTTP + + + proxyGroup + + + + + + + + + + &Address and Port + + + + + + + 0 + 0 + + + + + 300 + 0 + + + + 127.0.0.1 + + + + + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + QAbstractSpinBox::PlusMinus + + + 65535 + + + 8080 + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + Authentication + + + + + + &Username: + + + proxyUserEdit + + + + + + + + + + &Password: + + + proxyPassEdit + + + + + + + QLineEdit::Password + + + + + + + Note: Proxy username and password are stored in plain text inside the launcher's configuration file! + + + true + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + proxyDefaultBtn + proxyNoneBtn + proxySOCKS5Btn + proxyHTTPBtn + proxyAddrEdit + proxyPortEdit + proxyUserEdit + proxyPassEdit + + + + + + + diff --git a/launcher/ui/pages/instance/DataPackPage.cpp b/launcher/ui/pages/instance/DataPackPage.cpp new file mode 100644 index 0000000..fb07a76 --- /dev/null +++ b/launcher/ui/pages/instance/DataPackPage.cpp @@ -0,0 +1,357 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "DataPackPage.h" +#include "minecraft/PackProfile.h" +#include "ui_ExternalResourcesPage.h" + +#include "ui/dialogs/CustomMessageBox.h" +#include "ui/dialogs/ProgressDialog.h" +#include "ui/dialogs/ResourceDownloadDialog.h" +#include "ui/dialogs/ResourceUpdateDialog.h" + +DataPackPage::DataPackPage(BaseInstance* instance, DataPackFolderModel* model, QWidget* parent) + : ExternalResourcesPage(instance, model, parent), m_model(model) +{ + ui->actionDownloadItem->setText(tr("Download Packs")); + ui->actionDownloadItem->setToolTip(tr("Download data packs from online mod platforms")); + ui->actionDownloadItem->setEnabled(true); + ui->actionsToolbar->insertActionBefore(ui->actionAddItem, ui->actionDownloadItem); + + connect(ui->actionDownloadItem, &QAction::triggered, this, &DataPackPage::downloadDataPacks); + + ui->actionUpdateItem->setToolTip(tr("Try to check or update all selected data packs (all data packs if none are selected)")); + connect(ui->actionUpdateItem, &QAction::triggered, this, &DataPackPage::updateDataPacks); + ui->actionsToolbar->insertActionBefore(ui->actionAddItem, ui->actionUpdateItem); + + auto updateMenu = new QMenu(this); + + auto update = updateMenu->addAction(ui->actionUpdateItem->text()); + connect(update, &QAction::triggered, this, &DataPackPage::updateDataPacks); + + updateMenu->addAction(ui->actionResetItemMetadata); + connect(ui->actionResetItemMetadata, &QAction::triggered, this, &DataPackPage::deleteDataPackMetadata); + + ui->actionUpdateItem->setMenu(updateMenu); + + ui->actionChangeVersion->setToolTip(tr("Change a data pack's version.")); + connect(ui->actionChangeVersion, &QAction::triggered, this, &DataPackPage::changeDataPackVersion); + ui->actionsToolbar->insertActionAfter(ui->actionUpdateItem, ui->actionChangeVersion); +} + +void DataPackPage::updateFrame(const QModelIndex& current, [[maybe_unused]] const QModelIndex& previous) +{ + auto sourceCurrent = m_filterModel->mapToSource(current); + int row = sourceCurrent.row(); + auto& dp = m_model->at(row); + ui->frame->updateWithDataPack(dp); +} + +void DataPackPage::downloadDataPacks() +{ + if (m_instance->typeName() != "Minecraft") + return; // this is a null instance or a legacy instance + + m_downloadDialog = new ResourceDownload::DataPackDownloadDialog(this, m_model, m_instance); + connect(this, &QObject::destroyed, m_downloadDialog, &QDialog::close); + connect(m_downloadDialog, &QDialog::finished, this, &DataPackPage::downloadDialogFinished); + + m_downloadDialog->open(); +} + +void DataPackPage::downloadDialogFinished(int result) +{ + if (result) { + auto tasks = new ConcurrentTask(tr("Download Data Packs"), APPLICATION->settings()->get("NumberOfConcurrentDownloads").toInt()); + connect(tasks, &Task::failed, [this, tasks](QString reason) { + CustomMessageBox::selectable(this, tr("Error"), reason, QMessageBox::Critical)->show(); + tasks->deleteLater(); + }); + connect(tasks, &Task::aborted, [this, tasks]() { + CustomMessageBox::selectable(this, tr("Aborted"), tr("Download stopped by user."), QMessageBox::Information)->show(); + tasks->deleteLater(); + }); + connect(tasks, &Task::succeeded, [this, tasks]() { + QStringList warnings = tasks->warnings(); + if (warnings.count()) + CustomMessageBox::selectable(this, tr("Warnings"), warnings.join('\n'), QMessageBox::Warning)->show(); + + tasks->deleteLater(); + }); + + if (m_downloadDialog) { + for (auto& task : m_downloadDialog->getTasks()) { + tasks->addTask(task); + } + } else { + qWarning() << "ResourceDownloadDialog vanished before we could collect tasks!"; + } + + ProgressDialog loadDialog(this); + loadDialog.setSkipButton(true, tr("Abort")); + loadDialog.execWithTask(tasks); + + m_model->update(); + } + if (m_downloadDialog) + m_downloadDialog->deleteLater(); +} + +void DataPackPage::updateDataPacks() +{ + if (m_instance->typeName() != "Minecraft") + return; // this is a null instance or a legacy instance + + if (APPLICATION->settings()->get("ModMetadataDisabled").toBool()) { + QMessageBox::critical(this, tr("Error"), tr("Data pack updates are unavailable when metadata is disabled!")); + return; + } + if (m_instance != nullptr && m_instance->isRunning()) { + auto response = + CustomMessageBox::selectable(this, tr("Confirm Update"), + tr("Updating data packs while the game is running may cause pack duplication and game crashes.\n" + "The old files may not be deleted as they are in use.\n" + "Are you sure you want to do this?"), + QMessageBox::Warning, QMessageBox::Yes | QMessageBox::No, QMessageBox::No) + ->exec(); + + if (response != QMessageBox::Yes) + return; + } + auto selection = m_filterModel->mapSelectionToSource(ui->treeView->selectionModel()->selection()).indexes(); + + auto mods_list = m_model->selectedResources(selection); + bool use_all = mods_list.empty(); + if (use_all) + mods_list = m_model->allResources(); + + ResourceUpdateDialog update_dialog(this, m_instance, m_model, mods_list, false, { ModPlatform::ModLoaderType::DataPack }); + update_dialog.checkCandidates(); + + if (update_dialog.aborted()) { + CustomMessageBox::selectable(this, tr("Aborted"), tr("The data pack updater was aborted!"), QMessageBox::Warning)->show(); + return; + } + if (update_dialog.noUpdates()) { + QString message{ tr("'%1' is up-to-date! :)").arg(mods_list.front()->name()) }; + if (mods_list.size() > 1) { + if (use_all) { + message = tr("All data packs are up-to-date! :)"); + } else { + message = tr("All selected data packs are up-to-date! :)"); + } + } + CustomMessageBox::selectable(this, tr("Update checker"), message)->exec(); + return; + } + + if (update_dialog.exec()) { + auto tasks = new ConcurrentTask("Download Data Packs", APPLICATION->settings()->get("NumberOfConcurrentDownloads").toInt()); + connect(tasks, &Task::failed, [this, tasks](QString reason) { + CustomMessageBox::selectable(this, tr("Error"), reason, QMessageBox::Critical)->show(); + tasks->deleteLater(); + }); + connect(tasks, &Task::aborted, [this, tasks]() { + CustomMessageBox::selectable(this, tr("Aborted"), tr("Download stopped by user."), QMessageBox::Information)->show(); + tasks->deleteLater(); + }); + connect(tasks, &Task::succeeded, [this, tasks]() { + QStringList warnings = tasks->warnings(); + if (warnings.count()) { + CustomMessageBox::selectable(this, tr("Warnings"), warnings.join('\n'), QMessageBox::Warning)->show(); + } + tasks->deleteLater(); + }); + + for (auto task : update_dialog.getTasks()) { + tasks->addTask(task); + } + + ProgressDialog loadDialog(this); + loadDialog.setSkipButton(true, tr("Abort")); + loadDialog.execWithTask(tasks); + + m_model->update(); + } +} + +void DataPackPage::deleteDataPackMetadata() +{ + auto selection = m_filterModel->mapSelectionToSource(ui->treeView->selectionModel()->selection()).indexes(); + auto selectionCount = m_model->selectedDataPacks(selection).length(); + if (selectionCount == 0) + return; + if (selectionCount > 1) { + auto response = CustomMessageBox::selectable(this, tr("Confirm Removal"), + tr("You are about to remove the metadata for %1 data packs.\n" + "Are you sure?") + .arg(selectionCount), + QMessageBox::Warning, QMessageBox::Yes | QMessageBox::No, QMessageBox::No) + ->exec(); + + if (response != QMessageBox::Yes) + return; + } + + m_model->deleteMetadata(selection); +} + +void DataPackPage::changeDataPackVersion() +{ + if (m_instance->typeName() != "Minecraft") + return; // this is a null instance or a legacy instance + + if (APPLICATION->settings()->get("ModMetadataDisabled").toBool()) { + QMessageBox::critical(this, tr("Error"), tr("Data pack updates are unavailable when metadata is disabled!")); + return; + } + + const QModelIndexList rows = ui->treeView->selectionModel()->selectedRows(); + + if (rows.count() != 1) + return; + + Resource& resource = m_model->at(m_filterModel->mapToSource(rows[0]).row()); + + if (resource.metadata() == nullptr) + return; + + ResourceDownload::DataPackDownloadDialog mdownload(this, m_model, m_instance); + mdownload.setResourceMetadata(resource.metadata()); + if (mdownload.exec()) { + auto tasks = new ConcurrentTask("Download Data Packs", APPLICATION->settings()->get("NumberOfConcurrentDownloads").toInt()); + connect(tasks, &Task::failed, [this, tasks](QString reason) { + CustomMessageBox::selectable(this, tr("Error"), reason, QMessageBox::Critical)->show(); + tasks->deleteLater(); + }); + connect(tasks, &Task::aborted, [this, tasks]() { + CustomMessageBox::selectable(this, tr("Aborted"), tr("Download stopped by user."), QMessageBox::Information)->show(); + tasks->deleteLater(); + }); + connect(tasks, &Task::succeeded, [this, tasks]() { + QStringList warnings = tasks->warnings(); + if (warnings.count()) + CustomMessageBox::selectable(this, tr("Warnings"), warnings.join('\n'), QMessageBox::Warning)->show(); + + tasks->deleteLater(); + }); + + for (auto& task : mdownload.getTasks()) { + tasks->addTask(task); + } + + ProgressDialog loadDialog(this); + loadDialog.setSkipButton(true, tr("Abort")); + loadDialog.execWithTask(tasks); + + m_model->update(); + } +} + +GlobalDataPackPage::GlobalDataPackPage(MinecraftInstance* instance, QWidget* parent) : QWidget(parent), m_instance(instance) +{ + auto layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + setLayout(layout); + + connect(instance->settings()->getSetting("GlobalDataPacksEnabled").get(), &Setting::SettingChanged, this, [this] { + updateContent(); + if (m_container != nullptr) + m_container->refreshContainer(); + }); + + connect(instance->settings()->getSetting("GlobalDataPacksPath").get(), &Setting::SettingChanged, this, + &GlobalDataPackPage::updateContent); +} + +QString GlobalDataPackPage::displayName() const +{ + if (m_underlyingPage == nullptr) + return {}; + + return m_underlyingPage->displayName(); +} + +QIcon GlobalDataPackPage::icon() const +{ + if (m_underlyingPage == nullptr) + return {}; + + return m_underlyingPage->icon(); +} + +QString GlobalDataPackPage::helpPage() const +{ + if (m_underlyingPage == nullptr) + return {}; + + return m_underlyingPage->helpPage(); +} + +bool GlobalDataPackPage::shouldDisplay() const +{ + return m_instance->settings()->get("GlobalDataPacksEnabled").toBool(); +} + +bool GlobalDataPackPage::apply() +{ + return m_underlyingPage == nullptr || m_underlyingPage->apply(); +} + +void GlobalDataPackPage::openedImpl() +{ + if (m_underlyingPage != nullptr) + m_underlyingPage->openedImpl(); +} + +void GlobalDataPackPage::closedImpl() +{ + if (m_underlyingPage != nullptr) + m_underlyingPage->closedImpl(); +} + +void GlobalDataPackPage::updateContent() +{ + if (m_underlyingPage != nullptr) { + if (m_container->selectedPage() == this) + m_underlyingPage->closedImpl(); + + m_underlyingPage->apply(); + + layout()->removeWidget(m_underlyingPage); + + delete m_underlyingPage; + m_underlyingPage = nullptr; + } + + if (shouldDisplay()) { + m_underlyingPage = new DataPackPage(m_instance, m_instance->dataPackList()); + m_underlyingPage->setParentContainer(m_container); + m_underlyingPage->updateExtraInfo = [this](QString id, QString value) { updateExtraInfo(std::move(id), std::move(value)); }; + + if (m_container->selectedPage() == this) + m_underlyingPage->openedImpl(); + + layout()->addWidget(m_underlyingPage); + } +} +void GlobalDataPackPage::setParentContainer(BasePageContainer* container) +{ + BasePage::setParentContainer(container); + updateContent(); +} diff --git a/launcher/ui/pages/instance/DataPackPage.h b/launcher/ui/pages/instance/DataPackPage.h new file mode 100644 index 0000000..a3e6627 --- /dev/null +++ b/launcher/ui/pages/instance/DataPackPage.h @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include "ExternalResourcesPage.h" +#include "minecraft/mod/DataPackFolderModel.h" +#include "ui/dialogs/ResourceDownloadDialog.h" + +class DataPackPage : public ExternalResourcesPage { + Q_OBJECT + public: + explicit DataPackPage(BaseInstance* instance, DataPackFolderModel* model, QWidget* parent = nullptr); + + QString displayName() const override { return QObject::tr("Data Packs"); } + QIcon icon() const override { return QIcon::fromTheme("datapacks"); } + QString id() const override { return "datapacks"; } + QString helpPage() const override { return "Data-packs"; } + bool shouldDisplay() const override { return true; } + + public slots: + void updateFrame(const QModelIndex& current, const QModelIndex& previous) override; + void downloadDataPacks(); + void downloadDialogFinished(int result); + void updateDataPacks(); + void deleteDataPackMetadata(); + void changeDataPackVersion(); + + private: + DataPackFolderModel* m_model; + QPointer m_downloadDialog; +}; + +/** + * Syncs DataPackPage with GlobalDataPacksPath and shows/hides based on GlobalDataPacksEnabled. + */ +class GlobalDataPackPage : public QWidget, public BasePage { + public: + explicit GlobalDataPackPage(MinecraftInstance* instance, QWidget* parent = nullptr); + + QString displayName() const override; + QIcon icon() const override; + QString id() const override { return "datapacks"; } + QString helpPage() const override; + + bool shouldDisplay() const override; + + bool apply() override; + void openedImpl() override; + void closedImpl() override; + + void setParentContainer(BasePageContainer* container) override; + + private: + void updateContent(); + QVBoxLayout* layout() { return static_cast(QWidget::layout()); } + + MinecraftInstance* m_instance; + DataPackPage* m_underlyingPage = nullptr; +}; diff --git a/launcher/ui/pages/instance/ExternalResourcesPage.cpp b/launcher/ui/pages/instance/ExternalResourcesPage.cpp new file mode 100644 index 0000000..da7fa3e --- /dev/null +++ b/launcher/ui/pages/instance/ExternalResourcesPage.cpp @@ -0,0 +1,350 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ExternalResourcesPage.h" +#include "ui/dialogs/CustomMessageBox.h" +#include "ui_ExternalResourcesPage.h" + +#include "DesktopServices.h" +#include "Version.h" +#include "minecraft/mod/ResourceFolderModel.h" +#include "ui/GuiUtil.h" + +#include +#include +#include +#include + +ExternalResourcesPage::ExternalResourcesPage(BaseInstance* instance, ResourceFolderModel* model, QWidget* parent) + : QMainWindow(parent), m_instance(instance), ui(new Ui::ExternalResourcesPage), m_model(model) +{ + ui->setupUi(this); + + ui->actionsToolbar->insertSpacer(ui->actionViewFolder); + + m_filterModel = model->createFilterProxyModel(this); + m_filterModel->setDynamicSortFilter(true); + m_filterModel->setFilterCaseSensitivity(Qt::CaseInsensitive); + m_filterModel->setSortCaseSensitivity(Qt::CaseInsensitive); + m_filterModel->setSourceModel(m_model); + m_filterModel->setFilterKeyColumn(-1); + ui->treeView->setModel(m_filterModel); + // must come after setModel + ui->treeView->setResizeModes(m_model->columnResizeModes()); + + ui->treeView->installEventFilter(this); + ui->treeView->sortByColumn(1, Qt::AscendingOrder); + ui->treeView->setContextMenuPolicy(Qt::CustomContextMenu); + + // The default function names by Qt are pretty ugly, so let's just connect the actions manually, + // to make it easier to read :) + connect(ui->actionAddItem, &QAction::triggered, this, &ExternalResourcesPage::addItem); + connect(ui->actionRemoveItem, &QAction::triggered, this, &ExternalResourcesPage::removeItem); + connect(ui->actionEnableItem, &QAction::triggered, this, &ExternalResourcesPage::enableItem); + connect(ui->actionDisableItem, &QAction::triggered, this, &ExternalResourcesPage::disableItem); + connect(ui->actionViewHomepage, &QAction::triggered, this, &ExternalResourcesPage::viewHomepage); + connect(ui->actionViewConfigs, &QAction::triggered, this, &ExternalResourcesPage::viewConfigs); + connect(ui->actionViewFolder, &QAction::triggered, this, &ExternalResourcesPage::viewFolder); + + connect(ui->treeView, &ModListView::customContextMenuRequested, this, &ExternalResourcesPage::ShowContextMenu); + connect(ui->treeView, &ModListView::activated, this, &ExternalResourcesPage::itemActivated); + + auto selection_model = ui->treeView->selectionModel(); + + connect(selection_model, &QItemSelectionModel::currentChanged, this, [this](const QModelIndex& current, const QModelIndex& previous) { + if (!current.isValid()) { + ui->frame->clear(); + return; + } + + updateFrame(current, previous); + }); + + auto updateExtra = [this]() { + if (updateExtraInfo) + updateExtraInfo(id(), extraHeaderInfoString()); + }; + + connect(selection_model, &QItemSelectionModel::selectionChanged, this, updateExtra); + connect(model, &ResourceFolderModel::updateFinished, this, updateExtra); + connect(model, &ResourceFolderModel::parseFinished, this, updateExtra); + + connect(selection_model, &QItemSelectionModel::selectionChanged, this, [this] { updateActions(); }); + connect(m_model, &ResourceFolderModel::rowsInserted, this, [this] { updateActions(); }); + connect(m_model, &ResourceFolderModel::rowsRemoved, this, [this] { updateActions(); }); + + auto viewHeader = ui->treeView->header(); + viewHeader->setContextMenuPolicy(Qt::CustomContextMenu); + + connect(viewHeader, &QHeaderView::customContextMenuRequested, this, &ExternalResourcesPage::ShowHeaderContextMenu); + + m_model->loadColumns(ui->treeView); + connect(ui->treeView->header(), &QHeaderView::sectionResized, this, [this] { m_model->saveColumns(ui->treeView); }); + connect(ui->filterEdit, &QLineEdit::textChanged, this, &ExternalResourcesPage::filterTextChanged); + updateActions(); +} + +ExternalResourcesPage::~ExternalResourcesPage() +{ + delete ui; +} + +QMenu* ExternalResourcesPage::createPopupMenu() +{ + QMenu* filteredMenu = QMainWindow::createPopupMenu(); + filteredMenu->removeAction(ui->actionsToolbar->toggleViewAction()); + return filteredMenu; +} + +void ExternalResourcesPage::ShowContextMenu(const QPoint& pos) +{ + auto menu = ui->actionsToolbar->createContextMenu(this, tr("Context menu")); + menu->exec(ui->treeView->mapToGlobal(pos)); + delete menu; +} + +void ExternalResourcesPage::ShowHeaderContextMenu(const QPoint& pos) +{ + auto menu = m_model->createHeaderContextMenu(ui->treeView); + menu->exec(ui->treeView->mapToGlobal(pos)); + menu->deleteLater(); +} + +void ExternalResourcesPage::openedImpl() +{ + m_model->startWatching(); + + auto const setting_name = QString("WideBarVisibility_%1").arg(id()); + m_wide_bar_setting = APPLICATION->settings()->getOrRegisterSetting(setting_name); + + ui->actionsToolbar->setVisibilityState(QByteArray::fromBase64(m_wide_bar_setting->get().toString().toUtf8())); +} + +void ExternalResourcesPage::closedImpl() +{ + m_model->stopWatching(); + + m_wide_bar_setting->set(QString::fromUtf8(ui->actionsToolbar->getVisibilityState().toBase64())); +} + +void ExternalResourcesPage::retranslate() +{ + ui->retranslateUi(this); +} + +void ExternalResourcesPage::itemActivated(const QModelIndex&) +{ + auto selection = m_filterModel->mapSelectionToSource(ui->treeView->selectionModel()->selection()); + m_model->setResourceEnabled(selection.indexes(), EnableAction::TOGGLE); +} + +void ExternalResourcesPage::filterTextChanged(const QString& newContents) +{ + m_viewFilter = newContents; + m_filterModel->setFilterRegularExpression(m_viewFilter); +} + +bool ExternalResourcesPage::shouldDisplay() const +{ + return true; +} + +bool ExternalResourcesPage::listFilter(QKeyEvent* keyEvent) +{ + switch (keyEvent->key()) { + case Qt::Key_Delete: + removeItem(); + return true; + case Qt::Key_Plus: + addItem(); + return true; + default: + break; + } + return QWidget::eventFilter(ui->treeView, keyEvent); +} + +bool ExternalResourcesPage::eventFilter(QObject* obj, QEvent* ev) +{ + if (ev->type() != QEvent::KeyPress) + return QWidget::eventFilter(obj, ev); + + QKeyEvent* keyEvent = static_cast(ev); + if (obj == ui->treeView) + return listFilter(keyEvent); + + return QWidget::eventFilter(obj, ev); +} + +void ExternalResourcesPage::addItem() +{ + auto list = GuiUtil::BrowseForFiles( + helpPage(), tr("Select %1", "Select whatever type of files the page contains. Example: 'Loader Mods'").arg(displayName()), + m_fileSelectionFilter.arg(displayName()), APPLICATION->settings()->get("CentralModsDir").toString(), this->parentWidget()); + + if (!list.isEmpty()) { + for (auto filename : list) { + m_model->installResource(filename); + } + } +} + +void ExternalResourcesPage::removeItem() +{ + auto selection = m_filterModel->mapSelectionToSource(ui->treeView->selectionModel()->selection()); + + int count = 0; + bool folder = false; + for (auto& i : selection.indexes()) { + if (i.column() == 0) { + count++; + + // if a folder is selected, show the confirmation dialog + if (m_model->at(i.row()).fileinfo().isDir()) + folder = true; + } + } + + QString text; + bool multiple = count > 1; + + if (multiple) { + text = tr("You are about to remove %1 items.\n" + "This may be permanent and they will be gone from the folder.\n\n" + "Are you sure?") + .arg(count); + } else if (folder) { + text = tr("You are about to remove the folder \"%1\".\n" + "This may be permanent and it will be gone from the parent folder.\n\n" + "Are you sure?") + .arg(m_model->at(selection.indexes().at(0).row()).fileinfo().fileName()); + } + + if (!text.isEmpty()) { + auto response = CustomMessageBox::selectable(this, tr("Confirm Removal"), text, QMessageBox::Warning, + QMessageBox::Yes | QMessageBox::No, QMessageBox::No) + ->exec(); + + if (response != QMessageBox::Yes) + return; + } + + removeItems(selection); +} + +void ExternalResourcesPage::removeItems(const QItemSelection& selection) +{ + if (m_instance != nullptr && m_instance->isRunning()) { + auto response = CustomMessageBox::selectable(this, tr("Confirm Delete"), + tr("If you remove this resource while the game is running it may crash your game.\n" + "Are you sure you want to do this?"), + QMessageBox::Warning, QMessageBox::Yes | QMessageBox::No, QMessageBox::No) + ->exec(); + + if (response != QMessageBox::Yes) + return; + } + m_model->deleteResources(selection.indexes()); +} + +void ExternalResourcesPage::enableItem() +{ + auto selection = m_filterModel->mapSelectionToSource(ui->treeView->selectionModel()->selection()); + m_model->setResourceEnabled(selection.indexes(), EnableAction::ENABLE); +} + +void ExternalResourcesPage::disableItem() +{ + auto selection = m_filterModel->mapSelectionToSource(ui->treeView->selectionModel()->selection()); + m_model->setResourceEnabled(selection.indexes(), EnableAction::DISABLE); +} + +void ExternalResourcesPage::viewHomepage() +{ + auto selection = m_filterModel->mapSelectionToSource(ui->treeView->selectionModel()->selection()).indexes(); + for (auto resource : m_model->selectedResources(selection)) { + auto url = resource->homepage(); + if (!url.isEmpty()) + DesktopServices::openUrl(url); + } +} + +void ExternalResourcesPage::viewConfigs() +{ + DesktopServices::openPath(m_instance->instanceConfigFolder(), true); +} + +void ExternalResourcesPage::viewFolder() +{ + DesktopServices::openPath(m_model->dir().absolutePath(), true); +} + +void ExternalResourcesPage::updateActions() +{ + const bool hasSelection = ui->treeView->selectionModel()->hasSelection(); + const QModelIndexList selection = m_filterModel->mapSelectionToSource(ui->treeView->selectionModel()->selection()).indexes(); + const QList selectedResources = m_model->selectedResources(selection); + + ui->actionUpdateItem->setEnabled(!m_model->empty()); + ui->actionResetItemMetadata->setEnabled(hasSelection); + + ui->actionChangeVersion->setEnabled(selectedResources.size() == 1 && selectedResources[0]->metadata() != nullptr); + + ui->actionRemoveItem->setEnabled(hasSelection); + ui->actionEnableItem->setEnabled(hasSelection); + ui->actionDisableItem->setEnabled(hasSelection); + + ui->actionViewHomepage->setEnabled(hasSelection && std::any_of(selectedResources.begin(), selectedResources.end(), + [](Resource* resource) { return !resource->homepage().isEmpty(); })); + ui->actionExportMetadata->setEnabled(!m_model->empty()); +} + +void ExternalResourcesPage::updateFrame(const QModelIndex& current, [[maybe_unused]] const QModelIndex& previous) +{ + auto sourceCurrent = m_filterModel->mapToSource(current); + int row = sourceCurrent.row(); + Resource const& resource = m_model->at(row); + ui->frame->updateWithResource(resource); +} + +QString ExternalResourcesPage::extraHeaderInfoString() +{ + if (ui && ui->treeView && ui->treeView->selectionModel()) { + auto selection = m_filterModel->mapSelectionToSource(ui->treeView->selectionModel()->selection()).indexes(); + if (auto count = std::count_if(selection.cbegin(), selection.cend(), [](auto v) { return v.column() == 0; }); count != 0) + return tr(" (%1 installed, %2 selected)").arg(m_model->size()).arg(count); + } + return tr(" (%1 installed)").arg(m_model->size()); +} diff --git a/launcher/ui/pages/instance/ExternalResourcesPage.h b/launcher/ui/pages/instance/ExternalResourcesPage.h new file mode 100644 index 0000000..7f43206 --- /dev/null +++ b/launcher/ui/pages/instance/ExternalResourcesPage.h @@ -0,0 +1,78 @@ +#pragma once + +#include +#include + +#include "Application.h" +#include "minecraft/MinecraftInstance.h" +#include "settings/Setting.h" +#include "ui/pages/BasePage.h" + +class ResourceFolderModel; + +namespace Ui { +class ExternalResourcesPage; +} + +/* This page is used as a base for pages in which the user can manage external resources + * related to the game, such as mods, shaders or resource packs. */ +class ExternalResourcesPage : public QMainWindow, public BasePage { + Q_OBJECT + + public: + explicit ExternalResourcesPage(BaseInstance* instance, ResourceFolderModel* model, QWidget* parent = nullptr); + virtual ~ExternalResourcesPage(); + + virtual QString displayName() const override = 0; + virtual QIcon icon() const override = 0; + virtual QString id() const override = 0; + virtual QString helpPage() const override = 0; + + virtual bool shouldDisplay() const override = 0; + QString extraHeaderInfoString(); + + void openedImpl() override; + void closedImpl() override; + + void retranslate() override; + + protected: + bool eventFilter(QObject* obj, QEvent* ev) override; + bool listFilter(QKeyEvent* ev); + QMenu* createPopupMenu() override; + + public slots: + virtual void updateActions(); + virtual void updateFrame(const QModelIndex& current, const QModelIndex& previous); + + protected slots: + void itemActivated(const QModelIndex& index); + void filterTextChanged(const QString& newContents); + + virtual void addItem(); + void removeItem(); + virtual void removeItems(const QItemSelection& selection); + + virtual void enableItem(); + virtual void disableItem(); + + virtual void viewHomepage(); + + virtual void viewFolder(); + virtual void viewConfigs(); + + void ShowContextMenu(const QPoint& pos); + void ShowHeaderContextMenu(const QPoint& pos); + + protected: + BaseInstance* m_instance = nullptr; + + Ui::ExternalResourcesPage* ui = nullptr; + ResourceFolderModel* m_model; + QSortFilterProxyModel* m_filterModel = nullptr; + + QString m_fileSelectionFilter; + QString m_viewFilter; + + std::shared_ptr m_wide_bar_setting = nullptr; +}; diff --git a/launcher/ui/pages/instance/ExternalResourcesPage.ui b/launcher/ui/pages/instance/ExternalResourcesPage.ui new file mode 100644 index 0000000..c6955d0 --- /dev/null +++ b/launcher/ui/pages/instance/ExternalResourcesPage.ui @@ -0,0 +1,245 @@ + + + ExternalResourcesPage + + + + 0 + 0 + 1042 + 501 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + true + + + QAbstractItemView::DragDropMode::DropOnly + + + true + + + + + + + + 0 + 0 + + + + + + + + Search + + + + + + + + Actions + + + Qt::ToolButtonStyle::ToolButtonIconOnly + + + true + + + RightToolBarArea + + + false + + + + + + + + + + + + + &Add File + + + Add a locally downloaded file. + + + + + false + + + &Remove + + + Remove all selected items. + + + + + false + + + &Enable + + + Enable all selected items. + + + + + false + + + &Disable + + + Disable all selected items. + + + + + View &Configs + + + Open the 'config' folder in the system file manager. + + + + + View &Folder + + + Open the folder in the system file manager. + + + + + false + + + &Download + + + Download resources from online mod platforms. + + + + + false + + + Check for &Updates + + + Try to check or update all selected resources (all resources if none are selected). + + + + + Reset Update Metadata + + + QAction::MenuRole::NoRole + + + + + Verify Dependencies + + + QAction::MenuRole::NoRole + + + + + false + + + Export List + + + Export resource's metadata to text. + + + + + false + + + Change Version + + + Change a resource's version. + + + QAction::MenuRole::NoRole + + + + + false + + + View Homepage + + + View the homepages of all selected items. + + + + + + ModListView + QTreeView +
    ui/widgets/ModListView.h
    +
    + + InfoFrame + QFrame +
    ui/widgets/InfoFrame.h
    + 1 +
    + + WideBar + QToolBar +
    ui/widgets/WideBar.h
    +
    +
    + + treeView + + + +
    diff --git a/launcher/ui/pages/instance/GameOptionsPage.h b/launcher/ui/pages/instance/GameOptionsPage.h new file mode 100644 index 0000000..43f9197 --- /dev/null +++ b/launcher/ui/pages/instance/GameOptionsPage.h @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +#include "ui/pages/BasePage.h" + +namespace Ui { +class GameOptionsPage; +} + +class GameOptions; +class MinecraftInstance; + +class GameOptionsPage : public QWidget, public BasePage { + Q_OBJECT + + public: + explicit GameOptionsPage(MinecraftInstance* inst, QWidget* parent = 0); + virtual ~GameOptionsPage(); + + void openedImpl() override; + void closedImpl() override; + + virtual QString displayName() const override { return tr("Game Options"); } + virtual QIcon icon() const override { return QIcon::fromTheme("settings"); } + virtual QString id() const override { return "gameoptions"; } + virtual QString helpPage() const override { return "Game-Options-management"; } + void retranslate() override; + + private: // data + Ui::GameOptionsPage* ui = nullptr; + std::shared_ptr m_model; +}; diff --git a/launcher/ui/pages/instance/InstanceSettingsPage.h b/launcher/ui/pages/instance/InstanceSettingsPage.h new file mode 100644 index 0000000..79d5944 --- /dev/null +++ b/launcher/ui/pages/instance/InstanceSettingsPage.h @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include "BaseInstance.h" +#include "ui/pages/BasePage.h" +#include "ui/widgets/MinecraftSettingsWidget.h" + +class InstanceSettingsPage : public MinecraftSettingsWidget, public BasePage { + Q_OBJECT + + public: + explicit InstanceSettingsPage(MinecraftInstance* instance, QWidget* parent = nullptr) : MinecraftSettingsWidget(instance, parent) + { + connect(APPLICATION, &Application::globalSettingsAboutToOpen, this, &InstanceSettingsPage::saveSettings); + connect(APPLICATION, &Application::globalSettingsApplied, this, &InstanceSettingsPage::loadSettings); + } + ~InstanceSettingsPage() override {} + QString displayName() const override { return tr("Settings"); } + QIcon icon() const override { return QIcon::fromTheme("instance-settings"); } + QString id() const override { return "settings"; } + bool apply() override + { + saveSettings(); + return true; + } + QString helpPage() const override { return "Instance-settings"; } +}; diff --git a/launcher/ui/pages/instance/LogPage.cpp b/launcher/ui/pages/instance/LogPage.cpp new file mode 100644 index 0000000..7706989 --- /dev/null +++ b/launcher/ui/pages/instance/LogPage.cpp @@ -0,0 +1,329 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2024 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "LogPage.h" +#include "ui_LogPage.h" + +#include "Application.h" + +#include +#include +#include + +#include "launch/LaunchTask.h" +#include "settings/Setting.h" + +#include "ui/GuiUtil.h" +#include "ui/themes/ThemeManager.h" + +#include + +QVariant LogFormatProxyModel::data(const QModelIndex& index, int role) const +{ + const LogColors& colors = APPLICATION->themeManager()->getLogColors(); + + switch (role) { + case Qt::FontRole: + return m_font; + case Qt::ForegroundRole: { + MessageLevel level = static_cast(QIdentityProxyModel::data(index, LogModel::LevelRole).toInt()); + QColor result = colors.foreground.value(level); + + if (result.isValid()) + return result; + + break; + } + case Qt::BackgroundRole: { + MessageLevel level = static_cast(QIdentityProxyModel::data(index, LogModel::LevelRole).toInt()); + QColor result = colors.background.value(level); + + if (result.isValid()) + return result; + + break; + } + } + + return QIdentityProxyModel::data(index, role); +} + +QModelIndex LogFormatProxyModel::find(const QModelIndex& start, const QString& value, bool reverse) const +{ + QModelIndex parentIndex = parent(start); + auto compare = [this, start, parentIndex, value](int r) -> QModelIndex { + QModelIndex idx = index(r, start.column(), parentIndex); + if (!idx.isValid() || idx == start) { + return QModelIndex(); + } + QVariant v = data(idx, Qt::DisplayRole); + QString t = v.toString(); + if (t.contains(value, Qt::CaseInsensitive)) + return idx; + return QModelIndex(); + }; + if (reverse) { + int from = start.row(); + int to = 0; + + for (int i = 0; i < 2; ++i) { + for (int r = from; (r >= to); --r) { + auto idx = compare(r); + if (idx.isValid()) + return idx; + } + // prepare for the next iteration + from = rowCount() - 1; + to = start.row(); + } + } else { + int from = start.row(); + int to = rowCount(parentIndex); + + for (int i = 0; i < 2; ++i) { + for (int r = from; (r < to); ++r) { + auto idx = compare(r); + if (idx.isValid()) + return idx; + } + // prepare for the next iteration + from = 0; + to = start.row(); + } + } + return QModelIndex(); +} + +LogPage::LogPage(BaseInstance* instance, QWidget* parent) : QWidget(parent), ui(new Ui::LogPage), m_instance(instance) +{ + ui->setupUi(this); + + m_proxy = new LogFormatProxyModel(this); + + // set up fonts in the log proxy + { + QString fontFamily = APPLICATION->settings()->get("ConsoleFont").toString(); + bool conversionOk = false; + int fontSize = APPLICATION->settings()->get("ConsoleFontSize").toInt(&conversionOk); + if (!conversionOk) { + fontSize = 11; + } + m_proxy->setFont(QFont(fontFamily, fontSize)); + } + + ui->text->setModel(m_proxy); + + // set up instance and launch process recognition + { + auto launchTask = m_instance->getLaunchTask(); + if (launchTask) { + setInstanceLaunchTaskChanged(launchTask, true); + } + connect(m_instance, &BaseInstance::launchTaskChanged, this, &LogPage::onInstanceLaunchTaskChanged); + } + + auto findShortcut = new QShortcut(QKeySequence(QKeySequence::Find), this); + connect(findShortcut, &QShortcut::activated, this, &LogPage::findActivated); + auto findNextShortcut = new QShortcut(QKeySequence(QKeySequence::FindNext), this); + connect(findNextShortcut, &QShortcut::activated, this, &LogPage::findNextActivated); + connect(ui->searchBar, &QLineEdit::returnPressed, this, &LogPage::on_findButton_clicked); + auto findPreviousShortcut = new QShortcut(QKeySequence(QKeySequence::FindPrevious), this); + connect(findPreviousShortcut, &QShortcut::activated, this, &LogPage::findPreviousActivated); +} + +LogPage::~LogPage() +{ + delete ui; +} + +void LogPage::modelStateToUI() +{ + if (m_model->wrapLines()) { + ui->text->setWordWrap(true); + ui->wrapCheckbox->setCheckState(Qt::Checked); + } else { + ui->text->setWordWrap(false); + ui->wrapCheckbox->setCheckState(Qt::Unchecked); + } + if (m_model->colorLines()) { + ui->text->setColorLines(true); + ui->colorCheckbox->setCheckState(Qt::Checked); + } else { + ui->text->setColorLines(false); + ui->colorCheckbox->setCheckState(Qt::Unchecked); + } + if (m_model->suspended()) { + ui->trackLogCheckbox->setCheckState(Qt::Unchecked); + } else { + ui->trackLogCheckbox->setCheckState(Qt::Checked); + } +} + +void LogPage::UIToModelState() +{ + if (!m_model) { + return; + } + m_model->setLineWrap(ui->wrapCheckbox->checkState() == Qt::Checked); + m_model->setColorLines(ui->colorCheckbox->checkState() == Qt::Checked); + m_model->suspend(ui->trackLogCheckbox->checkState() != Qt::Checked); +} + +void LogPage::setInstanceLaunchTaskChanged(LaunchTask* proc, bool initial) +{ + m_process = proc; + if (m_process) { + m_model = proc->getLogModel(); + m_proxy->setSourceModel(m_model.get()); + if (initial) { + modelStateToUI(); + } else { + UIToModelState(); + } + } else { + m_proxy->setSourceModel(nullptr); + m_model.reset(); + } +} + +void LogPage::onInstanceLaunchTaskChanged(LaunchTask* proc) +{ + setInstanceLaunchTaskChanged(proc, false); +} + +bool LogPage::apply() +{ + return true; +} + +bool LogPage::shouldDisplay() const +{ + return true; +} + +void LogPage::on_btnPaste_clicked() +{ + if (!m_model) + return; + + // FIXME: turn this into a proper task and move the upload logic out of GuiUtil! + m_model->append(MessageLevel::Launcher, + QString("Log upload triggered at: %1").arg(QDateTime::currentDateTime().toString(Qt::RFC2822Date))); + auto url = GuiUtil::uploadPaste(tr("Minecraft Log"), m_model->toPlainText(), this); + if (!url.has_value()) { + m_model->append(MessageLevel::Error, QString("Log upload canceled")); + } else if (url->isNull()) { + m_model->append(MessageLevel::Error, QString("Log upload failed!")); + } else { + m_model->append(MessageLevel::Launcher, QString("Log uploaded to: %1").arg(url.value())); + } +} + +void LogPage::on_btnCopy_clicked() +{ + if (!m_model) + return; + m_model->append(MessageLevel::Launcher, QString("Clipboard copy at: %1").arg(QDateTime::currentDateTime().toString(Qt::RFC2822Date))); + GuiUtil::setClipboardText(m_model->toPlainText()); +} + +void LogPage::on_btnClear_clicked() +{ + if (!m_model) + return; + m_model->clear(); + m_container->refreshContainer(); +} + +void LogPage::on_btnBottom_clicked() +{ + ui->text->scrollToBottom(); +} + +void LogPage::on_trackLogCheckbox_clicked(bool checked) +{ + if (!m_model) + return; + m_model->suspend(!checked); +} + +void LogPage::on_wrapCheckbox_clicked(bool checked) +{ + ui->text->setWordWrap(checked); + if (!m_model) + return; + m_model->setLineWrap(checked); +} + +void LogPage::on_colorCheckbox_clicked(bool checked) +{ + ui->text->setColorLines(checked); + if (!m_model) + return; + m_model->setColorLines(checked); +} + +void LogPage::on_findButton_clicked() +{ + auto modifiers = QApplication::keyboardModifiers(); + bool reverse = modifiers & Qt::ShiftModifier; + ui->text->findNext(ui->searchBar->text(), reverse); +} + +void LogPage::findNextActivated() +{ + ui->text->findNext(ui->searchBar->text(), false); +} + +void LogPage::findPreviousActivated() +{ + ui->text->findNext(ui->searchBar->text(), true); +} + +void LogPage::findActivated() +{ + // focus the search bar if it doesn't have focus + if (!ui->searchBar->hasFocus()) { + ui->searchBar->setFocus(); + ui->searchBar->selectAll(); + } +} + +void LogPage::retranslate() +{ + ui->retranslateUi(this); +} diff --git a/launcher/ui/pages/instance/LogPage.h b/launcher/ui/pages/instance/LogPage.h new file mode 100644 index 0000000..ef93f2c --- /dev/null +++ b/launcher/ui/pages/instance/LogPage.h @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +#include "BaseInstance.h" +#include "launch/LaunchTask.h" +#include "ui/pages/BasePage.h" + +namespace Ui { +class LogPage; +} +class QTextCharFormat; + +class LogFormatProxyModel : public QIdentityProxyModel { + public: + LogFormatProxyModel(QObject* parent = nullptr) : QIdentityProxyModel(parent) {} + QVariant data(const QModelIndex& index, int role) const override; + QFont getFont() const { return m_font; } + void setFont(QFont font) { m_font = font; } + QModelIndex find(const QModelIndex& start, const QString& value, bool reverse) const; + + private: + QFont m_font; +}; + +class LogPage : public QWidget, public BasePage { + Q_OBJECT + + public: + explicit LogPage(BaseInstance* instance, QWidget* parent = 0); + virtual ~LogPage(); + virtual QString displayName() const override { return tr("Minecraft Log"); } + virtual QIcon icon() const override { return QIcon::fromTheme("log"); } + virtual QString id() const override { return "console"; } + virtual bool apply() override; + virtual QString helpPage() const override { return "Minecraft-Logs"; } + virtual bool shouldDisplay() const override; + void retranslate() override; + + private slots: + void on_btnPaste_clicked(); + void on_btnCopy_clicked(); + void on_btnClear_clicked(); + void on_btnBottom_clicked(); + + void on_trackLogCheckbox_clicked(bool checked); + void on_wrapCheckbox_clicked(bool checked); + void on_colorCheckbox_clicked(bool checked); + + void on_findButton_clicked(); + void findActivated(); + void findNextActivated(); + void findPreviousActivated(); + + void onInstanceLaunchTaskChanged(LaunchTask* proc); + + private: + void modelStateToUI(); + void UIToModelState(); + void setInstanceLaunchTaskChanged(LaunchTask* proc, bool initial); + + private: + Ui::LogPage* ui; + BaseInstance* m_instance; + LaunchTask* m_process; + + LogFormatProxyModel* m_proxy; + shared_qobject_ptr m_model; +}; diff --git a/launcher/ui/pages/instance/LogPage.ui b/launcher/ui/pages/instance/LogPage.ui new file mode 100644 index 0000000..2362e19 --- /dev/null +++ b/launcher/ui/pages/instance/LogPage.ui @@ -0,0 +1,183 @@ + + + LogPage + + + + 0 + 0 + 825 + 782 + + + + + 0 + + + 0 + + + 0 + + + + + false + + + true + + + + + + Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse|Qt::TextBrowserInteraction|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse + + + false + + + + + + + + + Keep updating + + + true + + + + + + + Wrap lines + + + true + + + + + + + Color lines + + + true + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Copy the whole log into the clipboard + + + &Copy + + + + + + + Upload the log to the paste service configured in preferences + + + Upload + + + + + + + Clear the log + + + Clear + + + + + + + + + + 0 + 0 + + + + Find + + + + + + + + 0 + 0 + + + + Scroll all the way to bottom + + + Bottom + + + + + + + Qt::Vertical + + + + + + + Search + + + + + + + + LogView + QPlainTextEdit +
    ui/widgets/LogView.h
    +
    +
    + + trackLogCheckbox + wrapCheckbox + colorCheckbox + btnCopy + btnPaste + btnClear + text + findButton + + + +
    diff --git a/launcher/ui/pages/instance/ManagedPackPage.cpp b/launcher/ui/pages/instance/ManagedPackPage.cpp new file mode 100644 index 0000000..508bfeb --- /dev/null +++ b/launcher/ui/pages/instance/ManagedPackPage.cpp @@ -0,0 +1,525 @@ +// SPDX-FileCopyrightText: 2022 flowln +// +// SPDX-License-Identifier: GPL-3.0-only + +#include "ManagedPackPage.h" +#include +#include +#include +#include +#include "modplatform/ModIndex.h" +#include "ui_ManagedPackPage.h" + +#include +#include +#include +#include +#include + +#include "Application.h" +#include "BuildConfig.h" +#include "InstanceImportTask.h" +#include "InstanceList.h" +#include "InstanceTask.h" +#include "Json.h" +#include "Markdown.h" +#include "StringUtils.h" + +#include "ui/InstanceWindow.h" +#include "ui/dialogs/CustomMessageBox.h" +#include "ui/dialogs/ProgressDialog.h" + +#include "net/ApiDownload.h" + +/** This is just to override the combo box popup behavior so that the combo box doesn't take the whole screen. + * ... thanks Qt. + */ +class NoBigComboBoxStyle : public QProxyStyle { + Q_OBJECT + + public: + // clang-format off + int styleHint(QStyle::StyleHint hint, const QStyleOption* option = nullptr, const QWidget* widget = nullptr, QStyleHintReturn* returnData = nullptr) const override + { + if (hint == QStyle::SH_ComboBox_Popup) + return false; + + return QProxyStyle::styleHint(hint, option, widget, returnData); + } + // clang-format on + + /** + * Something about QProxyStyle and QStyle objects means they can't be free'd just + * because all the widgets using them are gone. + * They seems to be tied to the QApplicaiton lifecycle. + * So make singletons tied to the lifetime of the application to clean them up and ensure they aren't + * being remade over and over again, thus leaking memory. + */ + public: + static NoBigComboBoxStyle* getInstance(QStyle* style) + { + static QHash s_singleton_instances_ = {}; + static std::mutex s_singleton_instances_mutex_; + + std::lock_guard lock(s_singleton_instances_mutex_); + auto inst_iter = s_singleton_instances_.constFind(style); + NoBigComboBoxStyle* inst = nullptr; + if (inst_iter == s_singleton_instances_.constEnd() || *inst_iter == nullptr) { + inst = new NoBigComboBoxStyle(style); + inst->setParent(APPLICATION); + s_singleton_instances_.insert(style, inst); + qDebug() << "QProxyStyle NoBigComboBox created for" << style->objectName() << style; + } else { + inst = *inst_iter; + } + return inst; + } + + private: + NoBigComboBoxStyle(QStyle* style) : QProxyStyle(style) {} +}; + +ManagedPackPage* ManagedPackPage::createPage(BaseInstance* inst, QString type, QWidget* parent) +{ + if (type == "modrinth") + return new ModrinthManagedPackPage(inst, nullptr, parent); + if (type == "flame" && (APPLICATION->capabilities() & Application::SupportsFlame)) + return new FlameManagedPackPage(inst, nullptr, parent); + + return new GenericManagedPackPage(inst, nullptr, parent); +} + +ManagedPackPage::ManagedPackPage(BaseInstance* inst, InstanceWindow* instance_window, QWidget* parent) + : QWidget(parent), m_instance_window(instance_window), ui(new Ui::ManagedPackPage), m_inst(inst) +{ + Q_ASSERT(inst); + + ui->setupUi(this); + + // NOTE: GTK2 themes crash with the proxy style. + // This seems like an upstream bug, so there's not much else that can be done. + if (!QStyleFactory::keys().contains("gtk2")) { + auto comboStyle = NoBigComboBoxStyle::getInstance(ui->versionsComboBox->style()); + ui->versionsComboBox->setStyle(comboStyle); + } + + ui->versionsComboBox->view()->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + ui->versionsComboBox->view()->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); + + ui->reloadButton->setVisible(false); + connect(ui->reloadButton, &QPushButton::clicked, this, [this](bool) { + ui->reloadButton->setVisible(false); + + m_loaded = false; + // Pretend we're opening the page again + openedImpl(); + }); + + connect(ui->changelogTextBrowser, &QTextBrowser::anchorClicked, this, [](const QUrl url) { + if (url.scheme().isEmpty()) { + auto querry = + QUrlQuery(url.query()).queryItemValue("remoteUrl", QUrl::FullyDecoded); // curseforge workaround for linkout?remoteUrl= + auto decoded = QUrl::fromPercentEncoding(querry.toUtf8()); + auto newUrl = QUrl(decoded); + if (newUrl.isValid() && (newUrl.scheme() == "http" || newUrl.scheme() == "https")) + QDesktopServices ::openUrl(newUrl); + return; + } + QDesktopServices::openUrl(url); + }); + + connect(ui->urlLine, &QLineEdit::textChanged, this, [this](QString text) { m_inst->settings()->set("ManagedPackURL", text); }); +} + +ManagedPackPage::~ManagedPackPage() +{ + delete ui; +} + +void ManagedPackPage::openedImpl() +{ + if (m_inst->getManagedPackID().isEmpty()) { + ui->packVersion->hide(); + ui->packVersionLabel->hide(); + ui->packOrigin->hide(); + ui->packOriginLabel->hide(); + ui->versionsComboBox->hide(); + ui->updateToVersionLabel->setText(tr("URL:")); + ui->updateButton->setText(tr("Update Pack")); + ui->updateButton->setDisabled(false); + ui->urlLine->setText(m_inst->settings()->get("ManagedPackURL").toString()); + + ui->packName->setText(m_inst->name()); + ui->changelogTextBrowser->setText(tr("This is a local modpack.\n" + "This can be updated either using a file in %1 format or an URL.\n" + "Do not use a different format than the one mentioned as it may break the instance.\n" + "Make sure you also trust the URL.\n") + .arg(displayName())); + return; + } + ui->urlLine->hide(); + ui->packName->setText(m_inst->getManagedPackName()); + ui->packVersion->setText(m_inst->getManagedPackVersionName()); + ui->packOrigin->setText(tr("Website: %2 | Pack ID: %3 | Version ID: %4") + .arg(url(), displayName(), m_inst->getManagedPackID(), m_inst->getManagedPackVersionID())); + + parseManagedPack(); +} + +QString ManagedPackPage::displayName() const +{ + auto type = m_inst->getManagedPackType(); + if (type.isEmpty()) + return {}; + if (type == "flame") + type = "CurseForge"; + return type.replace(0, 1, type[0].toUpper()); +} + +QIcon ManagedPackPage::icon() const +{ + return QIcon::fromTheme(m_inst->getManagedPackType()); +} + +QString ManagedPackPage::helpPage() const +{ + return {}; +} + +void ManagedPackPage::retranslate() +{ + ui->retranslateUi(this); +} + +bool ManagedPackPage::shouldDisplay() const +{ + return m_inst->isManagedPack(); +} + +bool ManagedPackPage::runUpdateTask(InstanceTask* task) +{ + Q_ASSERT(task); + + unique_qobject_ptr wrapped_task(APPLICATION->instances()->wrapInstanceTask(task)); + + connect(wrapped_task.get(), &Task::failed, + [this](const QString& reason) { CustomMessageBox::selectable(this, tr("Error"), reason, QMessageBox::Critical)->show(); }); + connect(wrapped_task.get(), &Task::succeeded, [this, task]() { + QStringList warnings = task->warnings(); + if (warnings.count()) { + CustomMessageBox::selectable(this, tr("Warnings"), warnings.join('\n'), QMessageBox::Warning)->show(); + } + }); + connect(wrapped_task.get(), &Task::aborted, [this] { + CustomMessageBox::selectable(this, tr("Task aborted"), tr("The task has been aborted by the user."), QMessageBox::Information) + ->show(); + }); + + ProgressDialog loadDialog(this); + loadDialog.setSkipButton(true, tr("Abort")); + loadDialog.execWithTask(wrapped_task.get()); + + return wrapped_task->wasSuccessful(); +} + +void ManagedPackPage::suggestVersion() +{ + ui->updateButton->setText(tr("Update Pack")); + ui->updateButton->setDisabled(false); +} + +void ManagedPackPage::setFailState() +{ + qDebug() << "Setting fail state!"; + + // We block signals here so that suggestVersion() doesn't get called, causing an assertion fail. + ui->versionsComboBox->blockSignals(true); + ui->versionsComboBox->clear(); + ui->versionsComboBox->addItem(tr("Failed to search for available versions."), {}); + ui->versionsComboBox->blockSignals(false); + + ui->changelogTextBrowser->setText(tr("Failed to request changelog data for this modpack.")); + + ui->updateButton->setText(tr("Cannot update!")); + ui->updateButton->setDisabled(true); + + ui->reloadButton->setVisible(true); +} + +ModrinthManagedPackPage::ModrinthManagedPackPage(BaseInstance* inst, InstanceWindow* instance_window, QWidget* parent) + : ManagedPackPage(inst, instance_window, parent) +{ + Q_ASSERT(inst->isManagedPack()); + connect(ui->versionsComboBox, &QComboBox::currentIndexChanged, this, &ModrinthManagedPackPage::suggestVersion); + connect(ui->updateButton, &QPushButton::clicked, this, &ModrinthManagedPackPage::update); + connect(ui->updateFromFileButton, &QPushButton::clicked, this, &ModrinthManagedPackPage::updateFromFile); +} + +// MODRINTH +void ModrinthManagedPackPage::parseManagedPack() +{ + qDebug() << "Parsing Modrinth pack"; + + // No need for the extra work because we already have everything we need. + if (m_loaded) + return; + + if (m_fetch_job && m_fetch_job->isRunning()) + m_fetch_job->abort(); + + ResourceAPI::Callback> callbacks{}; + m_pack = { m_inst->getManagedPackID() }; + + // Use default if no callbacks are set + callbacks.on_succeed = [this](auto& doc) { + m_pack.versions = doc; + m_pack.versionsLoaded = true; + + // We block signals here so that suggestVersion() doesn't get called, causing an assertion fail. + ui->versionsComboBox->blockSignals(true); + ui->versionsComboBox->clear(); + ui->versionsComboBox->blockSignals(false); + + for (const auto& version : m_pack.versions) { + QString name = version.getVersionDisplayString(); + + // NOTE: the id from version isn't the same id in the modpack format spec... + // e.g. HexMC's 4.4.0 has versionId 4.0.0 in the modpack index.............. + if (version.version == m_inst->getManagedPackVersionName()) + name = tr("%1 (Current)").arg(name); + + ui->versionsComboBox->addItem(name, version.fileId); + } + + suggestVersion(); + + m_loaded = true; + }; + callbacks.on_fail = [this](QString reason, int) { setFailState(); }; + callbacks.on_abort = [this]() { setFailState(); }; + m_fetch_job = m_api.getProjectVersions( + { std::make_shared(m_pack), {}, {}, ModPlatform::ResourceType::Modpack }, std::move(callbacks)); + + ui->changelogTextBrowser->setText(tr("Fetching changelogs...")); + + m_fetch_job->start(); +} + +QString ModrinthManagedPackPage::url() const +{ + return "https://modrinth.com/mod/" + m_inst->getManagedPackID(); +} + +void ModrinthManagedPackPage::suggestVersion() +{ + auto index = ui->versionsComboBox->currentIndex(); + if (m_pack.versions.length() == 0) { + setFailState(); + return; + } + auto version = m_pack.versions.at(index); + + ui->changelogTextBrowser->setHtml(StringUtils::htmlListPatch(markdownToHTML(version.changelog.toUtf8()))); + + ManagedPackPage::suggestVersion(); +} + +/// @brief Called when the update task has completed. +/// Internally handles the closing of the instance window if the update was successful and shows a message box. +/// @param did_succeed Whether the update task was successful. +void ManagedPackPage::onUpdateTaskCompleted(bool did_succeed) const +{ + // Close the window if the update was successful + if (did_succeed) { + if (m_instance_window != nullptr) + m_instance_window->close(); + + CustomMessageBox::selectable(nullptr, tr("Update Successful"), + tr("The instance updated to pack version %1 successfully.").arg(m_inst->getManagedPackVersionName()), + QMessageBox::Information) + ->show(); + } else { + CustomMessageBox::selectable( + nullptr, tr("Update Failed"), + tr("The instance failed to update to pack version %1. Please check launcher logs for more information.") + .arg(m_inst->getManagedPackVersionName()), + QMessageBox::Critical) + ->show(); + } +} + +void ModrinthManagedPackPage::update() +{ + auto customURL = m_inst->settings()->get("ManagedPackURL").toString(); + if (m_inst->getManagedPackID().isEmpty() && !customURL.isEmpty()) { + updatePack(customURL); + return; + } + auto index = ui->versionsComboBox->currentIndex(); + if (m_pack.versions.length() == 0) { + setFailState(); + return; + } + auto version = m_pack.versions.at(index); + + updatePack(version.downloadUrl, version.fileId.toString(), version.version); +} + +void ModrinthManagedPackPage::updateFromFile() +{ + auto output = QFileDialog::getOpenFileUrl(this, tr("Choose update file"), QDir::homePath(), tr("Modrinth pack") + " (*.mrpack *.zip)"); + if (output.isEmpty()) + return; + + updatePack(output); +} + +// FLAME +FlameManagedPackPage::FlameManagedPackPage(BaseInstance* inst, InstanceWindow* instance_window, QWidget* parent) + : ManagedPackPage(inst, instance_window, parent) +{ + Q_ASSERT(inst->isManagedPack()); + connect(ui->versionsComboBox, &QComboBox::currentIndexChanged, this, &FlameManagedPackPage::suggestVersion); + connect(ui->updateButton, &QPushButton::clicked, this, &FlameManagedPackPage::update); + connect(ui->updateFromFileButton, &QPushButton::clicked, this, &FlameManagedPackPage::updateFromFile); +} + +void FlameManagedPackPage::parseManagedPack() +{ + qDebug() << "Parsing Flame pack"; + + // We need to tell the user to redownload the pack, since we didn't save the required info previously + if (m_inst->getManagedPackID().isEmpty()) { + setFailState(); + QString message = + tr("

    Hey there!

    " + "

    " + "It seems like your Pack ID is null. This is because of a bug in older versions of the launcher.
    " + "Unfortunately, we can't do the proper API requests without this information.
    " + "
    " + "So, in order for this feature to work, you will need to re-download the modpack from the built-in downloader.
    " + "
    " + "Don't worry though, it will ask you to update this instance instead, so you'll not lose this instance!" + "

    "); + + ui->changelogTextBrowser->setHtml(StringUtils::htmlListPatch(message)); + return; + } + + // No need for the extra work because we already have everything we need. + if (m_loaded) + return; + + if (m_fetch_job && m_fetch_job->isRunning()) + m_fetch_job->abort(); + + QString id = m_inst->getManagedPackID(); + m_pack = { id }; + + ResourceAPI::Callback> callbacks{}; + + // Use default if no callbacks are set + callbacks.on_succeed = [this](auto& doc) { + m_pack.versions = doc; + m_pack.versionsLoaded = true; + + // We block signals here so that suggestVersion() doesn't get called, causing an assertion fail. + ui->versionsComboBox->blockSignals(true); + ui->versionsComboBox->clear(); + ui->versionsComboBox->blockSignals(false); + + for (const auto& version : m_pack.versions) { + QString name = version.getVersionDisplayString(); + + if (version.fileId == m_inst->getManagedPackVersionID().toInt()) + name = tr("%1 (Current)").arg(name); + + ui->versionsComboBox->addItem(name, QVariant(version.fileId)); + } + + suggestVersion(); + + m_loaded = true; + }; + callbacks.on_fail = [this](QString reason, int) { setFailState(); }; + callbacks.on_abort = [this]() { setFailState(); }; + m_fetch_job = m_api.getProjectVersions( + { std::make_shared(m_pack), {}, {}, ModPlatform::ResourceType::Modpack }, std::move(callbacks)); + + m_fetch_job->start(); +} + +QString FlameManagedPackPage::url() const +{ + // FIXME: We should display the websiteUrl field, but this requires doing the API request first :( + return "https://www.curseforge.com/projects/" + m_inst->getManagedPackID(); +} + +void FlameManagedPackPage::suggestVersion() +{ + auto index = ui->versionsComboBox->currentIndex(); + if (m_pack.versions.length() == 0) { + setFailState(); + return; + } + auto version = m_pack.versions.at(index); + + ui->changelogTextBrowser->setHtml( + StringUtils::htmlListPatch(m_api.getModFileChangelog(m_inst->getManagedPackID().toInt(), version.fileId.toInt()))); + + ManagedPackPage::suggestVersion(); +} + +void FlameManagedPackPage::update() +{ + auto customURL = m_inst->settings()->get("ManagedPackURL").toString(); + if (m_inst->getManagedPackID().isEmpty() && !customURL.isEmpty()) { + updatePack(customURL); + return; + } + auto index = ui->versionsComboBox->currentIndex(); + if (m_pack.versions.length() == 0) { + setFailState(); + return; + } + auto version = m_pack.versions.at(index); + + updatePack(version.downloadUrl, version.fileId.toString()); +} + +void FlameManagedPackPage::updateFromFile() +{ + auto output = QFileDialog::getOpenFileUrl(this, tr("Choose update file"), QDir::homePath(), tr("CurseForge pack") + " (*.zip)"); + if (output.isEmpty()) + return; + + updatePack(output); +} + +void ManagedPackPage::updatePack(const QUrl& url, QString versionID, QString versionName) +{ + QMap extra_info; + // NOTE: Don't use 'm_pack.id' here, since we didn't completely parse all the metadata for the pack, including this field. + extra_info.insert("pack_id", m_inst->getManagedPackID()); + extra_info.insert("pack_version_id", versionID); + extra_info.insert("original_instance_id", m_inst->id()); + + auto extracted = new InstanceImportTask(url, this, std::move(extra_info)); + + if (versionName.isEmpty()) { + extracted->setName(m_inst->name()); + } else { + InstanceName inst_name(m_inst->getManagedPackName(), versionName); + inst_name.setName(m_inst->name().replace(m_inst->getManagedPackVersionName(), versionName)); + extracted->setName(inst_name); + } + extracted->setGroup(APPLICATION->instances()->getInstanceGroup(m_inst->id())); + extracted->setIcon(m_inst->iconKey()); + extracted->setConfirmUpdate(false); + + // Run our task then handle the result + auto did_succeed = runUpdateTask(extracted); + onUpdateTaskCompleted(did_succeed); +} + +#include "ManagedPackPage.moc" diff --git a/launcher/ui/pages/instance/ManagedPackPage.h b/launcher/ui/pages/instance/ManagedPackPage.h new file mode 100644 index 0000000..4b73328 --- /dev/null +++ b/launcher/ui/pages/instance/ManagedPackPage.h @@ -0,0 +1,162 @@ +// SPDX-FileCopyrightText: 2022 flowln +// +// SPDX-License-Identifier: GPL-3.0-only + +#pragma once + +#include "BaseInstance.h" + +#include "modplatform/ModIndex.h" +#include "modplatform/modrinth/ModrinthAPI.h" + +#include "modplatform/flame/FlameAPI.h" + +#include "net/NetJob.h" + +#include "ui/pages/BasePage.h" + +#include + +namespace Ui { +class ManagedPackPage; +} + +class InstanceTask; +class InstanceWindow; + +class ManagedPackPage : public QWidget, public BasePage { + Q_OBJECT + + public: + inline static ManagedPackPage* createPage(BaseInstance* inst, QWidget* parent = nullptr) + { + return ManagedPackPage::createPage(inst, inst->getManagedPackType(), parent); + } + + static ManagedPackPage* createPage(BaseInstance* inst, QString type, QWidget* parent = nullptr); + ~ManagedPackPage() override; + + QString displayName() const override; + QIcon icon() const override; + QString helpPage() const override; + QString id() const override { return "managed_pack"; } + bool shouldDisplay() const override; + + void openedImpl() override; + + bool apply() override { return true; } + void retranslate() override; + + /** Gets the necessary information about the managed pack, such as + * available versions*/ + virtual void parseManagedPack() {}; + + /** URL of the managed pack. + * Not the version-specific one. + */ + virtual QString url() const { return {}; }; + + void setInstanceWindow(InstanceWindow* window) { m_instance_window = window; } + + public slots: + /** Gets the current version selection and update the UI, including the update button and the changelog. + */ + virtual void suggestVersion(); + + virtual void update() {}; + virtual void updateFromFile() {}; + + protected slots: + /** Does the necessary UI changes for when something failed. + * + * This includes: + * - Setting an appropriate text on the version selector to indicate a fail; + * - Setting an appropriate text on the changelog text browser to indicate a fail; + * - Disable the update button. + */ + void setFailState(); + + protected: + ManagedPackPage(BaseInstance* inst, InstanceWindow* instance_window, QWidget* parent = nullptr); + + /** Run the InstanceTask, with a progress dialog and all. + * Similar to MainWindow::instanceFromInstanceTask + * + * Returns whether the task was successful. + */ + bool runUpdateTask(InstanceTask*); + + void updatePack(const QUrl& url, QString versionID = {}, QString versionName = {}); + + protected: + InstanceWindow* m_instance_window = nullptr; + + Ui::ManagedPackPage* ui; + BaseInstance* m_inst; + + bool m_loaded = false; + + void onUpdateTaskCompleted(bool did_succeed) const; +}; + +/** Simple page for when we aren't a managed pack. */ +class GenericManagedPackPage final : public ManagedPackPage { + Q_OBJECT + + public: + GenericManagedPackPage(BaseInstance* inst, InstanceWindow* instance_window, QWidget* parent = nullptr) + : ManagedPackPage(inst, instance_window, parent) + {} + ~GenericManagedPackPage() override = default; + + // TODO: We may want to show this page with some useful info at some point. + bool shouldDisplay() const override { return false; }; +}; + +class ModrinthManagedPackPage final : public ManagedPackPage { + Q_OBJECT + + public: + ModrinthManagedPackPage(BaseInstance* inst, InstanceWindow* instance_window, QWidget* parent = nullptr); + ~ModrinthManagedPackPage() override = default; + + void parseManagedPack() override; + QString url() const override; + QString helpPage() const override { return "modrinth-managed-pack"; } + + public slots: + void suggestVersion() override; + + void update() override; + void updateFromFile() override; + + private: + Task::Ptr m_fetch_job = nullptr; + + ModPlatform::IndexedPack m_pack; + ModrinthAPI m_api; +}; + +class FlameManagedPackPage final : public ManagedPackPage { + Q_OBJECT + + public: + FlameManagedPackPage(BaseInstance* inst, InstanceWindow* instance_window, QWidget* parent = nullptr); + ~FlameManagedPackPage() override = default; + + void parseManagedPack() override; + QString url() const override; + QString helpPage() const override { return "curseforge-managed-pack"; } + + public slots: + void suggestVersion() override; + + void update() override; + void updateFromFile() override; + + private: + Task::Ptr m_fetch_job = nullptr; + + ModPlatform::IndexedPack m_pack; + FlameAPI m_api; +}; diff --git a/launcher/ui/pages/instance/ManagedPackPage.ui b/launcher/ui/pages/instance/ManagedPackPage.ui new file mode 100644 index 0000000..5ed8040 --- /dev/null +++ b/launcher/ui/pages/instance/ManagedPackPage.ui @@ -0,0 +1,219 @@ + + + ManagedPackPage + + + + 0 + 0 + 731 + 538 + + + + + 0 + + + 0 + + + 6 + + + 0 + + + + + + + + 0 + 0 + + + + Pack Information + + + + + + + + Pack Name: + + + + + + + placeholder + + + + + + + + + + + Current version: + + + + + + + IBeamCursor + + + placeholder + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse + + + + + + + + + + + Provider information: + + + + + + + IBeamCursor + + + placeholder + + + Qt::RichText + + + true + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse + + + + + + + + + + + + Qt::Horizontal + + + + + + + + + + 0 + 0 + + + + Update to version: + + + + + + + + + + + + + false + + + + 0 + 0 + + + + Fetching versions... + + + + + + + + 0 + 0 + + + + Update From File + + + + + + + + + + 0 + 0 + + + + Changelog + + + + + + No changelog available for this version! + + + false + + + + + + + + + + + + Reload page + + + + + + + + ProjectDescriptionPage + QTextBrowser +
    ui/widgets/ProjectDescriptionPage.h
    +
    +
    + + +
    diff --git a/launcher/ui/pages/instance/McClient.cpp b/launcher/ui/pages/instance/McClient.cpp new file mode 100644 index 0000000..0a71943 --- /dev/null +++ b/launcher/ui/pages/instance/McClient.cpp @@ -0,0 +1,188 @@ +#include "McClient.h" + +#include +#include +#include +#include +#include + +#include "Exception.h" +#include "Json.h" + +McClient::McClient(QObject* parent, QString domain, QString ip, const uint16_t port) + : QObject(parent), m_domain(std::move(domain)), m_ip(std::move(ip)), m_port(port) +{} + +void McClient::getStatusData() +{ + qDebug() << "Connecting to socket.."; + + connect(&m_socket, &QTcpSocket::connected, this, [this]() { + qDebug() << "Connected to socket successfully"; + sendRequest(); + + connect(&m_socket, &QTcpSocket::readyRead, this, &McClient::readRawResponse); + }); + + connect(&m_socket, &QTcpSocket::errorOccurred, this, [this]() { emitFail("Socket disconnected: " + m_socket.errorString()); }); + + m_socket.connectToHost(m_ip, m_port); +} + +void McClient::sendRequest() +{ + QByteArray data; + writeVarInt(data, 0x00); // packet ID + writeVarInt(data, 763); // hardcoded protocol version (763 = 1.20.1) + writeString(data, m_domain); // server address + writeUInt16(data, m_port); // server port + writeVarInt(data, 0x01); // next state + writePacketToSocket(data); // send handshake packet + + writeVarInt(data, 0x00); // packet ID + writePacketToSocket(data); // send status packet +} + +void McClient::readRawResponse() +{ + if (m_responseReadState == ResponseReadState::Finished) { + return; + } + + m_resp.append(m_socket.readAll()); + if (m_responseReadState == ResponseReadState::Waiting && m_resp.size() >= 5) { + m_wantedRespLength = readVarInt(m_resp); + m_responseReadState = ResponseReadState::GotLength; + } + + if (m_responseReadState == ResponseReadState::GotLength && m_resp.size() >= m_wantedRespLength) { + if (m_resp.size() > m_wantedRespLength) { + qDebug().nospace() << "Warning: Packet length doesn't match actual packet size (" << m_wantedRespLength << " expected vs " + << m_resp.size() << " received)"; + } + try { + parseResponse(); + } catch (const Exception& e) { + emitFail(e.cause()); + } + m_responseReadState = ResponseReadState::Finished; + } +} + +void McClient::parseResponse() +{ + qDebug() << "Received response successfully"; + + const int packetID = readVarInt(m_resp); + if (packetID != 0x00) { + throw Exception(QString("Packet ID doesn't match expected value (0x00 vs 0x%1)").arg(packetID, 0, 16)); + } + + Q_UNUSED(readVarInt(m_resp)); // json length + + // 'resp' should now be the JSON string + QJsonParseError parseError; + const QJsonDocument doc = Json::parseUntilGarbage(m_resp, &parseError); + if (parseError.error != QJsonParseError::NoError) { + qDebug() << "Failed to parse JSON:" << parseError.errorString(); + emitFail(parseError.errorString()); + return; + } + emitSucceed(doc.object()); +} + +// NOLINTBEGIN(*-signed-bitwise) + +// From https://wiki.vg/Protocol#VarInt_and_VarLong +constexpr uint8_t g_varIntValueMask = 0x7F; +constexpr uint8_t g_varIntContinue = 0x80; + +void McClient::writeVarInt(QByteArray& data, int value) +{ + while ((value & ~g_varIntValueMask) != 0) { // check if the value is too big to fit in 7 bits + // Write 7 bits + data.append(static_cast((value & ~g_varIntValueMask) | g_varIntContinue)); // NOLINT(*-narrowing-conversions) + + // Erase theses 7 bits from the value to write + // Note: >>> means that the sign bit is shifted with the rest of the number rather than being left alone + value >>= 7; + } + data.append(static_cast(value)); // NOLINT(*-narrowing-conversions) +} + +// From https://wiki.vg/Protocol#VarInt_and_VarLong +int McClient::readVarInt(QByteArray& data) +{ + int value = 0; + int position = 0; + + while (position < 32) { + const uint8_t currentByte = readByte(data); + value |= (currentByte & g_varIntValueMask) << position; + + if ((currentByte & g_varIntContinue) == 0) { + break; + } + + position += 7; + } + + if (position >= 32) { + throw Exception("VarInt is too big"); + } + + return value; +} + +// NOLINTEND(*-signed-bitwise) + +uint8_t McClient::readByte(QByteArray& data) +{ + if (data.isEmpty()) { + throw Exception("No more bytes to read"); + } + + const uint8_t byte = data.at(0); + data.remove(0, 1); + return byte; +} + +void McClient::writeUInt16(QByteArray& data, const uint16_t value) +{ + QDataStream stream(&data, QIODeviceBase::Append); + stream.setByteOrder(QDataStream::BigEndian); + stream << value; +} + +void McClient::writeString(QByteArray& data, const QString& value) +{ + writeVarInt(data, static_cast(value.size())); + data.append(value.toUtf8()); +} + +void McClient::writePacketToSocket(QByteArray& data) +{ + // we prefix the packet with its length + QByteArray dataWithSize; + writeVarInt(dataWithSize, static_cast(data.size())); + dataWithSize.append(data); + + // write it to the socket + m_socket.write(dataWithSize); + m_socket.flush(); + + data.clear(); +} + +void McClient::emitFail(const QString& error) +{ + qDebug() << "Minecraft server ping for status error:" << error; + emit failed(error); + emit finished(); +} + +void McClient::emitSucceed(QJsonObject data) +{ + emit succeeded(std::move(data)); + emit finished(); +} diff --git a/launcher/ui/pages/instance/McClient.h b/launcher/ui/pages/instance/McClient.h new file mode 100644 index 0000000..c1cb3d7 --- /dev/null +++ b/launcher/ui/pages/instance/McClient.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include +#include + +// Client for the Minecraft protocol +class McClient : public QObject { + Q_OBJECT + + public: + explicit McClient(QObject* parent, QString domain, QString ip, uint16_t port); + //! Read status data of the server, and calls the succeeded() signal with the parsed JSON data + void getStatusData(); + + signals: + void succeeded(QJsonObject data); + void failed(QString error); + void finished(); + + private: + static uint8_t readByte(QByteArray& data); + static int readVarInt(QByteArray& data); + static void writeUInt16(QByteArray& data, uint16_t value); + static void writeString(QByteArray& data, const QString& value); + static void writeVarInt(QByteArray& data, int value); + + private: + void sendRequest(); + //! Accumulate data until we have a full response, then call parseResponse() once + void readRawResponse(); + void parseResponse(); + void writePacketToSocket(QByteArray& data); + + void emitFail(const QString& error); + void emitSucceed(QJsonObject data); + + private: + enum class ResponseReadState : uint8_t { + Waiting, + GotLength, + Finished + }; + + QString m_domain; + QString m_ip; + uint16_t m_port; + QTcpSocket m_socket; + + ResponseReadState m_responseReadState = ResponseReadState::Waiting; + int32_t m_wantedRespLength = 0; + QByteArray m_resp; +}; diff --git a/launcher/ui/pages/instance/McResolver.cpp b/launcher/ui/pages/instance/McResolver.cpp new file mode 100644 index 0000000..5e2b823 --- /dev/null +++ b/launcher/ui/pages/instance/McResolver.cpp @@ -0,0 +1,78 @@ +#include +#include +#include +#include + +#include "McResolver.h" + +McResolver::McResolver(QObject* parent, QString domain, int port) : QObject(parent), m_constrDomain(domain), m_constrPort(port) {} + +void McResolver::ping() +{ + pingWithDomainSRV(m_constrDomain, m_constrPort); +} + +void McResolver::pingWithDomainSRV(QString domain, int port) +{ + QDnsLookup* lookup = new QDnsLookup(this); + lookup->setName(QString("_minecraft._tcp.%1").arg(domain)); + lookup->setType(QDnsLookup::SRV); + + connect(lookup, &QDnsLookup::finished, this, [this, domain, port]() { + QDnsLookup* lookup = qobject_cast(sender()); + + lookup->deleteLater(); + + if (lookup->error() != QDnsLookup::NoError) { + qDebug() << QString("Warning: SRV record lookup failed (%1), trying A record lookup").arg(lookup->errorString()); + pingWithDomainA(domain, port); + return; + } + + auto records = lookup->serviceRecords(); + if (records.isEmpty()) { + qDebug() << "Warning: no SRV entries found for domain, trying A record lookup"; + pingWithDomainA(domain, port); + return; + } + + const auto& firstRecord = records.at(0); + QString newDomain = firstRecord.target(); + int newPort = firstRecord.port(); + pingWithDomainA(newDomain, newPort); + }); + + lookup->lookup(); +} + +void McResolver::pingWithDomainA(QString domain, int port) +{ + QHostInfo::lookupHost(domain, this, [this, port](const QHostInfo& hostInfo) { + if (hostInfo.error() != QHostInfo::NoError) { + emitFail("A record lookup failed"); + return; + } + + auto records = hostInfo.addresses(); + if (records.isEmpty()) { + emitFail("No A entries found for domain"); + return; + } + + const auto& firstRecord = records.at(0); + emitSucceed(firstRecord.toString(), port); + }); +} + +void McResolver::emitFail(QString error) +{ + qDebug() << "DNS resolver error:" << error; + emit failed(error); + emit finished(); +} + +void McResolver::emitSucceed(QString ip, int port) +{ + emit succeeded(ip, port); + emit finished(); +} diff --git a/launcher/ui/pages/instance/McResolver.h b/launcher/ui/pages/instance/McResolver.h new file mode 100644 index 0000000..3dfeddc --- /dev/null +++ b/launcher/ui/pages/instance/McResolver.h @@ -0,0 +1,28 @@ +#include +#include +#include +#include +#include + +// resolve the IP and port of a Minecraft server +class McResolver : public QObject { + Q_OBJECT + + QString m_constrDomain; + int m_constrPort; + + public: + explicit McResolver(QObject* parent, QString domain, int port); + void ping(); + + private: + void pingWithDomainSRV(QString domain, int port); + void pingWithDomainA(QString domain, int port); + void emitFail(QString error); + void emitSucceed(QString ip, int port); + + signals: + void succeeded(QString ip, int port); + void failed(QString error); + void finished(); +}; diff --git a/launcher/ui/pages/instance/ModFolderPage.cpp b/launcher/ui/pages/instance/ModFolderPage.cpp new file mode 100644 index 0000000..7ba72a9 --- /dev/null +++ b/launcher/ui/pages/instance/ModFolderPage.cpp @@ -0,0 +1,442 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2022 TheKodeToad + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ModFolderPage.h" +#include "minecraft/mod/Resource.h" +#include "ui/dialogs/ExportToModListDialog.h" +#include "ui/dialogs/InstallLoaderDialog.h" +#include "ui_ExternalResourcesPage.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Application.h" + +#include "ui/dialogs/CustomMessageBox.h" +#include "ui/dialogs/ResourceDownloadDialog.h" +#include "ui/dialogs/ResourceUpdateDialog.h" + +#include "minecraft/PackProfile.h" +#include "minecraft/VersionFilterData.h" +#include "minecraft/mod/Mod.h" +#include "minecraft/mod/ModFolderModel.h" + +#include "tasks/ConcurrentTask.h" +#include "tasks/Task.h" +#include "ui/dialogs/ProgressDialog.h" + +ModFolderPage::ModFolderPage(BaseInstance* inst, ModFolderModel* model, QWidget* parent) + : ExternalResourcesPage(inst, model, parent), m_model(model) +{ + ui->actionDownloadItem->setText(tr("Download Mods")); + ui->actionDownloadItem->setToolTip(tr("Download mods from online mod platforms")); + ui->actionDownloadItem->setEnabled(true); + ui->actionsToolbar->insertActionBefore(ui->actionAddItem, ui->actionDownloadItem); + + connect(ui->actionDownloadItem, &QAction::triggered, this, &ModFolderPage::downloadMods); + + ui->actionUpdateItem->setToolTip(tr("Try to check or update all selected mods (all mods if none are selected)")); + connect(ui->actionUpdateItem, &QAction::triggered, this, &ModFolderPage::updateMods); + ui->actionsToolbar->insertActionBefore(ui->actionAddItem, ui->actionUpdateItem); + + auto updateMenu = new QMenu(this); + + auto update = updateMenu->addAction(tr("Check for Updates")); + connect(update, &QAction::triggered, this, &ModFolderPage::updateMods); + + updateMenu->addAction(ui->actionVerifyItemDependencies); + connect(ui->actionVerifyItemDependencies, &QAction::triggered, this, [this] { updateMods(true); }); + + auto depsDisabled = APPLICATION->settings()->getSetting("ModDependenciesDisabled"); + ui->actionVerifyItemDependencies->setVisible(!depsDisabled->get().toBool()); + connect(depsDisabled.get(), &Setting::SettingChanged, this, + [this](const Setting&, const QVariant& value) { ui->actionVerifyItemDependencies->setVisible(!value.toBool()); }); + + updateMenu->addAction(ui->actionResetItemMetadata); + connect(ui->actionResetItemMetadata, &QAction::triggered, this, &ModFolderPage::deleteModMetadata); + + ui->actionUpdateItem->setMenu(updateMenu); + + ui->actionChangeVersion->setToolTip(tr("Change a mod's version.")); + connect(ui->actionChangeVersion, &QAction::triggered, this, &ModFolderPage::changeModVersion); + ui->actionsToolbar->insertActionAfter(ui->actionUpdateItem, ui->actionChangeVersion); + + ui->actionViewHomepage->setToolTip(tr("View the homepages of all selected mods.")); + + ui->actionExportMetadata->setToolTip(tr("Export mod's metadata to text.")); + connect(ui->actionExportMetadata, &QAction::triggered, this, &ModFolderPage::exportModMetadata); + ui->actionsToolbar->insertActionAfter(ui->actionViewHomepage, ui->actionExportMetadata); + + ui->actionsToolbar->insertActionAfter(ui->actionViewFolder, ui->actionViewConfigs); +} + +bool ModFolderPage::shouldDisplay() const +{ + return true; +} + +void ModFolderPage::updateFrame(const QModelIndex& current, [[maybe_unused]] const QModelIndex& previous) +{ + auto sourceCurrent = m_filterModel->mapToSource(current); + int row = sourceCurrent.row(); + const Mod& mod = m_model->at(row); + ui->frame->updateWithMod(mod); +} + +void ModFolderPage::removeItems(const QItemSelection& selection) +{ + if (m_instance != nullptr && m_instance->isRunning()) { + auto response = CustomMessageBox::selectable(this, tr("Confirm Delete"), + tr("If you remove mods while the game is running it may crash your game.\n" + "Are you sure you want to do this?"), + QMessageBox::Warning, QMessageBox::Yes | QMessageBox::No, QMessageBox::No) + ->exec(); + + if (response != QMessageBox::Yes) + return; + } + + auto indexes = selection.indexes(); + auto affected = m_model->getAffectedMods(indexes, EnableAction::DISABLE); + if (!affected.isEmpty()) { + auto response = CustomMessageBox::selectable(this, tr("Confirm Disable"), + tr("The mods you are trying to delete are required by %1 mods.\n" + "Do you want to disable them?") + .arg(affected.length()), + QMessageBox::Warning, QMessageBox::Yes | QMessageBox::No | QMessageBox::Cancel, + QMessageBox::Cancel) + ->exec(); + + if (response == QMessageBox::Cancel) { + return; + } + if (response == QMessageBox::Yes) { + m_model->setResourceEnabled(affected, EnableAction::DISABLE); + } + } + m_model->deleteResources(indexes); +} + +void ModFolderPage::downloadMods() +{ + if (m_instance->typeName() != "Minecraft") + return; // this is a null instance or a legacy instance + + auto profile = static_cast(m_instance)->getPackProfile(); + if (!profile->getModLoaders().has_value()) { + if (handleNoModLoader()) { + return; + } + } + + m_downloadDialog = new ResourceDownload::ModDownloadDialog(this, m_model, m_instance); + connect(this, &QObject::destroyed, m_downloadDialog, &QDialog::close); + connect(m_downloadDialog, &QDialog::finished, this, &ModFolderPage::downloadDialogFinished); + + m_downloadDialog->open(); +} + +void ModFolderPage::downloadDialogFinished(int result) +{ + if (result) { + auto tasks = new ConcurrentTask(tr("Download Mods"), APPLICATION->settings()->get("NumberOfConcurrentDownloads").toInt()); + connect(tasks, &Task::failed, [this, tasks](QString reason) { + CustomMessageBox::selectable(this, tr("Error"), reason, QMessageBox::Critical)->show(); + tasks->deleteLater(); + }); + connect(tasks, &Task::aborted, [this, tasks]() { + CustomMessageBox::selectable(this, tr("Aborted"), tr("Download stopped by user."), QMessageBox::Information)->show(); + tasks->deleteLater(); + }); + connect(tasks, &Task::succeeded, [this, tasks]() { + QStringList warnings = tasks->warnings(); + if (warnings.count()) + CustomMessageBox::selectable(this, tr("Warnings"), warnings.join('\n'), QMessageBox::Warning)->show(); + + tasks->deleteLater(); + }); + + if (m_downloadDialog) { + for (auto& task : m_downloadDialog->getTasks()) { + tasks->addTask(task); + } + } else { + qWarning() << "ResourceDownloadDialog vanished before we could collect tasks!"; + } + + ProgressDialog loadDialog(this); + loadDialog.setSkipButton(true, tr("Abort")); + loadDialog.execWithTask(tasks); + + m_model->update(); + } + if (m_downloadDialog) + m_downloadDialog->deleteLater(); +} + +void ModFolderPage::updateMods(bool includeDeps) +{ + if (m_instance->typeName() != "Minecraft") + return; // this is a null instance or a legacy instance + + auto profile = static_cast(m_instance)->getPackProfile(); + if (!profile->getModLoaders().has_value()) { + if (handleNoModLoader()) { + return; + } + } + if (APPLICATION->settings()->get("ModMetadataDisabled").toBool()) { + QMessageBox::critical(this, tr("Error"), tr("Mod updates are unavailable when metadata is disabled!")); + return; + } + if (m_instance != nullptr && m_instance->isRunning()) { + auto response = + CustomMessageBox::selectable(this, tr("Confirm Update"), + tr("Updating mods while the game is running may cause mod duplication and game crashes.\n" + "The old files may not be deleted as they are in use.\n" + "Are you sure you want to do this?"), + QMessageBox::Warning, QMessageBox::Yes | QMessageBox::No, QMessageBox::No) + ->exec(); + + if (response != QMessageBox::Yes) + return; + } + auto selection = m_filterModel->mapSelectionToSource(ui->treeView->selectionModel()->selection()).indexes(); + + auto mods_list = m_model->selectedResources(selection); + bool use_all = mods_list.empty(); + if (use_all) + mods_list = m_model->allResources(); + + ResourceUpdateDialog update_dialog(this, m_instance, m_model, mods_list, includeDeps, profile->getModLoadersList()); + update_dialog.checkCandidates(); + + if (update_dialog.aborted()) { + CustomMessageBox::selectable(this, tr("Aborted"), tr("The mod updater was aborted!"), QMessageBox::Warning)->show(); + return; + } + if (update_dialog.noUpdates()) { + QString message{ tr("'%1' is up-to-date! :)").arg(mods_list.front()->name()) }; + if (mods_list.size() > 1) { + if (use_all) { + message = tr("All mods are up-to-date! :)"); + } else { + message = tr("All selected mods are up-to-date! :)"); + } + } + CustomMessageBox::selectable(this, tr("Update checker"), message)->exec(); + return; + } + + if (update_dialog.exec()) { + auto tasks = new ConcurrentTask("Download Mods", APPLICATION->settings()->get("NumberOfConcurrentDownloads").toInt()); + connect(tasks, &Task::failed, [this, tasks](QString reason) { + CustomMessageBox::selectable(this, tr("Error"), reason, QMessageBox::Critical)->show(); + tasks->deleteLater(); + }); + connect(tasks, &Task::aborted, [this, tasks]() { + CustomMessageBox::selectable(this, tr("Aborted"), tr("Download stopped by user."), QMessageBox::Information)->show(); + tasks->deleteLater(); + }); + connect(tasks, &Task::succeeded, [this, tasks]() { + QStringList warnings = tasks->warnings(); + if (warnings.count()) { + CustomMessageBox::selectable(this, tr("Warnings"), warnings.join('\n'), QMessageBox::Warning)->show(); + } + tasks->deleteLater(); + }); + + for (auto task : update_dialog.getTasks()) { + tasks->addTask(task); + } + + ProgressDialog loadDialog(this); + loadDialog.setSkipButton(true, tr("Abort")); + loadDialog.execWithTask(tasks); + + m_model->update(); + } +} + +void ModFolderPage::deleteModMetadata() +{ + auto selection = m_filterModel->mapSelectionToSource(ui->treeView->selectionModel()->selection()).indexes(); + auto selectionCount = m_model->selectedMods(selection).length(); + if (selectionCount == 0) + return; + if (selectionCount > 1) { + auto response = CustomMessageBox::selectable(this, tr("Confirm Removal"), + tr("You are about to remove the metadata for %1 mods.\n" + "Are you sure?") + .arg(selectionCount), + QMessageBox::Warning, QMessageBox::Yes | QMessageBox::No, QMessageBox::No) + ->exec(); + + if (response != QMessageBox::Yes) + return; + } + + m_model->deleteMetadata(selection); +} + +void ModFolderPage::changeModVersion() +{ + if (m_instance->typeName() != "Minecraft") + return; // this is a null instance or a legacy instance + + auto profile = static_cast(m_instance)->getPackProfile(); + if (!profile->getModLoaders().has_value()) { + if (handleNoModLoader()) { + return; + } + } + if (APPLICATION->settings()->get("ModMetadataDisabled").toBool()) { + QMessageBox::critical(this, tr("Error"), tr("Mod updates are unavailable when metadata is disabled!")); + return; + } + auto selection = m_filterModel->mapSelectionToSource(ui->treeView->selectionModel()->selection()).indexes(); + auto mods_list = m_model->selectedMods(selection); + if (mods_list.length() != 1 || mods_list[0]->metadata() == nullptr) + return; + + m_downloadDialog = new ResourceDownload::ModDownloadDialog(this, m_model, m_instance); + connect(this, &QObject::destroyed, m_downloadDialog, &QDialog::close); + connect(m_downloadDialog, &QDialog::finished, this, &ModFolderPage::downloadDialogFinished); + + m_downloadDialog->setResourceMetadata((*mods_list.begin())->metadata()); + m_downloadDialog->open(); +} + +void ModFolderPage::exportModMetadata() +{ + auto selection = m_filterModel->mapSelectionToSource(ui->treeView->selectionModel()->selection()).indexes(); + auto selectedMods = m_model->selectedMods(selection); + if (selectedMods.length() == 0) + selectedMods = m_model->allMods(); + + std::sort(selectedMods.begin(), selectedMods.end(), [](const Mod* a, const Mod* b) { return a->name() < b->name(); }); + ExportToModListDialog dlg(m_instance->name(), selectedMods, this); + dlg.exec(); +} + +CoreModFolderPage::CoreModFolderPage(BaseInstance* inst, ModFolderModel* mods, QWidget* parent) : ModFolderPage(inst, mods, parent) +{ + auto mcInst = dynamic_cast(m_instance); + if (mcInst) { + auto version = mcInst->getPackProfile(); + if (version && version->getComponent("net.minecraftforge") && version->getComponent("net.minecraft")) { + auto minecraftCmp = version->getComponent("net.minecraft"); + if (!minecraftCmp->m_loaded) { + version->reload(Net::Mode::Offline); + auto update = version->getCurrentTask(); + if (update) { + connect(update.get(), &Task::finished, this, [this] { + if (m_container) { + m_container->refreshContainer(); + } + }); + if (!update->isRunning()) { + update->start(); + } + } + } + } + } +} + +bool CoreModFolderPage::shouldDisplay() const +{ + if (ModFolderPage::shouldDisplay()) { + auto inst = dynamic_cast(m_instance); + if (!inst) + return true; + + auto version = inst->getPackProfile(); + if (!version || !version->getComponent("net.minecraftforge") || !version->getComponent("net.minecraft")) + return false; + auto minecraftCmp = version->getComponent("net.minecraft"); + return minecraftCmp->m_loaded && minecraftCmp->getReleaseDateTime() < g_VersionFilterData.legacyCutoffDate; + } + return false; +} + +NilModFolderPage::NilModFolderPage(BaseInstance* inst, ModFolderModel* mods, QWidget* parent) : ModFolderPage(inst, mods, parent) {} + +bool NilModFolderPage::shouldDisplay() const +{ + return m_model->dir().exists(); +} + +// Helper function so this doesn't need to be duplicated 3 times +inline bool ModFolderPage::handleNoModLoader() +{ + int resp = + QMessageBox::question(this, this->tr("Missing Mod Loader"), + this->tr("You need to install a compatible mod loader before installing mods. Would you like to do so?"), + QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); + switch (resp) { + case QMessageBox::Yes: { + // Should be safe + auto profile = static_cast(this->m_instance)->getPackProfile(); + InstallLoaderDialog dialog(profile, QString(), this); + bool ret = dialog.exec(); + this->m_container->refreshContainer(); + + // returning negation of dialog.exec which'll be true if the install loader dialog got canceled/closed + // and false if the user went through and installed a loader + return !ret; + } + case QMessageBox::No: { + // Nothing happens the dialog is already closing + // returning true so the caller doesn't go and continue with opening it's dialog without a mod loader + return true; + } + default: { + // Unreachable + // returning true as a safety measure + return true; + } + } +} diff --git a/launcher/ui/pages/instance/ModFolderPage.h b/launcher/ui/pages/instance/ModFolderPage.h new file mode 100644 index 0000000..62db9fa --- /dev/null +++ b/launcher/ui/pages/instance/ModFolderPage.h @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2022 TheKodeToad + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include "ExternalResourcesPage.h" +#include "ui/dialogs/ResourceDownloadDialog.h" + +class ModFolderPage : public ExternalResourcesPage { + Q_OBJECT + + inline bool handleNoModLoader(); + + public: + explicit ModFolderPage(BaseInstance* inst, ModFolderModel* model, QWidget* parent = nullptr); + virtual ~ModFolderPage() = default; + + void setFilter(const QString& filter) { m_fileSelectionFilter = filter; } + + virtual QString displayName() const override { return tr("Mods"); } + virtual QIcon icon() const override { return QIcon::fromTheme("loadermods"); } + virtual QString id() const override { return "mods"; } + virtual QString helpPage() const override { return "Loader-mods"; } + + virtual bool shouldDisplay() const override; + + public slots: + void updateFrame(const QModelIndex& current, const QModelIndex& previous) override; + + private slots: + void removeItems(const QItemSelection& selection) override; + + void downloadMods(); + void downloadDialogFinished(int result); + void updateMods(bool includeDeps = false); + void deleteModMetadata(); + void exportModMetadata(); + void changeModVersion(); + + protected: + ModFolderModel* m_model; + QPointer m_downloadDialog; +}; + +class CoreModFolderPage : public ModFolderPage { + Q_OBJECT + public: + explicit CoreModFolderPage(BaseInstance* inst, ModFolderModel* mods, QWidget* parent = 0); + virtual ~CoreModFolderPage() = default; + + virtual QString displayName() const override { return tr("Core Mods"); } + virtual QIcon icon() const override { return QIcon::fromTheme("coremods"); } + virtual QString id() const override { return "coremods"; } + virtual QString helpPage() const override { return "Core-mods"; } + + virtual bool shouldDisplay() const override; +}; + +class NilModFolderPage : public ModFolderPage { + Q_OBJECT + public: + explicit NilModFolderPage(BaseInstance* inst, ModFolderModel* mods, QWidget* parent = 0); + virtual ~NilModFolderPage() = default; + + virtual QString displayName() const override { return tr("Nilmods"); } + virtual QIcon icon() const override { return QIcon::fromTheme("coremods"); } + virtual QString id() const override { return "nilmods"; } + virtual QString helpPage() const override { return "Nilmods"; } + + virtual bool shouldDisplay() const override; +}; diff --git a/launcher/ui/pages/instance/NotesPage.cpp b/launcher/ui/pages/instance/NotesPage.cpp new file mode 100644 index 0000000..a86369f --- /dev/null +++ b/launcher/ui/pages/instance/NotesPage.cpp @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "NotesPage.h" +#include +#include "ui_NotesPage.h" + +NotesPage::NotesPage(BaseInstance* inst, QWidget* parent) : QWidget(parent), ui(new Ui::NotesPage), m_inst(inst) +{ + ui->setupUi(this); + ui->noteEditor->setText(m_inst->notes()); +} + +NotesPage::~NotesPage() +{ + delete ui; +} + +bool NotesPage::apply() +{ + m_inst->setNotes(ui->noteEditor->toPlainText()); + return true; +} + +void NotesPage::retranslate() +{ + ui->retranslateUi(this); +} diff --git a/launcher/ui/pages/instance/NotesPage.h b/launcher/ui/pages/instance/NotesPage.h new file mode 100644 index 0000000..f11e2ad --- /dev/null +++ b/launcher/ui/pages/instance/NotesPage.h @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +#include "BaseInstance.h" +#include "ui/pages/BasePage.h" + +namespace Ui { +class NotesPage; +} + +class NotesPage : public QWidget, public BasePage { + Q_OBJECT + + public: + explicit NotesPage(BaseInstance* inst, QWidget* parent = 0); + virtual ~NotesPage(); + virtual QString displayName() const override { return tr("Notes"); } + virtual QIcon icon() const override + { + auto icon = QIcon::fromTheme("notes"); + if (icon.isNull()) + icon = QIcon::fromTheme("news"); + return icon; + } + virtual QString id() const override { return "notes"; } + virtual bool apply() override; + virtual QString helpPage() const override { return "Notes"; } + void retranslate() override; + + private: + Ui::NotesPage* ui; + BaseInstance* m_inst; +}; diff --git a/launcher/ui/pages/instance/NotesPage.ui b/launcher/ui/pages/instance/NotesPage.ui new file mode 100644 index 0000000..4b506da --- /dev/null +++ b/launcher/ui/pages/instance/NotesPage.ui @@ -0,0 +1,43 @@ + + + NotesPage + + + + 0 + 0 + 731 + 538 + + + + + 0 + + + 0 + + + 0 + + + + + true + + + false + + + Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse|Qt::TextBrowserInteraction|Qt::TextEditable|Qt::TextEditorInteraction|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse + + + + + + + noteEditor + + + + diff --git a/launcher/ui/pages/instance/OtherLogsPage.cpp b/launcher/ui/pages/instance/OtherLogsPage.cpp new file mode 100644 index 0000000..19a9db0 --- /dev/null +++ b/launcher/ui/pages/instance/OtherLogsPage.cpp @@ -0,0 +1,543 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * Copyright (C) 2022 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "OtherLogsPage.h" +#include "ui_OtherLogsPage.h" + +#include + +#include "ui/GuiUtil.h" +#include "ui/themes/ThemeManager.h" + +#include +#include +#include +#include +#include +#include +#include + +OtherLogsPage::OtherLogsPage(QString id, QString displayName, QString helpPage, BaseInstance* instance, QWidget* parent) + : QWidget(parent) + , m_id(id) + , m_displayName(displayName) + , m_helpPage(helpPage) + , ui(new Ui::OtherLogsPage) + , m_instance(instance) + , m_basePath(instance ? instance->gameRoot() : APPLICATION->dataRoot()) + , m_logSearchPaths(instance ? instance->getLogFileSearchPaths() : QStringList{ "logs" }) +{ + ui->setupUi(this); + + m_proxy = new LogFormatProxyModel(this); + if (m_instance) { + m_model = new LogModel(this); + ui->trackLogCheckbox->hide(); + } else { + m_model = APPLICATION->logModel.get(); + } + + // set up fonts in the log proxy + { + QString fontFamily = APPLICATION->settings()->get("ConsoleFont").toString(); + bool conversionOk = false; + int fontSize = APPLICATION->settings()->get("ConsoleFontSize").toInt(&conversionOk); + if (!conversionOk) { + fontSize = 11; + } + m_proxy->setFont(QFont(fontFamily, fontSize)); + } + + ui->text->setModel(m_proxy); + + if (m_instance) { + m_model->setMaxLines(getConsoleMaxLines(m_instance->settings())); + m_model->setStopOnOverflow(shouldStopOnConsoleOverflow(m_instance->settings())); + m_model->setOverflowMessage(tr("Cannot display this log since the log length surpassed %1 lines.").arg(m_model->getMaxLines())); + } else { + modelStateToUI(); + } + m_proxy->setSourceModel(m_model); + + connect(&m_watcher, &QFileSystemWatcher::directoryChanged, this, &OtherLogsPage::populateSelectLogBox); + + auto findShortcut = new QShortcut(QKeySequence(QKeySequence::Find), this); + connect(findShortcut, &QShortcut::activated, this, &OtherLogsPage::findActivated); + + auto findNextShortcut = new QShortcut(QKeySequence(QKeySequence::FindNext), this); + connect(findNextShortcut, &QShortcut::activated, this, &OtherLogsPage::findNextActivated); + + auto findPreviousShortcut = new QShortcut(QKeySequence(QKeySequence::FindPrevious), this); + connect(findPreviousShortcut, &QShortcut::activated, this, &OtherLogsPage::findPreviousActivated); + + connect(ui->searchBar, &QLineEdit::returnPressed, this, &OtherLogsPage::on_findButton_clicked); +} + +OtherLogsPage::~OtherLogsPage() +{ + delete ui; +} + +void OtherLogsPage::modelStateToUI() +{ + if (m_model->wrapLines()) { + ui->text->setWordWrap(true); + ui->wrapCheckbox->setCheckState(Qt::Checked); + } else { + ui->text->setWordWrap(false); + ui->wrapCheckbox->setCheckState(Qt::Unchecked); + } + if (m_model->colorLines()) { + ui->text->setColorLines(true); + ui->colorCheckbox->setCheckState(Qt::Checked); + } else { + ui->text->setColorLines(false); + ui->colorCheckbox->setCheckState(Qt::Unchecked); + } + if (m_model->suspended()) { + ui->trackLogCheckbox->setCheckState(Qt::Unchecked); + } else { + ui->trackLogCheckbox->setCheckState(Qt::Checked); + } +} + +void OtherLogsPage::UIToModelState() +{ + if (!m_model) { + return; + } + m_model->setLineWrap(ui->wrapCheckbox->checkState() == Qt::Checked); + m_model->setColorLines(ui->colorCheckbox->checkState() == Qt::Checked); + m_model->suspend(ui->trackLogCheckbox->checkState() != Qt::Checked); +} + +void OtherLogsPage::retranslate() +{ + ui->retranslateUi(this); +} + +void OtherLogsPage::openedImpl() +{ + const QStringList failedPaths = m_watcher.addPaths(m_logSearchPaths); + + for (const QString& path : m_logSearchPaths) { + if (failedPaths.contains(path)) + qDebug() << "Failed to start watching" << path; + else + qDebug() << "Started watching" << path; + } + + populateSelectLogBox(); +} + +void OtherLogsPage::closedImpl() +{ + const QStringList failedPaths = m_watcher.removePaths(m_logSearchPaths); + + for (const QString& path : m_logSearchPaths) { + if (failedPaths.contains(path)) + qDebug() << "Failed to stop watching" << path; + else + qDebug() << "Stopped watching" << path; + } +} + +void OtherLogsPage::populateSelectLogBox() +{ + const QString prevCurrentFile = m_currentFile; + + ui->selectLogBox->blockSignals(true); + ui->selectLogBox->clear(); + if (!m_instance) + ui->selectLogBox->addItem(tr("Current logs")); + ui->selectLogBox->addItems(getPaths()); + ui->selectLogBox->blockSignals(false); + + if (!prevCurrentFile.isEmpty()) { + const int index = ui->selectLogBox->findText(prevCurrentFile); + if (index != -1) { + ui->selectLogBox->blockSignals(true); + ui->selectLogBox->setCurrentIndex(index); + ui->selectLogBox->blockSignals(false); + setControlsEnabled(true); + // don't refresh file + return; + } else { + setControlsEnabled(false); + } + } else if (!m_instance) { + ui->selectLogBox->setCurrentIndex(0); + setControlsEnabled(true); + } + + on_selectLogBox_currentIndexChanged(ui->selectLogBox->currentIndex()); +} + +void OtherLogsPage::on_selectLogBox_currentIndexChanged(const int index) +{ + QString file; + if (index > 0 || (index == 0 && m_instance)) { + file = ui->selectLogBox->itemText(index); + } + + if ((index != 0 || m_instance) && (file.isEmpty() || !QFile::exists(FS::PathCombine(m_basePath, file)))) { + m_currentFile = QString(); + ui->text->clear(); + setControlsEnabled(false); + } else { + m_currentFile = file; + reload(); + setControlsEnabled(true); + } +} + +void OtherLogsPage::on_btnReload_clicked() +{ + if (!m_instance && m_currentFile.isEmpty()) { + if (!m_model) + return; + m_model->clear(); + if (m_container) + m_container->refreshContainer(); + } else { + reload(); + } +} + +void OtherLogsPage::reload() +{ + if (m_currentFile.isEmpty()) { + if (m_instance) { + setControlsEnabled(false); + } else { + m_model = APPLICATION->logModel.get(); + m_proxy->setSourceModel(m_model); + ui->text->setModel(m_proxy); + ui->text->scrollToBottom(); + UIToModelState(); + setControlsEnabled(true); + } + return; + } + + QFile file(FS::PathCombine(m_basePath, m_currentFile)); + if (!file.open(QFile::ReadOnly)) { + setControlsEnabled(false); + ui->btnReload->setEnabled(true); // allow reload + m_currentFile = QString(); + QMessageBox::critical(this, tr("Error"), tr("Unable to open %1 for reading: %2").arg(m_currentFile, file.errorString())); + } else { + auto setPlainText = [this](const QString& text) { + QTextDocument* doc = ui->text->document(); + doc->setDefaultFont(m_proxy->getFont()); + ui->text->setPlainText(text); + }; + auto showTooBig = [setPlainText, &file]() { + setPlainText(tr("The file (%1) is too big. You may want to open it in a viewer optimized " + "for large files.") + .arg(file.fileName())); + }; + if (file.size() > (1024ll * 1024ll * 12ll)) { + showTooBig(); + return; + } + MessageLevel last = MessageLevel::Unknown; + + auto handleLine = [this, &last](QString line) { + if (line.isEmpty()) + return false; + if (line.back() == '\n') + line.resize(line.size() - 1); + if (line.back() == '\r') + line.resize(line.size() - 1); + MessageLevel level = MessageLevel::Unknown; + + QString lineTemp = line; // don't edit out the time and level for clarity + if (!m_instance) { + level = MessageLevel::takeFromLauncherLine(lineTemp); + } else { + level = LogParser::guessLevel(line, last); + } + + last = level; + m_model->append(level, line); + return m_model->isOverFlow(); + }; + + // Try to determine a level for each line + ui->text->clear(); + ui->text->setModel(nullptr); + if (!m_instance) { + m_model = new LogModel(this); + m_model->setMaxLines(getConsoleMaxLines(APPLICATION->settings())); + m_model->setStopOnOverflow(shouldStopOnConsoleOverflow(APPLICATION->settings())); + m_model->setOverflowMessage(tr("Cannot display this log since the log length surpassed %1 lines.").arg(m_model->getMaxLines())); + } + m_model->clear(); + if (file.fileName().endsWith(".gz")) { + QString line; + auto error = GZip::readGzFileByBlocks(&file, [&line, handleLine](const QByteArray& d) { + auto block = d; + int newlineIndex = block.indexOf('\n'); + while (newlineIndex != -1) { + line += QString::fromUtf8(block).left(newlineIndex); + block.remove(0, newlineIndex + 1); + if (handleLine(line)) { + line.clear(); + return false; + } + line.clear(); + newlineIndex = block.indexOf('\n'); + } + line += QString::fromUtf8(block); + return true; + }); + if (!error.isEmpty()) { + setPlainText(tr("The file (%1) encountered an error when reading: %2.").arg(file.fileName(), error)); + return; + } else if (!line.isEmpty()) { + handleLine(line); + } + } else { + while (!file.atEnd() && !handleLine(QString::fromUtf8(file.readLine()))) { + } + } + + if (m_instance) { + ui->text->setModel(m_proxy); + ui->text->scrollToBottom(); + } else { + m_proxy->setSourceModel(m_model); + ui->text->setModel(m_proxy); + ui->text->scrollToBottom(); + UIToModelState(); + setControlsEnabled(true); + } + } +} + +void OtherLogsPage::on_btnPaste_clicked() +{ + QString name = m_currentFile.isEmpty() ? displayName() : m_currentFile; + GuiUtil::uploadPaste(name, ui->text->toPlainText(), this); +} + +void OtherLogsPage::on_btnCopy_clicked() +{ + GuiUtil::setClipboardText(ui->text->toPlainText()); +} + +void OtherLogsPage::on_btnBottom_clicked() +{ + ui->text->scrollToBottom(); +} + +void OtherLogsPage::on_trackLogCheckbox_clicked(bool checked) +{ + if (!m_model) + return; + m_model->suspend(!checked); +} + +void OtherLogsPage::on_btnDelete_clicked() +{ + if (m_currentFile.isEmpty()) { + setControlsEnabled(false); + return; + } + if (QMessageBox::question(this, tr("Confirm Deletion"), + tr("You are about to delete \"%1\".\n" + "This may be permanent and it will be gone from the logs folder.\n\n" + "Are you sure?") + .arg(m_currentFile), + QMessageBox::Yes, QMessageBox::No) == QMessageBox::No) { + return; + } + QFile file(FS::PathCombine(m_basePath, m_currentFile)); + + if (FS::trash(file.fileName())) { + return; + } + + if (!file.remove()) { + QMessageBox::critical(this, tr("Error"), tr("Unable to delete %1: %2").arg(m_currentFile, file.errorString())); + } +} + +void OtherLogsPage::on_btnClean_clicked() +{ + auto toDelete = getPaths(); + if (toDelete.isEmpty()) { + return; + } + QMessageBox* messageBox = new QMessageBox(this); + messageBox->setWindowTitle(tr("Confirm Cleanup")); + if (toDelete.size() > 5) { + messageBox->setText(tr("Are you sure you want to delete all log files?")); + messageBox->setDetailedText(toDelete.join('\n')); + } else { + messageBox->setText(tr("Are you sure you want to delete all these files?\n%1").arg(toDelete.join('\n'))); + } + messageBox->setStandardButtons(QMessageBox::Ok | QMessageBox::Cancel); + messageBox->setDefaultButton(QMessageBox::Ok); + messageBox->setTextInteractionFlags(Qt::TextSelectableByMouse); + messageBox->setIcon(QMessageBox::Question); + messageBox->setTextInteractionFlags(Qt::TextBrowserInteraction); + + if (messageBox->exec() != QMessageBox::Ok) { + return; + } + QStringList failed; + for (auto item : toDelete) { + QString absolutePath = FS::PathCombine(m_basePath, item); + QFile file(absolutePath); + qDebug() << "Deleting log" << absolutePath; + if (FS::trash(file.fileName())) { + continue; + } + if (!file.remove()) { + failed.push_back(item); + } + } + if (!failed.empty()) { + QMessageBox* messageBoxFailure = new QMessageBox(this); + messageBoxFailure->setWindowTitle(tr("Error")); + if (failed.size() > 5) { + messageBoxFailure->setText(tr("Couldn't delete some files!")); + messageBoxFailure->setDetailedText(failed.join('\n')); + } else { + messageBoxFailure->setText(tr("Couldn't delete some files:\n%1").arg(failed.join('\n'))); + } + messageBoxFailure->setStandardButtons(QMessageBox::Ok); + messageBoxFailure->setDefaultButton(QMessageBox::Ok); + messageBoxFailure->setTextInteractionFlags(Qt::TextSelectableByMouse); + messageBoxFailure->setIcon(QMessageBox::Critical); + messageBoxFailure->setTextInteractionFlags(Qt::TextBrowserInteraction); + messageBoxFailure->exec(); + } +} + +void OtherLogsPage::on_wrapCheckbox_clicked(bool checked) +{ + ui->text->setWordWrap(checked); + if (!m_model) + return; + m_model->setLineWrap(checked); + ui->text->scrollToBottom(); +} + +void OtherLogsPage::on_colorCheckbox_clicked(bool checked) +{ + ui->text->setColorLines(checked); + if (!m_model) + return; + m_model->setColorLines(checked); + ui->text->scrollToBottom(); +} + +void OtherLogsPage::setControlsEnabled(const bool enabled) +{ + if (m_instance) { + ui->btnDelete->setEnabled(enabled); + ui->btnClean->setEnabled(enabled); + } else if (!m_currentFile.isEmpty()) { + ui->btnReload->setText(tr("&Reload")); + ui->btnReload->setToolTip(tr("Reload the contents of the log from the disk")); + ui->btnDelete->setEnabled(enabled); + ui->btnClean->setEnabled(enabled); + ui->trackLogCheckbox->setEnabled(false); + } else { + ui->btnReload->setText(tr("Clear")); + ui->btnReload->setToolTip(tr("Clear the log")); + ui->btnDelete->setEnabled(false); + ui->btnClean->setEnabled(false); + ui->trackLogCheckbox->setEnabled(enabled); + } + + ui->btnReload->setEnabled(enabled); + ui->btnCopy->setEnabled(enabled); + ui->btnPaste->setEnabled(enabled); + ui->text->setEnabled(enabled); +} + +QStringList OtherLogsPage::getPaths() +{ + QDir baseDir(m_basePath); + + QStringList result; + + for (QString searchPath : m_logSearchPaths) { + QDir searchDir(searchPath); + + QStringList filters{ "*.log", "*.log.gz" }; + + if (searchPath != m_basePath) + filters.append("*.txt"); + + QStringList entries = searchDir.entryList(filters, QDir::Files | QDir::Readable, QDir::SortFlag::Time); + + for (const QString& name : entries) + result.append(baseDir.relativeFilePath(searchDir.filePath(name))); + } + + return result; +} + +void OtherLogsPage::on_findButton_clicked() +{ + auto modifiers = QApplication::keyboardModifiers(); + bool reverse = modifiers & Qt::ShiftModifier; + ui->text->findNext(ui->searchBar->text(), reverse); +} + +void OtherLogsPage::findNextActivated() +{ + ui->text->findNext(ui->searchBar->text(), false); +} + +void OtherLogsPage::findPreviousActivated() +{ + ui->text->findNext(ui->searchBar->text(), true); +} + +void OtherLogsPage::findActivated() +{ + // focus the search bar if it doesn't have focus + if (!ui->searchBar->hasFocus()) { + ui->searchBar->setFocus(); + ui->searchBar->selectAll(); + } +} diff --git a/launcher/ui/pages/instance/OtherLogsPage.h b/launcher/ui/pages/instance/OtherLogsPage.h new file mode 100644 index 0000000..cd2fe64 --- /dev/null +++ b/launcher/ui/pages/instance/OtherLogsPage.h @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +#include +#include +#include "LogPage.h" +#include "ui/pages/BasePage.h" + +namespace Ui { +class OtherLogsPage; +} + +class RecursiveFileSystemWatcher; + +class OtherLogsPage : public QWidget, public BasePage { + Q_OBJECT + + public: + explicit OtherLogsPage(QString id, QString displayName, QString helpPage, BaseInstance* instance = nullptr, QWidget* parent = 0); + ~OtherLogsPage(); + + QString id() const override { return m_id; } + QString displayName() const override { return m_displayName; } + QIcon icon() const override { return QIcon::fromTheme("log"); } + QString helpPage() const override { return m_helpPage; } + void retranslate() override; + + void openedImpl() override; + void closedImpl() override; + + private slots: + void populateSelectLogBox(); + void on_selectLogBox_currentIndexChanged(int index); + void on_btnReload_clicked(); + void on_btnPaste_clicked(); + void on_btnCopy_clicked(); + void on_btnDelete_clicked(); + void on_btnClean_clicked(); + void on_btnBottom_clicked(); + + void on_trackLogCheckbox_clicked(bool checked); + void on_wrapCheckbox_clicked(bool checked); + void on_colorCheckbox_clicked(bool checked); + + void on_findButton_clicked(); + void findActivated(); + void findNextActivated(); + void findPreviousActivated(); + + private: + void reload(); + void modelStateToUI(); + void UIToModelState(); + void setControlsEnabled(bool enabled); + + QStringList getPaths(); + + private: + QString m_id; + QString m_displayName; + QString m_helpPage; + + Ui::OtherLogsPage* ui; + BaseInstance* m_instance; + /** Path to display log paths relative to. */ + QString m_basePath; + QStringList m_logSearchPaths; + QString m_currentFile; + QFileSystemWatcher m_watcher; + + LogFormatProxyModel* m_proxy; + LogModel* m_model; +}; diff --git a/launcher/ui/pages/instance/OtherLogsPage.ui b/launcher/ui/pages/instance/OtherLogsPage.ui new file mode 100644 index 0000000..77076d4 --- /dev/null +++ b/launcher/ui/pages/instance/OtherLogsPage.ui @@ -0,0 +1,230 @@ + + + OtherLogsPage + + + + 0 + 0 + 657 + 538 + + + + + 0 + + + 0 + + + 6 + + + 0 + + + + + + 0 + 0 + + + + &Find + + + + + + + Qt::Vertical + + + + + + + + 0 + 0 + + + + Scroll all the way to bottom + + + &Bottom + + + + + + + false + + + false + + + true + + + + + + Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse|Qt::TextBrowserInteraction|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse + + + false + + + + + + + + + + + + 0 + 0 + + + + + + + + Delete the selected log + + + &Delete Selected + + + + + + + Delete all the logs + + + Delete &All + + + + + + + + + + + Keep updating + + + true + + + + + + + Wrap lines + + + true + + + + + + + Color lines + + + true + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Copy the whole log into the clipboard + + + &Copy + + + + + + + Upload the log to the paste service configured in preferences + + + &Upload + + + + + + + Reload the contents of the log from the disk + + + &Reload + + + + + + + + + + + Search + + + + + + + + LogView + QPlainTextEdit +
    ui/widgets/LogView.h
    +
    +
    + + selectLogBox + btnReload + btnCopy + btnPaste + btnDelete + btnClean + wrapCheckbox + colorCheckbox + text + searchBar + findButton + + + +
    diff --git a/launcher/ui/pages/instance/ResourcePackPage.cpp b/launcher/ui/pages/instance/ResourcePackPage.cpp new file mode 100644 index 0000000..eb085e2 --- /dev/null +++ b/launcher/ui/pages/instance/ResourcePackPage.cpp @@ -0,0 +1,257 @@ +// SPDX-FileCopyrightText: 2023 flowln +// +// SPDX-License-Identifier: GPL-3.0-only AND Apache-2.0 +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ResourcePackPage.h" + +#include "ui/dialogs/CustomMessageBox.h" +#include "ui/dialogs/ProgressDialog.h" +#include "ui/dialogs/ResourceDownloadDialog.h" +#include "ui/dialogs/ResourceUpdateDialog.h" + +ResourcePackPage::ResourcePackPage(MinecraftInstance* instance, ResourcePackFolderModel* model, QWidget* parent) + : ExternalResourcesPage(instance, model, parent), m_model(model) +{ + ui->actionDownloadItem->setText(tr("Download Packs")); + ui->actionDownloadItem->setToolTip(tr("Download resource packs from online mod platforms")); + ui->actionDownloadItem->setEnabled(true); + ui->actionsToolbar->insertActionBefore(ui->actionAddItem, ui->actionDownloadItem); + + connect(ui->actionDownloadItem, &QAction::triggered, this, &ResourcePackPage::downloadResourcePacks); + + ui->actionUpdateItem->setToolTip(tr("Try to check or update all selected resource packs (all resource packs if none are selected)")); + connect(ui->actionUpdateItem, &QAction::triggered, this, &ResourcePackPage::updateResourcePacks); + ui->actionsToolbar->insertActionBefore(ui->actionAddItem, ui->actionUpdateItem); + + auto updateMenu = new QMenu(this); + + auto update = updateMenu->addAction(ui->actionUpdateItem->text()); + connect(update, &QAction::triggered, this, &ResourcePackPage::updateResourcePacks); + + updateMenu->addAction(ui->actionResetItemMetadata); + connect(ui->actionResetItemMetadata, &QAction::triggered, this, &ResourcePackPage::deleteResourcePackMetadata); + + ui->actionUpdateItem->setMenu(updateMenu); + + ui->actionChangeVersion->setToolTip(tr("Change a mod's version.")); + connect(ui->actionChangeVersion, &QAction::triggered, this, &ResourcePackPage::changeResourcePackVersion); + ui->actionsToolbar->insertActionAfter(ui->actionUpdateItem, ui->actionChangeVersion); +} + +void ResourcePackPage::updateFrame(const QModelIndex& current, [[maybe_unused]] const QModelIndex& previous) +{ + auto sourceCurrent = m_filterModel->mapToSource(current); + int row = sourceCurrent.row(); + auto& rp = static_cast(m_model->at(row)); + ui->frame->updateWithResourcePack(rp); +} + +void ResourcePackPage::downloadResourcePacks() +{ + if (m_instance->typeName() != "Minecraft") + return; // this is a null instance or a legacy instance + + m_downloadDialog = new ResourceDownload::ResourcePackDownloadDialog(this, m_model, m_instance); + connect(this, &QObject::destroyed, m_downloadDialog, &QDialog::close); + connect(m_downloadDialog, &QDialog::finished, this, &ResourcePackPage::downloadDialogFinished); + + m_downloadDialog->open(); +} + +void ResourcePackPage::downloadDialogFinished(int result) +{ + if (result) { + auto tasks = new ConcurrentTask("Download Resource Pack", APPLICATION->settings()->get("NumberOfConcurrentDownloads").toInt()); + connect(tasks, &Task::failed, [this, tasks](QString reason) { + CustomMessageBox::selectable(this, tr("Error"), reason, QMessageBox::Critical)->show(); + tasks->deleteLater(); + }); + connect(tasks, &Task::aborted, [this, tasks]() { + CustomMessageBox::selectable(this, tr("Aborted"), tr("Download stopped by user."), QMessageBox::Information)->show(); + tasks->deleteLater(); + }); + connect(tasks, &Task::succeeded, [this, tasks]() { + QStringList warnings = tasks->warnings(); + if (warnings.count()) + CustomMessageBox::selectable(this, tr("Warnings"), warnings.join('\n'), QMessageBox::Warning)->show(); + + tasks->deleteLater(); + }); + + if (m_downloadDialog) { + for (auto& task : m_downloadDialog->getTasks()) { + tasks->addTask(task); + } + } else { + qWarning() << "ResourceDownloadDialog vanished before we could collect tasks!"; + } + + ProgressDialog loadDialog(this); + loadDialog.setSkipButton(true, tr("Abort")); + loadDialog.execWithTask(tasks); + + m_model->update(); + } + if (m_downloadDialog) + m_downloadDialog->deleteLater(); +} + +void ResourcePackPage::updateResourcePacks() +{ + if (m_instance->typeName() != "Minecraft") + return; // this is a null instance or a legacy instance + + if (APPLICATION->settings()->get("ModMetadataDisabled").toBool()) { + QMessageBox::critical(this, tr("Error"), tr("Resource pack updates are unavailable when metadata is disabled!")); + return; + } + if (m_instance != nullptr && m_instance->isRunning()) { + auto response = CustomMessageBox::selectable( + this, tr("Confirm Update"), + tr("Updating resource packs while the game is running may cause pack duplication and game crashes.\n" + "The old files may not be deleted as they are in use.\n" + "Are you sure you want to do this?"), + QMessageBox::Warning, QMessageBox::Yes | QMessageBox::No, QMessageBox::No) + ->exec(); + + if (response != QMessageBox::Yes) + return; + } + auto selection = m_filterModel->mapSelectionToSource(ui->treeView->selectionModel()->selection()).indexes(); + + auto mods_list = m_model->selectedResources(selection); + bool use_all = mods_list.empty(); + if (use_all) + mods_list = m_model->allResources(); + + ResourceUpdateDialog update_dialog(this, m_instance, m_model, mods_list, false); + update_dialog.checkCandidates(); + + if (update_dialog.aborted()) { + CustomMessageBox::selectable(this, tr("Aborted"), tr("The resource pack updater was aborted!"), QMessageBox::Warning)->show(); + return; + } + if (update_dialog.noUpdates()) { + QString message{ tr("'%1' is up-to-date! :)").arg(mods_list.front()->name()) }; + if (mods_list.size() > 1) { + if (use_all) { + message = tr("All resource packs are up-to-date! :)"); + } else { + message = tr("All selected resource packs are up-to-date! :)"); + } + } + CustomMessageBox::selectable(this, tr("Update checker"), message)->exec(); + return; + } + + if (update_dialog.exec()) { + auto tasks = new ConcurrentTask("Download Resource Packs", APPLICATION->settings()->get("NumberOfConcurrentDownloads").toInt()); + connect(tasks, &Task::failed, [this, tasks](QString reason) { + CustomMessageBox::selectable(this, tr("Error"), reason, QMessageBox::Critical)->show(); + tasks->deleteLater(); + }); + connect(tasks, &Task::aborted, [this, tasks]() { + CustomMessageBox::selectable(this, tr("Aborted"), tr("Download stopped by user."), QMessageBox::Information)->show(); + tasks->deleteLater(); + }); + connect(tasks, &Task::succeeded, [this, tasks]() { + QStringList warnings = tasks->warnings(); + if (warnings.count()) { + CustomMessageBox::selectable(this, tr("Warnings"), warnings.join('\n'), QMessageBox::Warning)->show(); + } + tasks->deleteLater(); + }); + + for (auto task : update_dialog.getTasks()) { + tasks->addTask(task); + } + + ProgressDialog loadDialog(this); + loadDialog.setSkipButton(true, tr("Abort")); + loadDialog.execWithTask(tasks); + + m_model->update(); + } +} + +void ResourcePackPage::deleteResourcePackMetadata() +{ + auto selection = m_filterModel->mapSelectionToSource(ui->treeView->selectionModel()->selection()).indexes(); + auto selectionCount = m_model->selectedResourcePacks(selection).length(); + if (selectionCount == 0) + return; + if (selectionCount > 1) { + auto response = CustomMessageBox::selectable(this, tr("Confirm Removal"), + tr("You are about to remove the metadata for %1 resource packs.\n" + "Are you sure?") + .arg(selectionCount), + QMessageBox::Warning, QMessageBox::Yes | QMessageBox::No, QMessageBox::No) + ->exec(); + + if (response != QMessageBox::Yes) + return; + } + + m_model->deleteMetadata(selection); +} + +void ResourcePackPage::changeResourcePackVersion() +{ + if (m_instance->typeName() != "Minecraft") + return; // this is a null instance or a legacy instance + + if (APPLICATION->settings()->get("ModMetadataDisabled").toBool()) { + QMessageBox::critical(this, tr("Error"), tr("Resource pack updates are unavailable when metadata is disabled!")); + return; + } + + const QModelIndexList rows = ui->treeView->selectionModel()->selectedRows(); + + if (rows.count() != 1) + return; + + Resource& resource = m_model->at(m_filterModel->mapToSource(rows[0]).row()); + + if (resource.metadata() == nullptr) + return; + + m_downloadDialog = new ResourceDownload::ResourcePackDownloadDialog(this, m_model, m_instance); + connect(this, &QObject::destroyed, m_downloadDialog, &QDialog::close); + connect(m_downloadDialog, &QDialog::finished, this, &ResourcePackPage::downloadDialogFinished); + + m_downloadDialog->setResourceMetadata(resource.metadata()); + m_downloadDialog->open(); +} diff --git a/launcher/ui/pages/instance/ResourcePackPage.h b/launcher/ui/pages/instance/ResourcePackPage.h new file mode 100644 index 0000000..4e673e9 --- /dev/null +++ b/launcher/ui/pages/instance/ResourcePackPage.h @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: 2023 flowln +// +// SPDX-License-Identifier: GPL-3.0-only AND Apache-2.0 +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +#include "ExternalResourcesPage.h" +#include "ui/dialogs/ResourceDownloadDialog.h" +#include "ui_ExternalResourcesPage.h" + +#include "minecraft/mod/ResourcePackFolderModel.h" + +class ResourcePackPage : public ExternalResourcesPage { + Q_OBJECT + public: + explicit ResourcePackPage(MinecraftInstance* instance, ResourcePackFolderModel* model, QWidget* parent = 0); + + QString displayName() const override { return tr("Resource Packs"); } + QIcon icon() const override { return QIcon::fromTheme("resourcepacks"); } + QString id() const override { return "resourcepacks"; } + QString helpPage() const override { return "Resource-packs"; } + + virtual bool shouldDisplay() const override + { + return !m_instance->traits().contains("no-texturepacks") && !m_instance->traits().contains("texturepacks"); + } + + public slots: + void updateFrame(const QModelIndex& current, const QModelIndex& previous) override; + + private slots: + void downloadResourcePacks(); + void downloadDialogFinished(int result); + void updateResourcePacks(); + void deleteResourcePackMetadata(); + void changeResourcePackVersion(); + + protected: + ResourcePackFolderModel* m_model; + QPointer m_downloadDialog; +}; diff --git a/launcher/ui/pages/instance/ScreenshotsPage.cpp b/launcher/ui/pages/instance/ScreenshotsPage.cpp new file mode 100644 index 0000000..70647be --- /dev/null +++ b/launcher/ui/pages/instance/ScreenshotsPage.cpp @@ -0,0 +1,591 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2022 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ScreenshotsPage.h" +#include "BuildConfig.h" +#include "ui_ScreenshotsPage.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include "settings/SettingsObject.h" + +#include "ui/dialogs/CustomMessageBox.h" +#include "ui/dialogs/ProgressDialog.h" + +#include "net/NetJob.h" +#include "screenshots/ImgurAlbumCreation.h" +#include "screenshots/ImgurUpload.h" +#include "tasks/SequentialTask.h" + +#include +#include +#include "RWStorage.h" + +class ScreenshotsFSModel : public QFileSystemModel { + bool canDropMimeData(const QMimeData* data, Qt::DropAction action, int row, int column, const QModelIndex& parent) const override + { + QUrl root = QUrl::fromLocalFile(rootPath()); + // this disables reordering items inside the model + // by rejecting drops if the file is already inside the folder + if (data->hasUrls()) { + for (auto& url : data->urls()) { + if (root.isParentOf(url)) { + return false; + } + } + } + return QFileSystemModel::canDropMimeData(data, action, row, column, parent); + } +}; + +using SharedIconCache = RWStorage; +using SharedIconCachePtr = std::shared_ptr; + +class ThumbnailingResult : public QObject { + Q_OBJECT + public slots: + inline void emitResultsReady(const QString& path) { emit resultsReady(path); } + inline void emitResultsFailed(const QString& path) { emit resultsFailed(path); } + signals: + void resultsReady(const QString& path); + void resultsFailed(const QString& path); +}; + +class ThumbnailRunnable : public QRunnable { + public: + ThumbnailRunnable(QString path, SharedIconCachePtr cache) + { + m_path = path; + m_cache = cache; + } + void run() + { + QFileInfo info(m_path); + if (info.isDir()) + return; + if ((info.suffix().compare("png", Qt::CaseInsensitive) != 0)) + return; + if (!m_cache->stale(m_path)) + return; + QImage image(m_path); + if (image.isNull()) { + m_resultEmitter.emitResultsFailed(m_path); + qDebug() << "Error loading screenshot (perhaps too large?):" + m_path; + return; + } + QImage small; + if (image.width() > image.height()) + small = image.scaledToWidth(512).scaledToWidth(256, Qt::SmoothTransformation); + else + small = image.scaledToHeight(512).scaledToHeight(256, Qt::SmoothTransformation); + QPoint offset((256 - small.width()) / 2, (256 - small.height()) / 2); + QImage square(QSize(256, 256), QImage::Format_ARGB32); + square.fill(Qt::transparent); + + QPainter painter(&square); + painter.drawImage(offset, small); + painter.end(); + + QIcon icon(QPixmap::fromImage(square)); + m_cache->add(m_path, icon); + m_resultEmitter.emitResultsReady(m_path); + } + QString m_path; + SharedIconCachePtr m_cache; + ThumbnailingResult m_resultEmitter; +}; + +// this is about as elegant and well written as a bag of bricks with scribbles done by insane +// asylum patients. +class FilterModel : public QIdentityProxyModel { + Q_OBJECT + public: + explicit FilterModel(QObject* parent = 0) : QIdentityProxyModel(parent) + { + m_thumbnailingPool.setMaxThreadCount(4); + m_thumbnailCache = std::make_shared(); + m_thumbnailCache->add("placeholder", QIcon::fromTheme("screenshot-placeholder")); + connect(&watcher, &QFileSystemWatcher::fileChanged, this, &FilterModel::fileChanged); + } + virtual ~FilterModel() + { + m_thumbnailingPool.clear(); + if (!m_thumbnailingPool.waitForDone(500)) + qDebug() << "Thumbnail pool took longer than 500ms to finish"; + } + virtual QVariant data(const QModelIndex& proxyIndex, int role = Qt::DisplayRole) const + { + auto model = sourceModel(); + if (!model) + return QVariant(); + if (role == Qt::DisplayRole || role == Qt::EditRole) { + QVariant result = sourceModel()->data(mapToSource(proxyIndex), role); + static const QRegularExpression s_removeChars("\\.png$"); + return result.toString().remove(s_removeChars); + } + if (role == Qt::DecorationRole) { + QVariant result = sourceModel()->data(mapToSource(proxyIndex), QFileSystemModel::FilePathRole); + QString filePath = result.toString(); + QIcon temp; + if (!watched.contains(filePath)) { + ((QFileSystemWatcher&)watcher).addPath(filePath); + ((QSet&)watched).insert(filePath); + } + if (m_thumbnailCache->get(filePath, temp)) { + return temp; + } + if (!m_failed.contains(filePath)) { + ((FilterModel*)this)->thumbnailImage(filePath); + } + return (m_thumbnailCache->get("placeholder")); + } + return sourceModel()->data(mapToSource(proxyIndex), role); + } + virtual bool setData(const QModelIndex& index, const QVariant& value, int role = Qt::EditRole) + { + auto model = sourceModel(); + if (!model) + return false; + if (role != Qt::EditRole) + return false; + // FIXME: this is a workaround for a bug in QFileSystemModel, where it doesn't + // sort after renames + { + ((QFileSystemModel*)model)->setNameFilterDisables(true); + ((QFileSystemModel*)model)->setNameFilterDisables(false); + } + return model->setData(mapToSource(index), value.toString() + ".png", role); + } + + private: + void thumbnailImage(QString path) + { + auto runnable = new ThumbnailRunnable(path, m_thumbnailCache); + connect(&runnable->m_resultEmitter, &ThumbnailingResult::resultsReady, this, &FilterModel::thumbnailReady); + connect(&runnable->m_resultEmitter, &ThumbnailingResult::resultsFailed, this, &FilterModel::thumbnailFailed); + m_thumbnailingPool.start(runnable); + } + private slots: + void thumbnailReady(QString path) { emit layoutChanged(); } + void thumbnailFailed(QString path) { m_failed.insert(path); } + void fileChanged(QString filepath) + { + m_thumbnailCache->setStale(filepath); + // reinsert the path... + watcher.removePath(filepath); + if (QFile::exists(filepath)) { + watcher.addPath(filepath); + thumbnailImage(filepath); + } + } + + private: + SharedIconCachePtr m_thumbnailCache; + QThreadPool m_thumbnailingPool; + QSet m_failed; + QSet watched; + QFileSystemWatcher watcher; +}; + +class CenteredEditingDelegate : public QStyledItemDelegate { + public: + explicit CenteredEditingDelegate(QObject* parent = 0) : QStyledItemDelegate(parent) {} + virtual ~CenteredEditingDelegate() {} + virtual QWidget* createEditor(QWidget* parent, const QStyleOptionViewItem& option, const QModelIndex& index) const + { + auto widget = QStyledItemDelegate::createEditor(parent, option, index); + auto foo = dynamic_cast(widget); + if (foo) { + foo->setAlignment(Qt::AlignHCenter); + foo->setFrame(true); + foo->setMaximumWidth(192); + } + return widget; + } +}; + +ScreenshotsPage::ScreenshotsPage(QString path, QWidget* parent) : QMainWindow(parent), ui(new Ui::ScreenshotsPage) +{ + m_model.reset(new ScreenshotsFSModel()); + m_filterModel.reset(new FilterModel()); + m_filterModel->setSourceModel(m_model.get()); + m_model->setFilter(QDir::Files); + m_model->setReadOnly(false); + m_model->setNameFilters({ "*.png" }); + m_model->setNameFilterDisables(false); + // Sorts by modified date instead of creation date because that column is not available and would require subclassing, this should work + // considering screenshots aren't modified after creation. + constexpr int file_modified_column_index = 3; + m_model->sort(file_modified_column_index, Qt::DescendingOrder); + + m_folder = path; + m_valid = FS::ensureFolderPathExists(m_folder); + + ui->setupUi(this); + ui->toolBar->insertSpacer(ui->actionView_Folder); + + ui->listView->setIconSize(QSize(128, 128)); + ui->listView->setGridSize(QSize(192, 160)); + ui->listView->setSpacing(9); + // ui->listView->setUniformItemSizes(true); + ui->listView->setLayoutMode(QListView::Batched); + ui->listView->setViewMode(QListView::IconMode); + ui->listView->setResizeMode(QListView::Adjust); + ui->listView->installEventFilter(this); + ui->listView->setEditTriggers(QAbstractItemView::NoEditTriggers); + ui->listView->setItemDelegate(new CenteredEditingDelegate(this)); + ui->listView->setContextMenuPolicy(Qt::CustomContextMenu); + connect(ui->listView, &QListView::customContextMenuRequested, this, &ScreenshotsPage::ShowContextMenu); + connect(ui->listView, &QAbstractItemView::activated, this, &ScreenshotsPage::onItemActivated); +} + +bool ScreenshotsPage::eventFilter(QObject* obj, QEvent* evt) +{ + if (obj != ui->listView) + return QWidget::eventFilter(obj, evt); + if (evt->type() != QEvent::KeyPress) { + return QWidget::eventFilter(obj, evt); + } + QKeyEvent* keyEvent = static_cast(evt); + + if (keyEvent->matches(QKeySequence::Copy)) { + on_actionCopy_File_s_triggered(); + return true; + } + + switch (keyEvent->key()) { + case Qt::Key_Delete: + on_actionDelete_triggered(); + return true; + case Qt::Key_F2: + on_actionRename_triggered(); + return true; + default: + break; + } + return QWidget::eventFilter(obj, evt); +} + +void ScreenshotsPage::retranslate() +{ + ui->retranslateUi(this); +} + +ScreenshotsPage::~ScreenshotsPage() +{ + delete ui; +} + +void ScreenshotsPage::ShowContextMenu(const QPoint& pos) +{ + auto menu = ui->toolBar->createContextMenu(this, tr("Context menu")); + + if (ui->listView->selectionModel()->selectedRows().size() > 1) { + menu->removeAction(ui->actionCopy_Image); + } + + menu->exec(ui->listView->mapToGlobal(pos)); + delete menu; +} + +QMenu* ScreenshotsPage::createPopupMenu() +{ + QMenu* filteredMenu = QMainWindow::createPopupMenu(); + filteredMenu->removeAction(ui->toolBar->toggleViewAction()); + return filteredMenu; +} + +void ScreenshotsPage::onItemActivated(QModelIndex index) +{ + if (!index.isValid()) + return; + auto info = m_model->fileInfo(index); + DesktopServices::openPath(info); +} + +void ScreenshotsPage::onCurrentSelectionChanged(const QItemSelection& selected) +{ + bool allReadable = !selected.isEmpty(); + bool allWritable = !selected.isEmpty(); + + for (auto index : selected.indexes()) { + if (!index.isValid()) + break; + auto info = m_model->fileInfo(index); + if (!info.isReadable()) + allReadable = false; + if (!info.isWritable()) + allWritable = false; + } + + ui->actionUpload->setEnabled(allReadable); + ui->actionCopy_Image->setEnabled(allReadable); + ui->actionCopy_File_s->setEnabled(allReadable); + ui->actionDelete->setEnabled(allWritable); + ui->actionRename->setEnabled(allWritable); +} + +void ScreenshotsPage::on_actionView_Folder_triggered() +{ + DesktopServices::openPath(m_folder, true); +} + +void ScreenshotsPage::on_actionUpload_triggered() +{ + auto selection = ui->listView->selectionModel()->selectedRows(); + if (selection.isEmpty()) + return; + + QString text; + QUrl baseUrl(BuildConfig.IMGUR_BASE_URL); + if (selection.size() > 1) + text = tr("You are about to upload %1 screenshots to %2.\n" + "You should double-check for personal information.\n\n" + "Are you sure?") + .arg(QString::number(selection.size()), baseUrl.host()); + else + text = tr("You are about to upload the selected screenshot to %1.\n" + "You should double-check for personal information.\n\n" + "Are you sure?") + .arg(baseUrl.host()); + + auto response = CustomMessageBox::selectable(this, "Confirm Upload", text, QMessageBox::Warning, QMessageBox::Yes | QMessageBox::No, + QMessageBox::No) + ->exec(); + + if (response != QMessageBox::Yes) + return; + + QList uploaded; + auto job = NetJob::Ptr(new NetJob("Screenshot Upload", APPLICATION->network())); + + ProgressDialog dialog(this); + dialog.setSkipButton(true, tr("Abort")); + + if (selection.size() < 2) { + auto item = selection.at(0); + auto info = m_model->fileInfo(item); + auto screenshot = std::make_shared(info); + job->addNetAction(ImgurUpload::make(screenshot)); + + connect(job.get(), &Task::failed, [this](QString reason) { + CustomMessageBox::selectable(this, tr("Failed to upload screenshots!"), reason, QMessageBox::Critical)->show(); + }); + connect(job.get(), &Task::aborted, [this] { + CustomMessageBox::selectable(this, tr("Screenshots upload aborted"), tr("The task has been aborted by the user."), + QMessageBox::Information) + ->show(); + }); + + m_uploadActive = true; + + if (dialog.execWithTask(job.get()) == QDialog::Accepted) { + auto link = screenshot->m_url; + QClipboard* clipboard = QApplication::clipboard(); + qDebug() << "ImgurUpload link" << link; + clipboard->setText(link); + CustomMessageBox::selectable( + this, tr("Upload finished"), + tr("The link to the uploaded screenshot has been placed in your clipboard.").arg(link), + QMessageBox::Information) + ->exec(); + } + + m_uploadActive = false; + return; + } + + for (auto item : selection) { + auto info = m_model->fileInfo(item); + auto screenshot = std::make_shared(info); + uploaded.push_back(screenshot); + job->addNetAction(ImgurUpload::make(screenshot)); + } + SequentialTask task; + auto albumTask = NetJob::Ptr(new NetJob("Imgur Album Creation", APPLICATION->network())); + auto imgurResult = std::make_shared(); + auto imgurAlbum = ImgurAlbumCreation::make(imgurResult, uploaded); + albumTask->addNetAction(imgurAlbum); + task.addTask(job); + task.addTask(albumTask); + + connect(&task, &Task::failed, [this](QString reason) { + CustomMessageBox::selectable(this, tr("Failed to upload screenshots!"), reason, QMessageBox::Critical)->show(); + }); + connect(&task, &Task::aborted, [this] { + CustomMessageBox::selectable(this, tr("Screenshots upload aborted"), tr("The task has been aborted by the user."), + QMessageBox::Information) + ->show(); + }); + + m_uploadActive = true; + if (dialog.execWithTask(&task) == QDialog::Accepted) { + if (imgurResult->id.isEmpty()) { + CustomMessageBox::selectable(this, tr("Failed to upload screenshots!"), tr("Unknown error"), QMessageBox::Warning)->exec(); + } else { + auto link = QString("https://imgur.com/a/%1").arg(imgurResult->id); + qDebug() << "ImgurUpload link" << link; + QClipboard* clipboard = QApplication::clipboard(); + clipboard->setText(link); + CustomMessageBox::selectable( + this, tr("Upload finished"), + tr("The link to the uploaded album has been placed in your clipboard.").arg(link), + QMessageBox::Information) + ->exec(); + } + } + m_uploadActive = false; +} + +void ScreenshotsPage::on_actionCopy_Image_triggered() +{ + auto selection = ui->listView->selectionModel()->selectedRows(); + if (selection.size() < 1) { + return; + } + + // You can only copy one image to the clipboard. In the case of multiple selected files, only the first one gets copied. + auto item = selection[0]; + auto info = m_model->fileInfo(item); + QImage image(info.absoluteFilePath()); + Q_ASSERT(!image.isNull()); + QApplication::clipboard()->setImage(image, QClipboard::Clipboard); +} + +void ScreenshotsPage::on_actionCopy_File_s_triggered() +{ + auto selection = ui->listView->selectionModel()->selectedRows(); + if (selection.size() < 1) { + // Don't do anything so we don't empty the users clipboard + return; + } + + QString buf = ""; + for (auto item : selection) { + auto info = m_model->fileInfo(item); + buf += "file:///" + info.absoluteFilePath() + "\r\n"; + } + QMimeData* mimeData = new QMimeData(); + mimeData->setData("text/uri-list", buf.toLocal8Bit()); + QApplication::clipboard()->setMimeData(mimeData); +} + +void ScreenshotsPage::on_actionDelete_triggered() +{ + auto selected = ui->listView->selectionModel()->selectedIndexes(); + + int count = ui->listView->selectionModel()->selectedRows().size(); + QString text; + if (count > 1) + text = tr("You are about to delete %1 screenshots.\n" + "This may be permanent and they will be gone from the folder.\n\n" + "Are you sure?") + .arg(count); + else + text = tr("You are about to delete the selected screenshot.\n" + "This may be permanent and it will be gone from the folder.\n\n" + "Are you sure?") + .arg(count); + + auto response = + CustomMessageBox::selectable(this, tr("Confirm Deletion"), text, QMessageBox::Warning, QMessageBox::Yes | QMessageBox::No)->exec(); + + if (response != QMessageBox::Yes) + return; + + for (auto item : selected) { + if (FS::trash(m_model->filePath(item))) + continue; + + m_model->remove(item); + } +} + +void ScreenshotsPage::on_actionRename_triggered() +{ + auto selection = ui->listView->selectionModel()->selectedIndexes(); + if (selection.isEmpty()) + return; + ui->listView->edit(selection[0]); + // TODO: mass renaming +} + +void ScreenshotsPage::openedImpl() +{ + if (!m_valid) { + m_valid = FS::ensureFolderPathExists(m_folder); + } + if (m_valid) { + QString path = QDir(m_folder).absolutePath(); + auto idx = m_model->setRootPath(path); + if (idx.isValid()) { + ui->listView->setModel(m_filterModel.get()); + connect(ui->listView->selectionModel(), &QItemSelectionModel::selectionChanged, this, + &ScreenshotsPage::onCurrentSelectionChanged); + onCurrentSelectionChanged(ui->listView->selectionModel()->selection()); // set initial button enable states + ui->listView->setRootIndex(m_filterModel->mapFromSource(idx)); + } else { + ui->listView->setModel(nullptr); + } + } + + auto const setting_name = QString("WideBarVisibility_%1").arg(id()); + m_wide_bar_setting = APPLICATION->settings()->getOrRegisterSetting(setting_name); + + ui->toolBar->setVisibilityState(QByteArray::fromBase64(m_wide_bar_setting->get().toString().toUtf8())); +} + +void ScreenshotsPage::closedImpl() +{ + m_wide_bar_setting->set(QString::fromUtf8(ui->toolBar->getVisibilityState().toBase64())); +} + +#include "ScreenshotsPage.moc" diff --git a/launcher/ui/pages/instance/ScreenshotsPage.h b/launcher/ui/pages/instance/ScreenshotsPage.h new file mode 100644 index 0000000..0b068aa --- /dev/null +++ b/launcher/ui/pages/instance/ScreenshotsPage.h @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +#include "ui/pages/BasePage.h" + +#include "settings/Setting.h" + +class QIdentityProxyModel; +class QItemSelection; +namespace Ui { +class ScreenshotsPage; +} + +class ScreenshotsFSModel; + +struct ScreenShot; +class ScreenshotList; +class ImgurAlbumCreation; + +class ScreenshotsPage : public QMainWindow, public BasePage { + Q_OBJECT + + public: + explicit ScreenshotsPage(QString path, QWidget* parent = 0); + virtual ~ScreenshotsPage(); + + void openedImpl() override; + void closedImpl() override; + + enum { NothingDone = 0x42 }; + + virtual bool eventFilter(QObject*, QEvent*) override; + virtual QString displayName() const override { return tr("Screenshots"); } + virtual QIcon icon() const override { return QIcon::fromTheme("screenshots"); } + virtual QString id() const override { return "screenshots"; } + virtual QString helpPage() const override { return "Screenshots-management"; } + virtual bool apply() override { return !m_uploadActive; } + void retranslate() override; + + protected: + QMenu* createPopupMenu() override; + + private slots: + void on_actionUpload_triggered(); + void on_actionCopy_Image_triggered(); + void on_actionCopy_File_s_triggered(); + void on_actionDelete_triggered(); + void on_actionRename_triggered(); + void on_actionView_Folder_triggered(); + void onItemActivated(QModelIndex); + void onCurrentSelectionChanged(const QItemSelection& selected); + void ShowContextMenu(const QPoint& pos); + + private: + Ui::ScreenshotsPage* ui; + std::shared_ptr m_model; + std::shared_ptr m_filterModel; + QString m_folder; + bool m_valid = false; + bool m_uploadActive = false; + + std::shared_ptr m_wide_bar_setting = nullptr; +}; diff --git a/launcher/ui/pages/instance/ScreenshotsPage.ui b/launcher/ui/pages/instance/ScreenshotsPage.ui new file mode 100644 index 0000000..4ed92c4 --- /dev/null +++ b/launcher/ui/pages/instance/ScreenshotsPage.ui @@ -0,0 +1,114 @@ + + + ScreenshotsPage + + + + 0 + 0 + 800 + 600 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + false + + + QAbstractItemView::SelectionMode::ExtendedSelection + + + QAbstractItemView::SelectionBehavior::SelectItems + + + QAbstractItemView::ScrollMode::ScrollPerPixel + + + QListView::Movement::Snap + + + + + + + + Actions + + + Qt::ToolButtonStyle::ToolButtonTextOnly + + + RightToolBarArea + + + false + + + + + + + + + + + Upload + + + + + Delete + + + + + Rename + + + + + View Folder + + + + + Copy Image + + + Copy Image + + + + + Copy File(s) + + + Copy File(s) + + + + + + WideBar + QToolBar +
    ui/widgets/WideBar.h
    +
    +
    + + +
    diff --git a/launcher/ui/pages/instance/ServerPingTask.cpp b/launcher/ui/pages/instance/ServerPingTask.cpp new file mode 100644 index 0000000..4317b18 --- /dev/null +++ b/launcher/ui/pages/instance/ServerPingTask.cpp @@ -0,0 +1,47 @@ +#include + +#include +#include "Exception.h" +#include "McClient.h" +#include "McResolver.h" +#include "ServerPingTask.h" + +unsigned getOnlinePlayers(QJsonObject data) +{ + try { + return Json::requireInteger(Json::requireObject(data, "players"), "online"); + } catch (Exception& e) { + qWarning() << "server ping failed to parse response" << e.what(); + return 0; + } +} + +void ServerPingTask::executeTask() +{ + qDebug() << "Querying status of" << QString("%1:%2").arg(m_domain).arg(m_port); + + // Resolve the actual IP and port for the server + McResolver* resolver = new McResolver(nullptr, m_domain, m_port); + connect(resolver, &McResolver::succeeded, this, [this](QString ip, int port) { + qDebug().nospace().noquote() << "Resolved address for " << m_domain << ": " << ip << ":" << port; + + // Now that we have the IP and port, query the server + McClient* client = new McClient(nullptr, m_domain, ip, port); + + connect(client, &McClient::succeeded, this, [this](QJsonObject data) { + m_outputOnlinePlayers = getOnlinePlayers(data); + qDebug() << "Online players:" << m_outputOnlinePlayers; + emitSucceeded(); + }); + connect(client, &McClient::failed, this, [this](QString error) { emitFailed(error); }); + + // Delete McClient object when done + connect(client, &McClient::finished, this, [client]() { client->deleteLater(); }); + client->getStatusData(); + }); + connect(resolver, &McResolver::failed, this, [this](QString error) { emitFailed(error); }); + + // Delete McResolver object when done + connect(resolver, &McResolver::finished, [resolver]() { resolver->deleteLater(); }); + resolver->ping(); +} diff --git a/launcher/ui/pages/instance/ServerPingTask.h b/launcher/ui/pages/instance/ServerPingTask.h new file mode 100644 index 0000000..6f03b92 --- /dev/null +++ b/launcher/ui/pages/instance/ServerPingTask.h @@ -0,0 +1,21 @@ +#pragma once + +#include +#include + +#include + +class ServerPingTask : public Task { + Q_OBJECT + public: + explicit ServerPingTask(QString domain, int port) : Task(), m_domain(domain), m_port(port) {} + ~ServerPingTask() override = default; + int m_outputOnlinePlayers = -1; + + private: + QString m_domain; + int m_port; + + protected: + virtual void executeTask() override; +}; diff --git a/launcher/ui/pages/instance/ServersPage.cpp b/launcher/ui/pages/instance/ServersPage.cpp new file mode 100644 index 0000000..9012ebd --- /dev/null +++ b/launcher/ui/pages/instance/ServersPage.cpp @@ -0,0 +1,768 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ServersPage.h" +#include "Application.h" +#include "ServerPingTask.h" +#include "ui/dialogs/CustomMessageBox.h" +#include "ui_ServersPage.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +static const int COLUMN_COUNT = 3; // 3 , TBD: latency and other nice things. + +struct Server { + // Types + enum class AcceptsTextures : int { ASK = 0, ALWAYS = 1, NEVER = 2 }; + + // Methods + Server() { m_name = QObject::tr("Minecraft Server"); } + Server(const QString& name, const QString& address) + { + m_name = name; + m_address = address; + } + Server(nbt::tag_compound& server) + { + std::string addressStr(server["ip"]); + m_address = QString::fromUtf8(addressStr.c_str()); + + std::string nameStr(server["name"]); + m_name = QString::fromUtf8(nameStr.c_str()); + + if (server["icon"]) { + std::string base64str(server["icon"]); + m_icon = QByteArray::fromBase64(base64str.c_str()); + } + + if (server.has_key("acceptTextures", nbt::tag_type::Byte)) { + bool value = server["acceptTextures"].as().get(); + if (value) { + m_acceptsTextures = AcceptsTextures::ALWAYS; + } else { + m_acceptsTextures = AcceptsTextures::NEVER; + } + } + } + + void serialize(nbt::tag_compound& server) + { + server.insert("name", m_name.trimmed().toUtf8().toStdString()); + server.insert("ip", m_address.trimmed().toUtf8().toStdString()); + if (m_icon.size()) { + server.insert("icon", m_icon.toBase64().toStdString()); + } + if (m_acceptsTextures != AcceptsTextures::ASK) { + server.insert("acceptTextures", nbt::tag_byte(m_acceptsTextures == AcceptsTextures::ALWAYS)); + } + } + + // Data - persistent and user changeable + QString m_name; + QString m_address; + AcceptsTextures m_acceptsTextures = AcceptsTextures::ASK; + + // Data - persistent and automatically updated + QByteArray m_icon; + + // Data - temporary + std::optional m_currentPlayers; // nullopt if not calculated/calculating +}; + +static std::unique_ptr parseServersDat(const QString& filename) +{ + try { + QByteArray input = FS::read(filename); + std::istringstream foo(std::string(input.constData(), input.size())); + auto pair = nbt::io::read_compound(foo); + + if (pair.first != "") + return nullptr; + + if (pair.second == nullptr) + return nullptr; + + return std::move(pair.second); + } catch (...) { + return nullptr; + } +} + +static bool serializeServerDat(const QString& filename, nbt::tag_compound* levelInfo) +{ + try { + if (!FS::ensureFilePathExists(filename)) { + return false; + } + std::ostringstream s; + nbt::io::write_tag("", *levelInfo, s); + QByteArray val(s.str().data(), (int)s.str().size()); + FS::write(filename, val); + return true; + } catch (...) { + return false; + } +} + +class ServersModel : public QAbstractListModel { + Q_OBJECT + public: + enum Roles { + ServerPtrRole = Qt::UserRole, + }; + explicit ServersModel(const QString& path, QObject* parent = 0) : QAbstractListModel(parent) + { + m_path = path; + m_watcher = new QFileSystemWatcher(this); + connect(m_watcher, &QFileSystemWatcher::fileChanged, this, &ServersModel::fileChanged); + connect(m_watcher, &QFileSystemWatcher::directoryChanged, this, &ServersModel::dirChanged); + m_saveTimer.setSingleShot(true); + m_saveTimer.setInterval(5000); + connect(&m_saveTimer, &QTimer::timeout, this, &ServersModel::save_internal); + } + virtual ~ServersModel() = default; + + void observe() + { + if (m_observed) { + return; + } + m_observed = true; + + if (!m_loaded) { + load(); + } + + updateFSObserver(); + } + + void unobserve() + { + if (!m_observed) { + return; + } + m_observed = false; + + updateFSObserver(); + } + + void lock() + { + if (m_locked) { + return; + } + saveNow(); + + m_locked = true; + updateFSObserver(); + } + + void unlock() + { + if (!m_locked) { + return; + } + m_locked = false; + + updateFSObserver(); + } + + int addEmptyRow(int position) + { + if (m_locked) { + return -1; + } + if (position < 0 || position >= rowCount()) { + position = rowCount(); + } + beginInsertRows(QModelIndex(), position, position); + m_servers.insert(position, Server()); + endInsertRows(); + scheduleSave(); + return position; + } + + bool removeRow(int row) + { + if (m_locked) { + return false; + } + if (row < 0 || row >= rowCount()) { + return false; + } + beginRemoveRows(QModelIndex(), row, row); + m_servers.removeAt(row); + endRemoveRows(); // does absolutely nothing, the selected server stays as the next line... + scheduleSave(); + return true; + } + + bool moveUp(int row) + { + if (m_locked) { + return false; + } + if (row <= 0) { + return false; + } + beginMoveRows(QModelIndex(), row, row, QModelIndex(), row - 1); + m_servers.swapItemsAt(row - 1, row); + endMoveRows(); + scheduleSave(); + return true; + } + + bool moveDown(int row) + { + if (m_locked) { + return false; + } + int count = rowCount(); + if (row + 1 >= count) { + return false; + } + beginMoveRows(QModelIndex(), row, row, QModelIndex(), row + 2); + m_servers.swapItemsAt(row + 1, row); + endMoveRows(); + scheduleSave(); + return true; + } + + QVariant headerData(int section, Qt::Orientation orientation, int role) const override + { + if (section < 0 || section >= COLUMN_COUNT) + return QVariant(); + + if (role == Qt::DisplayRole) { + switch (section) { + case 0: + return tr("Name"); + case 1: + return tr("Address"); + case 2: + return tr("Online"); + } + } + + return QAbstractListModel::headerData(section, orientation, role); + } + + virtual QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override + { + if (!index.isValid()) + return QVariant(); + + int row = index.row(); + int column = index.column(); + if (column < 0 || column >= COLUMN_COUNT) + return QVariant(); + + if (row < 0 || row >= m_servers.size()) + return QVariant(); + + switch (role) { + case Qt::DecorationRole: { + if (column == 0) { + auto& bytes = m_servers[row].m_icon; + if (bytes.size()) { + QPixmap px; + if (px.loadFromData(bytes)) + return QIcon(px); + } + return QIcon::fromTheme("unknown_server"); + } else { + return QVariant(); + } + } + case Qt::DisplayRole: + switch (column) { + case 0: + return m_servers[row].m_name; + case 1: + return m_servers[row].m_address; + case 2: + if (m_servers[row].m_currentPlayers) { + return *m_servers[row].m_currentPlayers; + } else { + return "..."; + } + default: + return QVariant(); + } + case ServerPtrRole: + if (column == 0) + return QVariant::fromValue((void*)&m_servers[row]); + else + return QVariant(); + default: + return QVariant(); + } + } + + virtual int rowCount(const QModelIndex& parent = QModelIndex()) const override { return parent.isValid() ? 0 : m_servers.size(); } + int columnCount(const QModelIndex& parent) const override { return parent.isValid() ? 0 : COLUMN_COUNT; } + + Server* at(int index) + { + if (index < 0 || index >= rowCount()) { + return nullptr; + } + return &m_servers[index]; + } + + void setName(int row, const QString& name) + { + if (m_locked) { + return; + } + auto server = at(row); + if (!server || server->m_name == name) { + return; + } + server->m_name = name; + emit dataChanged(index(row, 0), index(row, COLUMN_COUNT - 1)); + scheduleSave(); + } + + void setAddress(int row, const QString& address) + { + if (m_locked) { + return; + } + auto server = at(row); + if (!server || server->m_address == address) { + return; + } + server->m_address = address; + emit dataChanged(index(row, 0), index(row, COLUMN_COUNT - 1)); + scheduleSave(); + } + + void setAcceptsTextures(int row, Server::AcceptsTextures textures) + { + if (m_locked) { + return; + } + auto server = at(row); + if (!server || server->m_acceptsTextures == textures) { + return; + } + server->m_acceptsTextures = textures; + emit dataChanged(index(row, 0), index(row, COLUMN_COUNT - 1)); + scheduleSave(); + } + + void load() + { + cancelSave(); + beginResetModel(); + QList servers; + auto serversDat = parseServersDat(serversPath()); + if (serversDat) { + auto& serversList = serversDat->at("servers").as(); + for (auto iter = serversList.begin(); iter != serversList.end(); iter++) { + auto& serverTag = (*iter).as(); + Server s(serverTag); + servers.append(s); + } + } + m_servers.swap(servers); + m_loaded = true; + endResetModel(); + } + + void saveNow() + { + if (saveIsScheduled()) { + save_internal(); + } + } + + void queryServersStatus() + { + // Abort the currently running task if present + if (m_currentQueryTask != nullptr) { + m_currentQueryTask->abort(); + qDebug() << "Aborted previous server query task"; + } + + m_currentQueryTask = ConcurrentTask::Ptr( + new ConcurrentTask("Query servers status", APPLICATION->settings()->get("NumberOfConcurrentTasks").toInt())); + int row = 0; + for (Server& server : m_servers) { + // reset current players + server.m_currentPlayers = {}; + emit dataChanged(index(row, 0), index(row, COLUMN_COUNT - 1)); + + // Start task to query server status + auto target = MinecraftTarget::parse(server.m_address, false); + auto* task = new ServerPingTask(target.address, target.port); + m_currentQueryTask->addTask(Task::Ptr(task)); + + // Update the model when the task is done + connect(task, &Task::finished, this, [this, task, row]() { + if (m_servers.size() < row) + return; + m_servers[row].m_currentPlayers = task->m_outputOnlinePlayers; + emit dataChanged(index(row, 0), index(row, COLUMN_COUNT - 1)); + }); + row++; + } + + m_currentQueryTask->start(); + } + + public slots: + void dirChanged(const QString& path) + { + qDebug() << "Changed:" << path; + load(); + } + void fileChanged(const QString& path) { qDebug() << "Changed:" << path; } + + private slots: + void save_internal() + { + cancelSave(); + QString path = serversPath(); + qDebug() << "Server list about to be saved to" << path; + + nbt::tag_compound out; + nbt::tag_list list; + for (auto& server : m_servers) { + nbt::tag_compound serverNbt; + server.serialize(serverNbt); + list.push_back(std::move(serverNbt)); + } + out.insert("servers", nbt::value(std::move(list))); + + if (!serializeServerDat(path, &out)) { + qDebug() << "Failed to save server list:" << path << "Will try again."; + scheduleSave(); + } + } + + private: + void scheduleSave() + { + if (!m_loaded) { + qDebug() << "Server list should never save if it didn't successfully load, path:" << m_path; + return; + } + if (!m_dirty) { + m_dirty = true; + qDebug() << "Server list save is scheduled for" << m_path; + } + m_saveTimer.start(); + } + + void cancelSave() + { + m_dirty = false; + m_saveTimer.stop(); + } + + bool saveIsScheduled() const { return m_dirty; } + + void updateFSObserver() + { + bool observingFS = m_watcher->directories().contains(m_path); + if (m_observed && m_locked) { + if (!observingFS) { + qWarning() << "Will watch" << m_path; + if (!m_watcher->addPath(m_path)) { + qWarning() << "Failed to start watching" << m_path; + } + } + } else { + if (observingFS) { + qWarning() << "Will stop watching" << m_path; + if (!m_watcher->removePath(m_path)) { + qWarning() << "Failed to stop watching" << m_path; + } + } + } + } + + QString serversPath() + { + QFileInfo foo(FS::PathCombine(m_path, "servers.dat")); + return foo.filePath(); + } + + private: + bool m_loaded = false; + bool m_locked = false; + bool m_observed = false; + bool m_dirty = false; + QString m_path; + QList m_servers; + QFileSystemWatcher* m_watcher = nullptr; + QTimer m_saveTimer; + ConcurrentTask::Ptr m_currentQueryTask = nullptr; +}; + +ServersPage::ServersPage(BaseInstance* inst, QWidget* parent) : QMainWindow(parent), ui(new Ui::ServersPage) +{ + ui->setupUi(this); + m_inst = inst; + m_model = new ServersModel(inst->gameRoot(), this); + ui->serversView->setIconSize(QSize(64, 64)); + ui->serversView->setModel(m_model); + ui->serversView->setContextMenuPolicy(Qt::CustomContextMenu); + connect(ui->serversView, &QTreeView::customContextMenuRequested, this, &ServersPage::ShowContextMenu); + + auto head = ui->serversView->header(); + if (head->count()) { + head->setSectionResizeMode(0, QHeaderView::Stretch); + for (int i = 1; i < head->count(); i++) { + head->setSectionResizeMode(i, QHeaderView::ResizeToContents); + } + } + + auto selectionModel = ui->serversView->selectionModel(); + connect(selectionModel, &QItemSelectionModel::currentChanged, this, &ServersPage::currentChanged); + connect(m_inst, &MinecraftInstance::runningStatusChanged, this, &ServersPage::runningStateChanged); + connect(ui->nameLine, &QLineEdit::textEdited, this, &ServersPage::nameEdited); + connect(ui->addressLine, &QLineEdit::textEdited, this, &ServersPage::addressEdited); + connect(ui->resourceComboBox, &QComboBox::currentIndexChanged, this, &ServersPage::resourceIndexChanged); + connect(m_model, &QAbstractItemModel::rowsRemoved, this, &ServersPage::rowsRemoved); + + m_locked = m_inst->isRunning(); + if (m_locked) { + m_model->lock(); + } + + updateState(); +} + +ServersPage::~ServersPage() +{ + m_model->saveNow(); + delete ui; +} + +void ServersPage::retranslate() +{ + ui->retranslateUi(this); +} + +void ServersPage::ShowContextMenu(const QPoint& pos) +{ + auto menu = ui->toolBar->createContextMenu(this, tr("Context menu")); + menu->exec(ui->serversView->mapToGlobal(pos)); + delete menu; +} + +QMenu* ServersPage::createPopupMenu() +{ + QMenu* filteredMenu = QMainWindow::createPopupMenu(); + filteredMenu->removeAction(ui->toolBar->toggleViewAction()); + return filteredMenu; +} + +void ServersPage::runningStateChanged(bool running) +{ + if (m_locked == running) { + return; + } + m_locked = running; + if (m_locked) { + m_model->lock(); + } else { + m_model->unlock(); + } + updateState(); +} + +void ServersPage::currentChanged(const QModelIndex& current, [[maybe_unused]] const QModelIndex& previous) +{ + int nextServer = -1; + if (!current.isValid()) { + nextServer = -1; + } else { + nextServer = current.row(); + } + currentServer = nextServer; + updateState(); +} + +// WARNING: this is here because currentChanged is not accurate when removing rows. the current item needs to be fixed up after removal. +void ServersPage::rowsRemoved([[maybe_unused]] const QModelIndex& parent, int first, int last) +{ + if (currentServer < first) { + // current was before the removal + return; + } else if (currentServer >= first && currentServer <= last) { + // current got removed... + return; + } else { + // current was past the removal + int count = last - first + 1; + currentServer -= count; + } +} + +void ServersPage::nameEdited(const QString& name) +{ + m_model->setName(currentServer, name); +} + +void ServersPage::addressEdited(const QString& address) +{ + m_model->setAddress(currentServer, address); +} + +void ServersPage::resourceIndexChanged(int index) +{ + auto acceptsTextures = Server::AcceptsTextures(index); + m_model->setAcceptsTextures(currentServer, acceptsTextures); +} + +void ServersPage::updateState() +{ + auto server = m_model->at(currentServer); + + bool serverEditEnabled = server && !m_locked; + ui->addressLine->setEnabled(serverEditEnabled); + ui->nameLine->setEnabled(serverEditEnabled); + ui->resourceComboBox->setEnabled(serverEditEnabled); + ui->actionMove_Down->setEnabled(serverEditEnabled); + ui->actionMove_Up->setEnabled(serverEditEnabled); + ui->actionRemove->setEnabled(serverEditEnabled); + ui->actionJoin->setEnabled(serverEditEnabled); + + if (server) { + ui->addressLine->setText(server->m_address); + ui->nameLine->setText(server->m_name); + ui->resourceComboBox->setCurrentIndex(int(server->m_acceptsTextures)); + } else { + ui->addressLine->setText(QString()); + ui->nameLine->setText(QString()); + ui->resourceComboBox->setCurrentIndex(0); + } + + ui->actionAdd->setDisabled(m_locked); +} + +void ServersPage::openedImpl() +{ + m_model->observe(); + + auto const setting_name = QString("WideBarVisibility_%1").arg(id()); + m_wide_bar_setting = APPLICATION->settings()->getOrRegisterSetting(setting_name); + + ui->toolBar->setVisibilityState(QByteArray::fromBase64(m_wide_bar_setting->get().toString().toUtf8())); + + // ping servers + m_model->queryServersStatus(); +} + +void ServersPage::closedImpl() +{ + m_model->unobserve(); + + m_wide_bar_setting->set(QString::fromUtf8(ui->toolBar->getVisibilityState().toBase64())); +} + +void ServersPage::on_actionAdd_triggered() +{ + int position = m_model->addEmptyRow(currentServer + 1); + if (position < 0) { + return; + } + // select the new row + ui->serversView->selectionModel()->setCurrentIndex( + m_model->index(position), QItemSelectionModel::SelectCurrent | QItemSelectionModel::Clear | QItemSelectionModel::Rows); + currentServer = position; +} + +void ServersPage::on_actionRemove_triggered() +{ + auto response = + CustomMessageBox::selectable(this, tr("Confirm Removal"), + tr("You are about to remove \"%1\".\n" + "This is permanent and the server will be gone from your list forever (A LONG TIME).\n\n" + "Are you sure?") + .arg(m_model->at(currentServer)->m_name), + QMessageBox::Warning, QMessageBox::Yes | QMessageBox::No, QMessageBox::No) + ->exec(); + + if (response != QMessageBox::Yes) + return; + + m_model->removeRow(currentServer); +} + +void ServersPage::on_actionMove_Up_triggered() +{ + if (m_model->moveUp(currentServer)) { + currentServer--; + } +} + +void ServersPage::on_actionMove_Down_triggered() +{ + if (m_model->moveDown(currentServer)) { + currentServer++; + } +} + +void ServersPage::on_actionJoin_triggered() +{ + const auto& address = m_model->at(currentServer)->m_address; + APPLICATION->launch(m_inst, LaunchMode::Normal, std::make_shared(MinecraftTarget::parse(address, false))); +} + +void ServersPage::on_actionRefresh_triggered() +{ + m_model->queryServersStatus(); +} + +#include "ServersPage.moc" diff --git a/launcher/ui/pages/instance/ServersPage.h b/launcher/ui/pages/instance/ServersPage.h new file mode 100644 index 0000000..0eec57d --- /dev/null +++ b/launcher/ui/pages/instance/ServersPage.h @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +#include "BaseInstance.h" +#include "ui/pages/BasePage.h" + +#include "settings/Setting.h" + +namespace Ui { +class ServersPage; +} + +struct Server; +class ServersModel; +class MinecraftInstance; + +class ServersPage : public QMainWindow, public BasePage { + Q_OBJECT + + public: + explicit ServersPage(BaseInstance* inst, QWidget* parent = 0); + virtual ~ServersPage(); + + void openedImpl() override; + void closedImpl() override; + + virtual QString displayName() const override { return tr("Servers"); } + virtual QIcon icon() const override { return QIcon::fromTheme("server"); } + virtual QString id() const override { return "servers"; } + virtual QString helpPage() const override { return "Servers-management"; } + void retranslate() override; + + protected: + QMenu* createPopupMenu() override; + + private: + void updateState(); + void scheduleSave(); + bool saveIsScheduled() const; + + private slots: + void currentChanged(const QModelIndex& current, const QModelIndex& previous); + void rowsRemoved(const QModelIndex& parent, int first, int last); + + void on_actionAdd_triggered(); + void on_actionRemove_triggered(); + void on_actionMove_Up_triggered(); + void on_actionMove_Down_triggered(); + void on_actionJoin_triggered(); + void on_actionRefresh_triggered(); + + void runningStateChanged(bool running); + + void nameEdited(const QString& name); + void addressEdited(const QString& address); + void resourceIndexChanged(int index); + + void ShowContextMenu(const QPoint& pos); + + private: // data + int currentServer = -1; + bool m_locked = true; + Ui::ServersPage* ui = nullptr; + ServersModel* m_model = nullptr; + BaseInstance* m_inst = nullptr; + + std::shared_ptr m_wide_bar_setting = nullptr; +}; diff --git a/launcher/ui/pages/instance/ServersPage.ui b/launcher/ui/pages/instance/ServersPage.ui new file mode 100644 index 0000000..727b64e --- /dev/null +++ b/launcher/ui/pages/instance/ServersPage.ui @@ -0,0 +1,204 @@ + + + ServersPage + + + + 0 + 0 + 1318 + 879 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + true + + + true + + + QAbstractItemView::SingleSelection + + + QAbstractItemView::SelectRows + + + + 64 + 64 + + + + false + + + QAbstractItemView::ScrollPerPixel + + + false + + + + + + + 6 + + + 6 + + + + + &Name + + + nameLine + + + + + + + + + + Address + + + addressLine + + + + + + + + + + Reso&urces + + + resourceComboBox + + + + + + + + Ask to download + + + + + Always download + + + + + Never download + + + + + + + + + + + Actions + + + Qt::LeftToolBarArea|Qt::RightToolBarArea + + + Qt::ToolButtonTextOnly + + + false + + + RightToolBarArea + + + false + + + + + + + + + + + + Add + + + + + Remove + + + + + Move Up + + + + + Move Down + + + + + Join + + + + + Refresh + + + + + + WideBar + QToolBar +
    ui/widgets/WideBar.h
    +
    +
    + + serversView + nameLine + addressLine + resourceComboBox + + + +
    diff --git a/launcher/ui/pages/instance/ShaderPackPage.cpp b/launcher/ui/pages/instance/ShaderPackPage.cpp new file mode 100644 index 0000000..3120d90 --- /dev/null +++ b/launcher/ui/pages/instance/ShaderPackPage.cpp @@ -0,0 +1,254 @@ +// SPDX-FileCopyrightText: 2023 flowln +// +// SPDX-License-Identifier: GPL-3.0-only AND Apache-2.0 +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ShaderPackPage.h" +#include "ui_ExternalResourcesPage.h" + +#include "ResourceDownloadTask.h" + +#include "minecraft/mod/ShaderPackFolderModel.h" + +#include "ui/dialogs/CustomMessageBox.h" +#include "ui/dialogs/ProgressDialog.h" +#include "ui/dialogs/ResourceDownloadDialog.h" +#include "ui/dialogs/ResourceUpdateDialog.h" + +ShaderPackPage::ShaderPackPage(MinecraftInstance* instance, ShaderPackFolderModel* model, QWidget* parent) + : ExternalResourcesPage(instance, model, parent), m_model(model) +{ + ui->actionDownloadItem->setText(tr("Download Packs")); + ui->actionDownloadItem->setToolTip(tr("Download shader packs from online mod platforms")); + ui->actionDownloadItem->setEnabled(true); + ui->actionsToolbar->insertActionBefore(ui->actionAddItem, ui->actionDownloadItem); + + connect(ui->actionDownloadItem, &QAction::triggered, this, &ShaderPackPage::downloadShaderPack); + + ui->actionUpdateItem->setToolTip(tr("Try to check or update all selected shader packs (all shader packs if none are selected)")); + connect(ui->actionUpdateItem, &QAction::triggered, this, &ShaderPackPage::updateShaderPacks); + ui->actionsToolbar->insertActionBefore(ui->actionAddItem, ui->actionUpdateItem); + + auto updateMenu = new QMenu(this); + + auto update = updateMenu->addAction(ui->actionUpdateItem->text()); + connect(update, &QAction::triggered, this, &ShaderPackPage::updateShaderPacks); + + updateMenu->addAction(ui->actionResetItemMetadata); + connect(ui->actionResetItemMetadata, &QAction::triggered, this, &ShaderPackPage::deleteShaderPackMetadata); + + ui->actionUpdateItem->setMenu(updateMenu); + + ui->actionChangeVersion->setToolTip(tr("Change a shader pack's version.")); + connect(ui->actionChangeVersion, &QAction::triggered, this, &ShaderPackPage::changeShaderPackVersion); + ui->actionsToolbar->insertActionAfter(ui->actionUpdateItem, ui->actionChangeVersion); +} + +void ShaderPackPage::downloadShaderPack() +{ + if (m_instance->typeName() != "Minecraft") + return; // this is a null instance or a legacy instance + + m_downloadDialog = new ResourceDownload::ShaderPackDownloadDialog(this, m_model, m_instance); + connect(this, &QObject::destroyed, m_downloadDialog, &QDialog::close); + connect(m_downloadDialog, &QDialog::finished, this, &ShaderPackPage::downloadDialogFinished); + + m_downloadDialog->open(); +} + +void ShaderPackPage::downloadDialogFinished(int result) +{ + if (result) { + auto tasks = new ConcurrentTask("Download Shader Packs", APPLICATION->settings()->get("NumberOfConcurrentDownloads").toInt()); + connect(tasks, &Task::failed, [this, tasks](QString reason) { + CustomMessageBox::selectable(this, tr("Error"), reason, QMessageBox::Critical)->show(); + tasks->deleteLater(); + }); + connect(tasks, &Task::aborted, [this, tasks]() { + CustomMessageBox::selectable(this, tr("Aborted"), tr("Download stopped by user."), QMessageBox::Information)->show(); + tasks->deleteLater(); + }); + connect(tasks, &Task::succeeded, [this, tasks]() { + QStringList warnings = tasks->warnings(); + if (warnings.count()) + CustomMessageBox::selectable(this, tr("Warnings"), warnings.join('\n'), QMessageBox::Warning)->show(); + + tasks->deleteLater(); + }); + + if (m_downloadDialog) { + for (auto& task : m_downloadDialog->getTasks()) { + tasks->addTask(task); + } + } else { + qWarning() << "ResourceDownloadDialog vanished before we could collect tasks!"; + } + + ProgressDialog loadDialog(this); + loadDialog.setSkipButton(true, tr("Abort")); + loadDialog.execWithTask(tasks); + + m_model->update(); + } + if (m_downloadDialog) + m_downloadDialog->deleteLater(); +} + +void ShaderPackPage::updateShaderPacks() +{ + if (m_instance->typeName() != "Minecraft") + return; // this is a null instance or a legacy instance + + if (APPLICATION->settings()->get("ModMetadataDisabled").toBool()) { + QMessageBox::critical(this, tr("Error"), tr("Shader pack updates are unavailable when metadata is disabled!")); + return; + } + if (m_instance != nullptr && m_instance->isRunning()) { + auto response = + CustomMessageBox::selectable(this, tr("Confirm Update"), + tr("Updating shader packs while the game is running may pack duplication and game crashes.\n" + "The old files may not be deleted as they are in use.\n" + "Are you sure you want to do this?"), + QMessageBox::Warning, QMessageBox::Yes | QMessageBox::No, QMessageBox::No) + ->exec(); + + if (response != QMessageBox::Yes) + return; + } + auto selection = m_filterModel->mapSelectionToSource(ui->treeView->selectionModel()->selection()).indexes(); + + auto mods_list = m_model->selectedResources(selection); + bool use_all = mods_list.empty(); + if (use_all) + mods_list = m_model->allResources(); + + ResourceUpdateDialog update_dialog(this, m_instance, m_model, mods_list, false); + update_dialog.checkCandidates(); + + if (update_dialog.aborted()) { + CustomMessageBox::selectable(this, tr("Aborted"), tr("The shader pack updater was aborted!"), QMessageBox::Warning)->show(); + return; + } + if (update_dialog.noUpdates()) { + QString message{ tr("'%1' is up-to-date! :)").arg(mods_list.front()->name()) }; + if (mods_list.size() > 1) { + if (use_all) { + message = tr("All shader packs are up-to-date! :)"); + } else { + message = tr("All selected shader packs are up-to-date! :)"); + } + } + CustomMessageBox::selectable(this, tr("Update checker"), message)->exec(); + return; + } + + if (update_dialog.exec()) { + auto tasks = new ConcurrentTask("Download Shader Packs", APPLICATION->settings()->get("NumberOfConcurrentDownloads").toInt()); + connect(tasks, &Task::failed, [this, tasks](QString reason) { + CustomMessageBox::selectable(this, tr("Error"), reason, QMessageBox::Critical)->show(); + tasks->deleteLater(); + }); + connect(tasks, &Task::aborted, [this, tasks]() { + CustomMessageBox::selectable(this, tr("Aborted"), tr("Download stopped by user."), QMessageBox::Information)->show(); + tasks->deleteLater(); + }); + connect(tasks, &Task::succeeded, [this, tasks]() { + QStringList warnings = tasks->warnings(); + if (warnings.count()) { + CustomMessageBox::selectable(this, tr("Warnings"), warnings.join('\n'), QMessageBox::Warning)->show(); + } + tasks->deleteLater(); + }); + + for (auto task : update_dialog.getTasks()) { + tasks->addTask(task); + } + + ProgressDialog loadDialog(this); + loadDialog.setSkipButton(true, tr("Abort")); + loadDialog.execWithTask(tasks); + + m_model->update(); + } +} + +void ShaderPackPage::deleteShaderPackMetadata() +{ + auto selection = m_filterModel->mapSelectionToSource(ui->treeView->selectionModel()->selection()).indexes(); + auto selectionCount = m_model->selectedShaderPacks(selection).length(); + if (selectionCount == 0) + return; + if (selectionCount > 1) { + auto response = CustomMessageBox::selectable(this, tr("Confirm Removal"), + tr("You are about to remove the metadata for %1 shader packs.\n" + "Are you sure?") + .arg(selectionCount), + QMessageBox::Warning, QMessageBox::Yes | QMessageBox::No, QMessageBox::No) + ->exec(); + + if (response != QMessageBox::Yes) + return; + } + + m_model->deleteMetadata(selection); +} + +void ShaderPackPage::changeShaderPackVersion() +{ + if (m_instance->typeName() != "Minecraft") + return; // this is a null instance or a legacy instance + + if (APPLICATION->settings()->get("ModMetadataDisabled").toBool()) { + QMessageBox::critical(this, tr("Error"), tr("Shader pack updates are unavailable when metadata is disabled!")); + return; + } + + const QModelIndexList rows = ui->treeView->selectionModel()->selectedRows(); + + if (rows.count() != 1) + return; + + Resource& resource = m_model->at(m_filterModel->mapToSource(rows[0]).row()); + + if (resource.metadata() == nullptr) + return; + + m_downloadDialog = new ResourceDownload::ShaderPackDownloadDialog(this, m_model, m_instance); + connect(this, &QObject::destroyed, m_downloadDialog, &QDialog::close); + connect(m_downloadDialog, &QDialog::finished, this, &ShaderPackPage::downloadDialogFinished); + + m_downloadDialog->setResourceMetadata(resource.metadata()); + m_downloadDialog->open(); +} diff --git a/launcher/ui/pages/instance/ShaderPackPage.h b/launcher/ui/pages/instance/ShaderPackPage.h new file mode 100644 index 0000000..cc53a01 --- /dev/null +++ b/launcher/ui/pages/instance/ShaderPackPage.h @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: 2023 flowln +// +// SPDX-License-Identifier: GPL-3.0-only AND Apache-2.0 +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include "ExternalResourcesPage.h" +#include "ui/dialogs/ResourceDownloadDialog.h" + +class ShaderPackPage : public ExternalResourcesPage { + Q_OBJECT + public: + explicit ShaderPackPage(MinecraftInstance* instance, ShaderPackFolderModel* model, QWidget* parent = nullptr); + ~ShaderPackPage() override = default; + + QString displayName() const override { return tr("Shader Packs"); } + QIcon icon() const override { return QIcon::fromTheme("shaderpacks"); } + QString id() const override { return "shaderpacks"; } + QString helpPage() const override { return "shader-packs"; } + + bool shouldDisplay() const override { return true; } + + public slots: + void downloadShaderPack(); + void downloadDialogFinished(int result); + void updateShaderPacks(); + void deleteShaderPackMetadata(); + void changeShaderPackVersion(); + + private: + ShaderPackFolderModel* m_model; + QPointer m_downloadDialog; +}; diff --git a/launcher/ui/pages/instance/TexturePackPage.cpp b/launcher/ui/pages/instance/TexturePackPage.cpp new file mode 100644 index 0000000..ec0486f --- /dev/null +++ b/launcher/ui/pages/instance/TexturePackPage.cpp @@ -0,0 +1,262 @@ +// SPDX-FileCopyrightText: 2023 flowln +// +// SPDX-License-Identifier: GPL-3.0-only AND Apache-2.0 +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "TexturePackPage.h" + +#include "ResourceDownloadTask.h" + +#include "minecraft/mod/TexturePack.h" + +#include "ui/dialogs/CustomMessageBox.h" +#include "ui/dialogs/ProgressDialog.h" +#include "ui/dialogs/ResourceDownloadDialog.h" +#include "ui/dialogs/ResourceUpdateDialog.h" + +TexturePackPage::TexturePackPage(MinecraftInstance* instance, TexturePackFolderModel* model, QWidget* parent) + : ExternalResourcesPage(instance, model, parent), m_model(model) +{ + ui->actionDownloadItem->setText(tr("Download Packs")); + ui->actionDownloadItem->setToolTip(tr("Download texture packs from online mod platforms")); + ui->actionDownloadItem->setEnabled(true); + ui->actionsToolbar->insertActionBefore(ui->actionAddItem, ui->actionDownloadItem); + + connect(ui->actionDownloadItem, &QAction::triggered, this, &TexturePackPage::downloadTexturePacks); + + ui->actionUpdateItem->setToolTip(tr("Try to check or update all selected texture packs (all texture packs if none are selected)")); + connect(ui->actionUpdateItem, &QAction::triggered, this, &TexturePackPage::updateTexturePacks); + ui->actionsToolbar->insertActionBefore(ui->actionAddItem, ui->actionUpdateItem); + + auto updateMenu = new QMenu(this); + + auto update = updateMenu->addAction(ui->actionUpdateItem->text()); + connect(update, &QAction::triggered, this, &TexturePackPage::updateTexturePacks); + + updateMenu->addAction(ui->actionResetItemMetadata); + connect(ui->actionResetItemMetadata, &QAction::triggered, this, &TexturePackPage::deleteTexturePackMetadata); + + ui->actionUpdateItem->setMenu(updateMenu); + + ui->actionChangeVersion->setToolTip(tr("Change a texture pack's version.")); + connect(ui->actionChangeVersion, &QAction::triggered, this, &TexturePackPage::changeTexturePackVersion); + ui->actionsToolbar->insertActionAfter(ui->actionUpdateItem, ui->actionChangeVersion); + + ui->actionViewHomepage->setToolTip(tr("View the homepages of all selected texture packs.")); +} + +void TexturePackPage::updateFrame(const QModelIndex& current, [[maybe_unused]] const QModelIndex& previous) +{ + auto sourceCurrent = m_filterModel->mapToSource(current); + int row = sourceCurrent.row(); + auto& rp = static_cast(m_model->at(row)); + ui->frame->updateWithTexturePack(rp); +} + +void TexturePackPage::downloadTexturePacks() +{ + if (m_instance->typeName() != "Minecraft") + return; // this is a null instance or a legacy instance + + m_downloadDialog = new ResourceDownload::TexturePackDownloadDialog(this, m_model, m_instance); + connect(this, &QObject::destroyed, m_downloadDialog, &QDialog::close); + connect(m_downloadDialog, &QDialog::finished, this, &TexturePackPage::downloadDialogFinished); + m_downloadDialog->open(); +} + +void TexturePackPage::downloadDialogFinished(int result) +{ + if (result) { + auto tasks = new ConcurrentTask("Download Texture Packs", APPLICATION->settings()->get("NumberOfConcurrentDownloads").toInt()); + connect(tasks, &Task::failed, [this, tasks](QString reason) { + CustomMessageBox::selectable(this, tr("Error"), reason, QMessageBox::Critical)->show(); + tasks->deleteLater(); + }); + connect(tasks, &Task::aborted, [this, tasks]() { + CustomMessageBox::selectable(this, tr("Aborted"), tr("Download stopped by user."), QMessageBox::Information)->show(); + tasks->deleteLater(); + }); + connect(tasks, &Task::succeeded, [this, tasks]() { + QStringList warnings = tasks->warnings(); + if (warnings.count()) + CustomMessageBox::selectable(this, tr("Warnings"), warnings.join('\n'), QMessageBox::Warning)->show(); + + tasks->deleteLater(); + }); + + if (m_downloadDialog) { + for (auto& task : m_downloadDialog->getTasks()) { + tasks->addTask(task); + } + } else { + qWarning() << "ResourceDownloadDialog vanished before we could collect tasks!"; + } + + ProgressDialog loadDialog(this); + loadDialog.setSkipButton(true, tr("Abort")); + loadDialog.execWithTask(tasks); + + m_model->update(); + } + if (m_downloadDialog) + m_downloadDialog->deleteLater(); +} + +void TexturePackPage::updateTexturePacks() +{ + if (m_instance->typeName() != "Minecraft") + return; // this is a null instance or a legacy instance + + if (APPLICATION->settings()->get("ModMetadataDisabled").toBool()) { + QMessageBox::critical(this, tr("Error"), tr("Texture pack updates are unavailable when metadata is disabled!")); + return; + } + if (m_instance != nullptr && m_instance->isRunning()) { + auto response = CustomMessageBox::selectable( + this, tr("Confirm Update"), + tr("Updating texture packs while the game is running may cause pack duplication and game crashes.\n" + "The old files may not be deleted as they are in use.\n" + "Are you sure you want to do this?"), + QMessageBox::Warning, QMessageBox::Yes | QMessageBox::No, QMessageBox::No) + ->exec(); + + if (response != QMessageBox::Yes) + return; + } + auto selection = m_filterModel->mapSelectionToSource(ui->treeView->selectionModel()->selection()).indexes(); + + auto mods_list = m_model->selectedResources(selection); + bool use_all = mods_list.empty(); + if (use_all) + mods_list = m_model->allResources(); + + ResourceUpdateDialog update_dialog(this, m_instance, m_model, mods_list, false); + update_dialog.checkCandidates(); + + if (update_dialog.aborted()) { + CustomMessageBox::selectable(this, tr("Aborted"), tr("The texture pack updater was aborted!"), QMessageBox::Warning)->show(); + return; + } + if (update_dialog.noUpdates()) { + QString message{ tr("'%1' is up-to-date! :)").arg(mods_list.front()->name()) }; + if (mods_list.size() > 1) { + if (use_all) { + message = tr("All texture packs are up-to-date! :)"); + } else { + message = tr("All selected texture packs are up-to-date! :)"); + } + } + CustomMessageBox::selectable(this, tr("Update checker"), message)->exec(); + return; + } + + if (update_dialog.exec()) { + auto tasks = new ConcurrentTask("Download Texture Packs", APPLICATION->settings()->get("NumberOfConcurrentDownloads").toInt()); + connect(tasks, &Task::failed, [this, tasks](QString reason) { + CustomMessageBox::selectable(this, tr("Error"), reason, QMessageBox::Critical)->show(); + tasks->deleteLater(); + }); + connect(tasks, &Task::aborted, [this, tasks]() { + CustomMessageBox::selectable(this, tr("Aborted"), tr("Download stopped by user."), QMessageBox::Information)->show(); + tasks->deleteLater(); + }); + connect(tasks, &Task::succeeded, [this, tasks]() { + QStringList warnings = tasks->warnings(); + if (warnings.count()) { + CustomMessageBox::selectable(this, tr("Warnings"), warnings.join('\n'), QMessageBox::Warning)->show(); + } + tasks->deleteLater(); + }); + + for (auto task : update_dialog.getTasks()) { + tasks->addTask(task); + } + + ProgressDialog loadDialog(this); + loadDialog.setSkipButton(true, tr("Abort")); + loadDialog.execWithTask(tasks); + + m_model->update(); + } +} + +void TexturePackPage::deleteTexturePackMetadata() +{ + auto selection = m_filterModel->mapSelectionToSource(ui->treeView->selectionModel()->selection()).indexes(); + auto selectionCount = m_model->selectedTexturePacks(selection).length(); + if (selectionCount == 0) + return; + if (selectionCount > 1) { + auto response = CustomMessageBox::selectable(this, tr("Confirm Removal"), + tr("You are about to remove the metadata for %1 texture packs.\n" + "Are you sure?") + .arg(selectionCount), + QMessageBox::Warning, QMessageBox::Yes | QMessageBox::No, QMessageBox::No) + ->exec(); + + if (response != QMessageBox::Yes) + return; + } + + m_model->deleteMetadata(selection); +} + +void TexturePackPage::changeTexturePackVersion() +{ + if (m_instance->typeName() != "Minecraft") + return; // this is a null instance or a legacy instance + + if (APPLICATION->settings()->get("ModMetadataDisabled").toBool()) { + QMessageBox::critical(this, tr("Error"), tr("Texture pack updates are unavailable when metadata is disabled!")); + return; + } + + const QModelIndexList rows = ui->treeView->selectionModel()->selectedRows(); + + if (rows.count() != 1) + return; + + Resource& resource = m_model->at(m_filterModel->mapToSource(rows[0]).row()); + + if (resource.metadata() == nullptr) + return; + + m_downloadDialog = new ResourceDownload::TexturePackDownloadDialog(this, m_model, m_instance); + connect(this, &QObject::destroyed, m_downloadDialog, &QDialog::close); + connect(m_downloadDialog, &QDialog::finished, this, &TexturePackPage::downloadDialogFinished); + + m_downloadDialog->setResourceMetadata(resource.metadata()); + m_downloadDialog->open(); +} diff --git a/launcher/ui/pages/instance/TexturePackPage.h b/launcher/ui/pages/instance/TexturePackPage.h new file mode 100644 index 0000000..dad0aff --- /dev/null +++ b/launcher/ui/pages/instance/TexturePackPage.h @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: 2023 flowln +// +// SPDX-License-Identifier: GPL-3.0-only AND Apache-2.0 +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +#include "ExternalResourcesPage.h" +#include "ui/dialogs/ResourceDownloadDialog.h" +#include "ui_ExternalResourcesPage.h" + +#include "minecraft/mod/TexturePackFolderModel.h" + +class TexturePackPage : public ExternalResourcesPage { + Q_OBJECT + public: + explicit TexturePackPage(MinecraftInstance* instance, TexturePackFolderModel* model, QWidget* parent = nullptr); + + QString displayName() const override { return tr("Texture packs"); } + QIcon icon() const override { return QIcon::fromTheme("resourcepacks"); } + QString id() const override { return "texturepacks"; } + QString helpPage() const override { return "Texture-packs"; } + + virtual bool shouldDisplay() const override { return m_instance->traits().contains("texturepacks"); } + + public slots: + void updateFrame(const QModelIndex& current, const QModelIndex& previous) override; + void downloadTexturePacks(); + void downloadDialogFinished(int result); + void updateTexturePacks(); + void deleteTexturePackMetadata(); + void changeTexturePackVersion(); + + private: + TexturePackFolderModel* m_model; + QPointer m_downloadDialog; +}; diff --git a/launcher/ui/pages/instance/VersionPage.cpp b/launcher/ui/pages/instance/VersionPage.cpp new file mode 100644 index 0000000..fea759b --- /dev/null +++ b/launcher/ui/pages/instance/VersionPage.cpp @@ -0,0 +1,591 @@ +// SPDX-FileCopyrightText: 2022-2023 Sefa Eyeoglu +// +// SPDX-License-Identifier: GPL-3.0-only AND Apache-2.0 + +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * Copyright (C) 2022-2023 Sefa Eyeoglu + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "Application.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "QObjectPtr.h" +#include "VersionPage.h" +#include "meta/JsonFormat.h" +#include "tasks/SequentialTask.h" +#include "ui/dialogs/InstallLoaderDialog.h" +#include "ui_VersionPage.h" + +#include "ui/dialogs/CustomMessageBox.h" +#include "ui/dialogs/NewComponentDialog.h" +#include "ui/dialogs/ProgressDialog.h" +#include "ui/dialogs/VersionSelectDialog.h" + +#include "ui/GuiUtil.h" + +#include "DesktopServices.h" +#include "Exception.h" +#include "icons/IconList.h" +#include "minecraft/PackProfile.h" +#include "minecraft/auth/AccountList.h" + +#include "meta/Index.h" +#include "meta/VersionList.h" + +class IconProxy : public QIdentityProxyModel { + Q_OBJECT + public: + IconProxy(QWidget* parentWidget) : QIdentityProxyModel(parentWidget) + { + connect(parentWidget, &QObject::destroyed, this, &IconProxy::widgetGone); + m_parentWidget = parentWidget; + } + + virtual QVariant data(const QModelIndex& proxyIndex, int role = Qt::DisplayRole) const override + { + QVariant var = QIdentityProxyModel::data(proxyIndex, role); + int column = proxyIndex.column(); + if (column == 0 && role == Qt::DecorationRole && m_parentWidget) { + if (!var.isNull()) { + auto string = var.toString(); + if (string == "warning") { + return QIcon::fromTheme("status-yellow"); + } else if (string == "error") { + return QIcon::fromTheme("status-bad"); + } + } + return QIcon::fromTheme("status-good"); + } + return var; + } + private slots: + void widgetGone() { m_parentWidget = nullptr; } + + private: + QWidget* m_parentWidget = nullptr; +}; + +QIcon VersionPage::icon() const +{ + return APPLICATION->icons()->getIcon(m_inst->iconKey()); +} +bool VersionPage::shouldDisplay() const +{ + return true; +} + +void VersionPage::retranslate() +{ + ui->retranslateUi(this); +} + +void VersionPage::openedImpl() +{ + auto const setting_name = QString("WideBarVisibility_%1").arg(id()); + m_wide_bar_setting = APPLICATION->settings()->getOrRegisterSetting(setting_name); + + ui->toolBar->setVisibilityState(QByteArray::fromBase64(m_wide_bar_setting->get().toString().toUtf8())); +} +void VersionPage::closedImpl() +{ + m_wide_bar_setting->set(QString::fromUtf8(ui->toolBar->getVisibilityState().toBase64())); +} + +QMenu* VersionPage::createPopupMenu() +{ + QMenu* filteredMenu = QMainWindow::createPopupMenu(); + filteredMenu->removeAction(ui->toolBar->toggleViewAction()); + return filteredMenu; +} + +VersionPage::VersionPage(MinecraftInstance* inst, QWidget* parent) : QMainWindow(parent), ui(new Ui::VersionPage), m_inst(inst) +{ + ui->setupUi(this); + + ui->toolBar->insertSpacer(ui->actionReload); + + m_profile = m_inst->getPackProfile(); + + reloadPackProfile(); + + auto proxy = new IconProxy(ui->packageView); + proxy->setSourceModel(m_profile); + + m_filterModel = new QSortFilterProxyModel(this); + m_filterModel->setDynamicSortFilter(true); + m_filterModel->setFilterCaseSensitivity(Qt::CaseInsensitive); + m_filterModel->setSortCaseSensitivity(Qt::CaseInsensitive); + m_filterModel->setSourceModel(proxy); + m_filterModel->setFilterKeyColumn(-1); + + ui->packageView->setModel(m_filterModel); + ui->packageView->installEventFilter(this); + ui->packageView->setSelectionMode(QAbstractItemView::SingleSelection); + ui->packageView->setContextMenuPolicy(Qt::CustomContextMenu); + + auto smodel = ui->packageView->selectionModel(); + connect(smodel, &QItemSelectionModel::currentChanged, this, &VersionPage::versionCurrent); + connect(smodel, &QItemSelectionModel::currentChanged, this, &VersionPage::packageCurrent); + connect(m_profile, &PackProfile::minecraftChanged, this, &VersionPage::updateVersionControls); + updateVersionControls(); + preselect(0); + connect(ui->packageView, &ModListView::customContextMenuRequested, this, &VersionPage::showContextMenu); + connect(ui->packageView, &QAbstractItemView::activated, this, [this](const QModelIndex& index) { + auto component = m_profile->getComponent(index.row()); + component->setEnabled(!component->isEnabled()); + }); + connect(ui->filterEdit, &QLineEdit::textChanged, this, &VersionPage::onFilterTextChanged); +} + +VersionPage::~VersionPage() +{ + delete ui; +} + +void VersionPage::showContextMenu(const QPoint& pos) +{ + auto menu = ui->toolBar->createContextMenu(this, tr("Context menu")); + menu->exec(ui->packageView->mapToGlobal(pos)); + delete menu; +} + +void VersionPage::packageCurrent(const QModelIndex& current, [[maybe_unused]] const QModelIndex& previous) +{ + if (!current.isValid()) { + ui->frame->clear(); + return; + } + int row = current.row(); + auto patch = m_profile->getComponent(row); + auto severity = patch->getProblemSeverity(); + switch (severity) { + case ProblemSeverity::Warning: + ui->frame->setName(tr("%1 possibly has issues.").arg(patch->getName())); + break; + case ProblemSeverity::Error: + ui->frame->setName(tr("%1 has issues!").arg(patch->getName())); + break; + default: + case ProblemSeverity::None: + ui->frame->clear(); + return; + } + + auto& problems = patch->getProblems(); + QString problemOut; + for (auto& problem : problems) { + if (problem.m_severity == ProblemSeverity::Error) { + problemOut += tr("Error: "); + } else if (problem.m_severity == ProblemSeverity::Warning) { + problemOut += tr("Warning: "); + } + problemOut += problem.m_description; + problemOut += "\n"; + } + ui->frame->setDescription(problemOut); +} + +void VersionPage::updateVersionControls() +{ + updateButtons(); +} + +void VersionPage::updateButtons(int row) +{ + if (row == -1) + row = currentRow(); + auto patch = m_profile->getComponent(row); + ui->actionRemove->setEnabled(patch && patch->isRemovable()); + ui->actionMove_down->setEnabled(patch && patch->isMoveable()); + ui->actionMove_up->setEnabled(patch && patch->isMoveable()); + ui->actionChange_version->setEnabled(patch && patch->isVersionChangeable(false)); + ui->actionEdit->setEnabled(patch && patch->isCustom()); + ui->actionCustomize->setEnabled(patch && patch->isCustomizable()); + ui->actionRevert->setEnabled(patch && patch->isRevertible()); +} + +bool VersionPage::reloadPackProfile() +{ + try { + auto result = m_profile->reload(Net::Mode::Online); + if (!result) { + QMessageBox::critical(this, tr("Error"), result.error); + } + return result; + } catch (const Exception& e) { + QMessageBox::critical(this, tr("Error"), e.cause()); + return false; + } catch (...) { + QMessageBox::critical(this, tr("Error"), tr("Couldn't load the instance profile.")); + return false; + } +} + +void VersionPage::on_actionReload_triggered() +{ + reloadPackProfile(); + m_container->refreshContainer(); +} + +void VersionPage::on_actionRemove_triggered() +{ + if (!ui->packageView->currentIndex().isValid()) { + return; + } + int index = ui->packageView->currentIndex().row(); + auto component = m_profile->getComponent(index); + if (component->isCustom()) { + auto response = CustomMessageBox::selectable(this, tr("Confirm Removal"), + tr("You are about to remove \"%1\".\n" + "This is permanent and will completely remove the custom component.\n\n" + "Are you sure?") + .arg(component->getName()), + QMessageBox::Warning, QMessageBox::Yes | QMessageBox::No, QMessageBox::No) + ->exec(); + + if (response != QMessageBox::Yes) + return; + } + // FIXME: use actual model, not reloading. + if (!m_profile->remove(index)) { + QMessageBox::critical(this, tr("Error"), tr("Couldn't remove file")); + } + updateButtons(); + reloadPackProfile(); + m_container->refreshContainer(); +} + +void VersionPage::on_actionAdd_to_Minecraft_jar_triggered() +{ + auto list = GuiUtil::BrowseForFiles("jarmod", tr("Select jar mods"), tr("Minecraft.jar mods") + " (*.zip *.jar)", + APPLICATION->settings()->get("CentralModsDir").toString(), this->parentWidget()); + if (!list.empty()) { + m_profile->installJarMods(list); + } + updateButtons(); +} + +void VersionPage::on_actionReplace_Minecraft_jar_triggered() +{ + auto jarPath = GuiUtil::BrowseForFile("jar", tr("Select jar"), tr("Minecraft.jar replacement") + " (*.jar)", + APPLICATION->settings()->get("CentralModsDir").toString(), this->parentWidget()); + if (!jarPath.isEmpty()) { + m_profile->installCustomJar(jarPath); + } + updateButtons(); +} + +void VersionPage::on_actionImport_Components_triggered() +{ + QStringList list = GuiUtil::BrowseForFiles("component", tr("Select components"), tr("Components") + " (*.json)", + APPLICATION->settings()->get("CentralModsDir").toString(), this->parentWidget()); + + if (!list.isEmpty()) { + if (!m_profile->installComponents(list)) { + QMessageBox::warning(this, tr("Failed to import components"), + tr("Some components could not be imported. Check logs for details")); + } + } + + updateButtons(); +} + +void VersionPage::on_actionAdd_Agents_triggered() +{ + QStringList list = GuiUtil::BrowseForFiles("agent", tr("Select agents"), tr("Java agents") + " (*.jar)", + APPLICATION->settings()->get("CentralModsDir").toString(), this->parentWidget()); + + if (!list.isEmpty()) + m_profile->installAgents(list); + + updateButtons(); +} + +void VersionPage::on_actionMove_up_triggered() +{ + try { + m_profile->move(currentRow(), PackProfile::MoveUp); + } catch (const Exception& e) { + QMessageBox::critical(this, tr("Error"), e.cause()); + } + updateButtons(); +} + +void VersionPage::on_actionMove_down_triggered() +{ + try { + m_profile->move(currentRow(), PackProfile::MoveDown); + } catch (const Exception& e) { + QMessageBox::critical(this, tr("Error"), e.cause()); + } + updateButtons(); +} + +void VersionPage::on_actionChange_version_triggered() +{ + auto versionRow = currentRow(); + if (versionRow == -1) { + return; + } + auto patch = m_profile->getComponent(versionRow); + auto name = patch->getName(); + auto list = patch->getVersionList(); + list->clearExternalRecommends(); + if (!list) { + return; + } + auto uid = list->uid(); + + // recommend the correct lwjgl version for the current minecraft version + if (uid == "org.lwjgl" || uid == "org.lwjgl3") { + auto minecraft = m_profile->getComponent("net.minecraft"); + auto lwjglReq = std::find_if(minecraft->m_cachedRequires.cbegin(), minecraft->m_cachedRequires.cend(), + [uid](const Meta::Require& req) -> bool { return req.uid == uid; }); + if (lwjglReq != minecraft->m_cachedRequires.cend()) { + auto lwjglVersion = !lwjglReq->equalsVersion.isEmpty() ? lwjglReq->equalsVersion : lwjglReq->suggests; + if (!lwjglVersion.isEmpty()) { + list->addExternalRecommends({ lwjglVersion }); + } + } + } + + VersionSelectDialog vselect(list.get(), tr("Change %1 version").arg(name), this); + if (uid == "net.fabricmc.intermediary" || uid == "org.quiltmc.hashed") { + vselect.setEmptyString(tr("No intermediary mappings versions are currently available.")); + vselect.setEmptyErrorString(tr("Couldn't load or download the intermediary mappings version lists!")); + } + vselect.setExactIfPresentFilter(BaseVersionList::ParentVersionRole, m_profile->getComponentVersion("net.minecraft")); + + auto currentVersion = patch->getVersion(); + if (!currentVersion.isEmpty()) { + vselect.setCurrentVersion(currentVersion); + } + if (!vselect.exec() || !vselect.selectedVersion()) + return; + + qDebug() << "Change" << uid << "to" << vselect.selectedVersion()->descriptor(); + bool important = false; + if (uid == "net.minecraft") { + important = true; + if (APPLICATION->settings()->get("AutomaticJavaSwitch").toBool() && m_inst->settings()->get("AutomaticJava").toBool() && + m_inst->settings()->get("OverrideJavaLocation").toBool()) { + m_inst->settings()->set("OverrideJavaLocation", false); + m_inst->settings()->set("JavaPath", ""); + } + } + m_profile->setComponentVersion(uid, vselect.selectedVersion()->descriptor(), important); + m_profile->resolve(Net::Mode::Online); + m_container->refreshContainer(); +} + +void VersionPage::on_actionDownload_All_triggered() +{ + if (!APPLICATION->accounts()->anyAccountIsValid()) { + CustomMessageBox::selectable(this, tr("Error"), + tr("Cannot download Minecraft or update instances unless you have at least " + "one account added.\nPlease add a Microsoft account."), + QMessageBox::Warning) + ->show(); + return; + } + + auto updateTasks = m_inst->createUpdateTask(); + if (updateTasks.isEmpty()) { + return; + } + auto task = makeShared(); + for (auto t : updateTasks) { + task->addTask(t); + } + ProgressDialog tDialog(this); + connect(task.get(), &Task::failed, this, &VersionPage::onGameUpdateError); + // FIXME: unused return value + tDialog.execWithTask(task.get()); + updateButtons(); + m_container->refreshContainer(); +} + +void VersionPage::on_actionInstall_Loader_triggered() +{ + InstallLoaderDialog dialog(m_inst->getPackProfile(), QString(), this); + dialog.exec(); + m_container->refreshContainer(); +} + +void VersionPage::on_actionAdd_Empty_triggered() +{ + NewComponentDialog compdialog(QString(), QString(), this); + QStringList blacklist; + for (int i = 0; i < m_profile->rowCount(); i++) { + auto comp = m_profile->getComponent(i); + blacklist.push_back(comp->getID()); + } + compdialog.setBlacklist(blacklist); + if (compdialog.exec()) { + qDebug() << "name:" << compdialog.name(); + qDebug() << "uid:" << compdialog.uid(); + m_profile->installEmpty(compdialog.uid(), compdialog.name()); + } +} + +void VersionPage::on_actionLibrariesFolder_triggered() +{ + DesktopServices::openPath(m_inst->getLocalLibraryPath(), true); +} + +void VersionPage::on_actionMinecraftFolder_triggered() +{ + DesktopServices::openPath(m_inst->gameRoot(), true); +} + +void VersionPage::versionCurrent(const QModelIndex& current, [[maybe_unused]] const QModelIndex& previous) +{ + currentIdx = current.row(); + updateButtons(currentIdx); +} + +void VersionPage::preselect(int row) +{ + if (row < 0) { + row = 0; + } + if (row >= m_profile->rowCount(QModelIndex())) { + row = m_profile->rowCount(QModelIndex()) - 1; + } + if (row < 0) { + return; + } + auto model_index = m_profile->index(row); + ui->packageView->selectionModel()->select(model_index, QItemSelectionModel::SelectCurrent | QItemSelectionModel::Rows); + updateButtons(row); +} + +void VersionPage::onGameUpdateError(QString error) +{ + CustomMessageBox::selectable(this, tr("Error updating instance"), error, QMessageBox::Warning)->show(); +} + +ComponentPtr VersionPage::current() +{ + auto row = currentRow(); + if (row < 0) { + return nullptr; + } + return m_profile->getComponent(row); +} + +int VersionPage::currentRow() +{ + if (ui->packageView->selectionModel()->selectedRows().isEmpty()) { + return -1; + } + return ui->packageView->selectionModel()->selectedRows().first().row(); +} + +void VersionPage::on_actionCustomize_triggered() +{ + auto version = currentRow(); + if (version == -1) { + return; + } + auto patch = m_profile->getComponent(version); + if (!patch->getVersionFile()) { + // TODO: wait for the update task to finish here... + return; + } + if (!m_profile->customize(version)) { + // TODO: some error box here + } + updateButtons(); + preselect(currentIdx); +} + +void VersionPage::on_actionEdit_triggered() +{ + auto version = current(); + if (!version) { + return; + } + auto filename = version->getFilename(); + if (!QFileInfo::exists(filename)) { + qWarning() << "file" << filename << "can't be opened for editing, doesn't exist!"; + return; + } + APPLICATION->openJsonEditor(filename); +} + +void VersionPage::on_actionRevert_triggered() +{ + auto version = currentRow(); + if (version == -1) { + return; + } + auto component = m_profile->getComponent(version); + + auto response = CustomMessageBox::selectable(this, tr("Confirm Reversion"), + tr("You are about to revert \"%1\".\n" + "This is permanent and will completely revert your customizations.\n\n" + "Are you sure?") + .arg(component->getName()), + QMessageBox::Warning, QMessageBox::Yes | QMessageBox::No, QMessageBox::No) + ->exec(); + + if (response != QMessageBox::Yes) + return; + + if (!m_profile->revertToBase(version)) { + // TODO: some error box here + } + updateButtons(); + preselect(currentIdx); + m_container->refreshContainer(); +} + +void VersionPage::onFilterTextChanged(const QString& newContents) +{ + m_filterModel->setFilterFixedString(newContents); +} + +#include "VersionPage.moc" diff --git a/launcher/ui/pages/instance/VersionPage.h b/launcher/ui/pages/instance/VersionPage.h new file mode 100644 index 0000000..493e3b8 --- /dev/null +++ b/launcher/ui/pages/instance/VersionPage.h @@ -0,0 +1,122 @@ +// SPDX-FileCopyrightText: 2022-2023 Sefa Eyeoglu +// +// SPDX-License-Identifier: GPL-3.0-only AND Apache-2.0 + +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * Copyright (C) 2022-2023 Sefa Eyeoglu + * Copyright (C) 2022 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +#include "minecraft/MinecraftInstance.h" +#include "minecraft/PackProfile.h" +#include "ui/pages/BasePage.h" + +namespace Ui { +class VersionPage; +} + +class VersionPage : public QMainWindow, public BasePage { + Q_OBJECT + + public: + explicit VersionPage(MinecraftInstance* inst, QWidget* parent = 0); + virtual ~VersionPage(); + virtual QString displayName() const override { return tr("Version"); } + virtual QIcon icon() const override; + virtual QString id() const override { return "version"; } + virtual QString helpPage() const override { return "Instance-Version"; } + virtual bool shouldDisplay() const override; + void retranslate() override; + + void openedImpl() override; + void closedImpl() override; + + private slots: + void on_actionChange_version_triggered(); + void on_actionInstall_Loader_triggered(); + void on_actionAdd_Empty_triggered(); + void on_actionReload_triggered(); + void on_actionRemove_triggered(); + void on_actionMove_up_triggered(); + void on_actionMove_down_triggered(); + void on_actionAdd_to_Minecraft_jar_triggered(); + void on_actionReplace_Minecraft_jar_triggered(); + void on_actionImport_Components_triggered(); + void on_actionAdd_Agents_triggered(); + void on_actionRevert_triggered(); + void on_actionEdit_triggered(); + void on_actionCustomize_triggered(); + void on_actionDownload_All_triggered(); + + void on_actionMinecraftFolder_triggered(); + void on_actionLibrariesFolder_triggered(); + + void updateVersionControls(); + + private: + ComponentPtr current(); + int currentRow(); + void updateButtons(int row = -1); + void preselect(int row = 0); + int doUpdate(); + + protected: + QMenu* createPopupMenu() override; + + /// FIXME: this shouldn't be necessary! + bool reloadPackProfile(); + + private: + Ui::VersionPage* ui; + QSortFilterProxyModel* m_filterModel; + PackProfile* m_profile; + MinecraftInstance* m_inst; + int currentIdx = 0; + + std::shared_ptr m_wide_bar_setting = nullptr; + + public slots: + void versionCurrent(const QModelIndex& current, const QModelIndex& previous); + + private slots: + void onGameUpdateError(QString error); + void packageCurrent(const QModelIndex& current, const QModelIndex& previous); + void showContextMenu(const QPoint& pos); + void onFilterTextChanged(const QString& newContents); +}; diff --git a/launcher/ui/pages/instance/VersionPage.ui b/launcher/ui/pages/instance/VersionPage.ui new file mode 100644 index 0000000..d525f56 --- /dev/null +++ b/launcher/ui/pages/instance/VersionPage.ui @@ -0,0 +1,260 @@ + + + VersionPage + + + + 0 + 0 + 961 + 1091 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + Qt::ScrollBarAlwaysOff + + + false + + + false + + + true + + + + + + + Search + + + + + + + + 0 + 0 + + + + + + + + + + + Actions + + + Qt::LeftToolBarArea|Qt::RightToolBarArea + + + Qt::ToolButtonTextOnly + + + false + + + RightToolBarArea + + + false + + + + + + + + + + + + + + + + + + + + + + + + + Change Version + + + Change version of the selected component. + + + + + Move Up + + + Make the selected component apply sooner. + + + + + Move Down + + + Make the selected component apply later. + + + + + Remove + + + Remove selected component from the instance. + + + + + Customize + + + Customize selected component. + + + + + Edit + + + Edit selected component. + + + + + Revert + + + Revert the selected component to default. + + + + + Install Loader + + + Install a mod loader. + + + + + Add to Minecraft.jar + + + Add a mod into the Minecraft jar file. + + + + + Replace Minecraft.jar + + + + + Add Agents + + + Add Java agents. + + + + + Add Empty + + + Add an empty custom component. + + + + + Reload + + + Reload all components. + + + + + Download all + + + Download the files needed to launch the instance now. + + + + + Open .minecraft + + + Open the instance's .minecraft folder. + + + + + Open libraries + + + Open the instance's local libraries folder. + + + + + Import Components + + + Import existing component JSON files. + + + + + + ModListView + QTreeView +
    ui/widgets/ModListView.h
    +
    + + InfoFrame + QFrame +
    ui/widgets/InfoFrame.h
    + 1 +
    + + WideBar + QToolBar +
    ui/widgets/WideBar.h
    +
    +
    + + +
    diff --git a/launcher/ui/pages/instance/WorldListPage.cpp b/launcher/ui/pages/instance/WorldListPage.cpp new file mode 100644 index 0000000..e56e9c7 --- /dev/null +++ b/launcher/ui/pages/instance/WorldListPage.cpp @@ -0,0 +1,477 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2022 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "WorldListPage.h" +#include "minecraft/WorldList.h" +#include "ui/dialogs/CustomMessageBox.h" +#include "ui_WorldListPage.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "FileSystem.h" +#include "tools/MCEditTool.h" + +#include "DesktopServices.h" +#include "ui/GuiUtil.h" + +#include "Application.h" +#include "DataPackPage.h" + +class WorldListProxyModel : public QSortFilterProxyModel { + Q_OBJECT + + public: + WorldListProxyModel(QObject* parent) : QSortFilterProxyModel(parent) {} + + virtual QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const + { + QModelIndex sourceIndex = mapToSource(index); + + if (index.column() == 0 && role == Qt::DecorationRole) { + WorldList* worlds = qobject_cast(sourceModel()); + auto iconFile = worlds->data(sourceIndex, WorldList::IconFileRole).toString(); + if (iconFile.isNull()) { + // NOTE: Minecraft uses the same placeholder for servers AND worlds + return QIcon::fromTheme("unknown_server"); + } + return QIcon(iconFile); + } + + return sourceIndex.data(role); + } +}; + +WorldListPage::WorldListPage(MinecraftInstance* inst, WorldList* worlds, QWidget* parent) + : QMainWindow(parent), m_inst(inst), ui(new Ui::WorldListPage), m_worlds(worlds) +{ + ui->setupUi(this); + + ui->toolBar->insertSpacer(ui->actionRefresh); + + WorldListProxyModel* proxy = new WorldListProxyModel(this); + proxy->setSortCaseSensitivity(Qt::CaseInsensitive); + proxy->setSourceModel(m_worlds); + proxy->setSortRole(Qt::UserRole); + ui->worldTreeView->setSortingEnabled(true); + ui->worldTreeView->setModel(proxy); + ui->worldTreeView->installEventFilter(this); + ui->worldTreeView->setContextMenuPolicy(Qt::CustomContextMenu); + ui->worldTreeView->setIconSize(QSize(64, 64)); + connect(ui->worldTreeView, &QTreeView::customContextMenuRequested, this, &WorldListPage::ShowContextMenu); + + auto head = ui->worldTreeView->header(); + head->setSectionResizeMode(0, QHeaderView::Stretch); + head->setSectionResizeMode(1, QHeaderView::ResizeToContents); + head->setSectionResizeMode(4, QHeaderView::ResizeToContents); + + connect(ui->worldTreeView->selectionModel(), &QItemSelectionModel::currentChanged, this, &WorldListPage::worldChanged); + worldChanged(QModelIndex(), QModelIndex()); +} + +void WorldListPage::openedImpl() +{ + m_worlds->startWatching(); + + if (!m_inst || !m_inst->traits().contains("feature:is_quick_play_singleplayer")) { + ui->toolBar->removeAction(ui->actionJoin); + } + + auto const setting_name = QString("WideBarVisibility_%1").arg(id()); + m_wide_bar_setting = APPLICATION->settings()->getOrRegisterSetting(setting_name); + + ui->toolBar->setVisibilityState(QByteArray::fromBase64(m_wide_bar_setting->get().toString().toUtf8())); +} + +void WorldListPage::closedImpl() +{ + m_worlds->stopWatching(); + + m_wide_bar_setting->set(QString::fromUtf8(ui->toolBar->getVisibilityState().toBase64())); +} + +WorldListPage::~WorldListPage() +{ + m_worlds->stopWatching(); + delete ui; +} + +void WorldListPage::ShowContextMenu(const QPoint& pos) +{ + auto menu = ui->toolBar->createContextMenu(this, tr("Context menu")); + menu->exec(ui->worldTreeView->mapToGlobal(pos)); + delete menu; +} + +QMenu* WorldListPage::createPopupMenu() +{ + QMenu* filteredMenu = QMainWindow::createPopupMenu(); + filteredMenu->removeAction(ui->toolBar->toggleViewAction()); + return filteredMenu; +} + +bool WorldListPage::shouldDisplay() const +{ + return true; +} + +void WorldListPage::retranslate() +{ + ui->retranslateUi(this); +} + +bool WorldListPage::worldListFilter(QKeyEvent* keyEvent) +{ + if (keyEvent->key() == Qt::Key_Delete) { + on_actionRemove_triggered(); + return true; + } + return QWidget::eventFilter(ui->worldTreeView, keyEvent); +} + +bool WorldListPage::eventFilter(QObject* obj, QEvent* ev) +{ + if (ev->type() != QEvent::KeyPress) { + return QWidget::eventFilter(obj, ev); + } + QKeyEvent* keyEvent = static_cast(ev); + if (obj == ui->worldTreeView) + return worldListFilter(keyEvent); + return QWidget::eventFilter(obj, ev); +} + +void WorldListPage::on_actionRemove_triggered() +{ + auto proxiedIndex = getSelectedWorld(); + + if (!proxiedIndex.isValid()) + return; + + auto result = CustomMessageBox::selectable(this, tr("Confirm Deletion"), + tr("You are about to delete \"%1\".\n" + "The world may be gone forever (A LONG TIME).\n\n" + "Are you sure?") + .arg(m_worlds->allWorlds().at(proxiedIndex.row()).name()), + QMessageBox::Warning, QMessageBox::Yes | QMessageBox::No, QMessageBox::No) + ->exec(); + + if (result != QMessageBox::Yes) { + return; + } + m_worlds->stopWatching(); + m_worlds->deleteWorld(proxiedIndex.row()); + m_worlds->startWatching(); +} + +void WorldListPage::on_actionView_Folder_triggered() +{ + DesktopServices::openPath(m_worlds->dir().absolutePath(), true); +} + +void WorldListPage::on_actionData_Packs_triggered() +{ + QModelIndex index = getSelectedWorld(); + + if (!index.isValid()) { + return; + } + + if (!worldSafetyNagQuestion(tr("Manage Data Packs"))) + return; + + const QString fullPath = m_worlds->data(index, WorldList::FolderRole).toString(); + const QString folder = FS::PathCombine(fullPath, "datapacks"); + + auto dialog = new QDialog(this); + dialog->setWindowTitle(tr("Data packs for %1").arg(m_worlds->data(index, WorldList::NameRole).toString())); + dialog->setWindowModality(Qt::WindowModal); + + dialog->resize(static_cast(std::max(0.5 * window()->width(), 400.0)), + static_cast(std::max(0.75 * window()->height(), 400.0))); + dialog->restoreGeometry(QByteArray::fromBase64(APPLICATION->settings()->get("DataPackDownloadGeometry").toByteArray())); + + GenericPageProvider provider(dialog->windowTitle()); + + bool isIndexed = !APPLICATION->settings()->get("ModMetadataDisabled").toBool(); + m_datapackModel.reset(new DataPackFolderModel(folder, m_inst, isIndexed, true)); + + provider.addPageCreator([this] { return new DataPackPage(m_inst, m_datapackModel.get(), this); }); + + auto layout = new QVBoxLayout(dialog); + + auto focusStealer = new QPushButton(dialog); + layout->addWidget(focusStealer); + focusStealer->setDefault(true); + focusStealer->hide(); + + auto pageContainer = new PageContainer(&provider, {}, dialog); + pageContainer->hidePageList(); + layout->addWidget(pageContainer); + + auto buttonBox = new QDialogButtonBox(QDialogButtonBox::Close | QDialogButtonBox::Help); + connect(buttonBox, &QDialogButtonBox::rejected, dialog, &QDialog::reject); + connect(buttonBox, &QDialogButtonBox::helpRequested, pageContainer, &PageContainer::help); + layout->addWidget(buttonBox); + + dialog->setLayout(layout); + + dialog->exec(); + + APPLICATION->settings()->set("DataPackDownloadGeometry", dialog->saveGeometry().toBase64()); +} + +void WorldListPage::on_actionReset_Icon_triggered() +{ + auto proxiedIndex = getSelectedWorld(); + + if (!proxiedIndex.isValid()) + return; + + if (m_worlds->resetIcon(proxiedIndex.row())) { + ui->actionReset_Icon->setEnabled(false); + } +} + +QModelIndex WorldListPage::getSelectedWorld() +{ + auto index = ui->worldTreeView->selectionModel()->currentIndex(); + + auto proxy = (QSortFilterProxyModel*)ui->worldTreeView->model(); + return proxy->mapToSource(index); +} + +void WorldListPage::on_actionCopy_Seed_triggered() +{ + QModelIndex index = getSelectedWorld(); + + if (!index.isValid()) { + return; + } + int64_t seed = m_worlds->data(index, WorldList::SeedRole).toLongLong(); + APPLICATION->clipboard()->setText(QString::number(seed)); +} + +void WorldListPage::on_actionMCEdit_triggered() +{ + if (m_mceditStarting) + return; + + auto mcedit = APPLICATION->mcedit(); + + const QString mceditPath = mcedit->path(); + + QModelIndex index = getSelectedWorld(); + + if (!index.isValid()) { + return; + } + + if (!worldSafetyNagQuestion(tr("Open World in MCEdit"))) + return; + + auto fullPath = m_worlds->data(index, WorldList::FolderRole).toString(); + + auto program = mcedit->getProgramPath(); + if (program.size()) { +#ifdef Q_OS_WIN32 + if (!QProcess::startDetached(program, { fullPath }, mceditPath)) { + mceditError(); + } +#else + m_mceditProcess.reset(new LoggedProcess()); + m_mceditProcess->setDetachable(true); + connect(m_mceditProcess.get(), &LoggedProcess::stateChanged, this, &WorldListPage::mceditState); + m_mceditProcess->start(program, { fullPath }); + m_mceditProcess->setWorkingDirectory(mceditPath); + m_mceditStarting = true; +#endif + } else { + QMessageBox::warning(this->parentWidget(), tr("No MCEdit found or set up!"), + tr("You do not have MCEdit set up or it was moved.\nYou can set it up in the global settings.")); + } +} + +void WorldListPage::mceditError() +{ + QMessageBox::warning(this->parentWidget(), tr("MCEdit failed to start!"), + tr("MCEdit failed to start.\nIt may be necessary to reinstall it.")); +} + +void WorldListPage::mceditState(LoggedProcess::State state) +{ + bool failed = false; + switch (state) { + case LoggedProcess::NotRunning: + case LoggedProcess::Starting: + return; + case LoggedProcess::FailedToStart: + case LoggedProcess::Crashed: + case LoggedProcess::Aborted: { + failed = true; + } + /* fallthrough */ + case LoggedProcess::Running: + case LoggedProcess::Finished: { + m_mceditStarting = false; + break; + } + } + if (failed) { + mceditError(); + } +} + +void WorldListPage::worldChanged([[maybe_unused]] const QModelIndex& current, [[maybe_unused]] const QModelIndex& previous) +{ + QModelIndex index = getSelectedWorld(); + bool enable = index.isValid(); + ui->actionCopy_Seed->setEnabled(enable); + ui->actionMCEdit->setEnabled(enable); + ui->actionRemove->setEnabled(enable); + ui->actionCopy->setEnabled(enable); + ui->actionRename->setEnabled(enable); + ui->actionData_Packs->setEnabled(enable); + bool hasIcon = !index.data(WorldList::IconFileRole).isNull(); + ui->actionReset_Icon->setEnabled(enable && hasIcon); + + auto supportsJoin = m_inst && m_inst->traits().contains("feature:is_quick_play_singleplayer"); + ui->actionJoin->setEnabled(enable && supportsJoin); + + if (!supportsJoin) { + ui->toolBar->removeAction(ui->actionJoin); + } +} + +void WorldListPage::on_actionAdd_triggered() +{ + auto list = GuiUtil::BrowseForFiles(displayName(), tr("Select a Minecraft world zip"), tr("Minecraft World Zip File") + " (*.zip)", + QString(), this->parentWidget()); + if (!list.empty()) { + m_worlds->stopWatching(); + for (auto filename : list) { + m_worlds->installWorld(QFileInfo(filename)); + } + m_worlds->startWatching(); + } +} + +bool WorldListPage::isWorldSafe(QModelIndex) +{ + return !m_inst->isRunning(); +} + +bool WorldListPage::worldSafetyNagQuestion(const QString& actionType) +{ + if (!isWorldSafe(getSelectedWorld())) { + auto result = QMessageBox::question( + this, actionType, tr("Changing a world while Minecraft is running is potentially unsafe.\nDo you wish to proceed?")); + if (result == QMessageBox::No) { + return false; + } + } + return true; +} + +void WorldListPage::on_actionCopy_triggered() +{ + QModelIndex index = getSelectedWorld(); + if (!index.isValid()) { + return; + } + + if (!worldSafetyNagQuestion(tr("Copy World"))) + return; + + auto worldVariant = m_worlds->data(index, WorldList::ObjectRole); + auto world = (World*)worldVariant.value(); + bool ok = false; + QString name = + QInputDialog::getText(this, tr("World name"), tr("Enter a new name for the copy."), QLineEdit::Normal, world->name(), &ok); + + if (ok && name.length() > 0) { + world->install(m_worlds->dir().absolutePath(), name); + } +} + +void WorldListPage::on_actionRename_triggered() +{ + QModelIndex index = getSelectedWorld(); + if (!index.isValid()) { + return; + } + + if (!worldSafetyNagQuestion(tr("Rename World"))) + return; + + auto worldVariant = m_worlds->data(index, WorldList::ObjectRole); + auto world = (World*)worldVariant.value(); + + bool ok = false; + QString name = QInputDialog::getText(this, tr("World name"), tr("Enter a new world name."), QLineEdit::Normal, world->name(), &ok); + + if (ok && name.length() > 0) { + world->rename(name); + } +} + +void WorldListPage::on_actionRefresh_triggered() +{ + m_worlds->update(); +} + +void WorldListPage::on_actionJoin_triggered() +{ + QModelIndex index = getSelectedWorld(); + if (!index.isValid()) { + return; + } + auto worldVariant = m_worlds->data(index, WorldList::ObjectRole); + auto world = (World*)worldVariant.value(); + APPLICATION->launch(m_inst, LaunchMode::Normal, std::make_shared(MinecraftTarget::parse(world->folderName(), true))); +} + +#include "WorldListPage.moc" diff --git a/launcher/ui/pages/instance/WorldListPage.h b/launcher/ui/pages/instance/WorldListPage.h new file mode 100644 index 0000000..0afb988 --- /dev/null +++ b/launcher/ui/pages/instance/WorldListPage.h @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +#include +#include "minecraft/MinecraftInstance.h" +#include "ui/pages/BasePage.h" + +#include "settings/Setting.h" + +class WorldList; +namespace Ui { +class WorldListPage; +} + +class WorldListPage : public QMainWindow, public BasePage { + Q_OBJECT + + public: + explicit WorldListPage(MinecraftInstance* inst, WorldList* worlds, QWidget* parent = 0); + virtual ~WorldListPage(); + + virtual QString displayName() const override { return tr("Worlds"); } + virtual QIcon icon() const override { return QIcon::fromTheme("worlds"); } + virtual QString id() const override { return "worlds"; } + virtual QString helpPage() const override { return "Worlds"; } + virtual bool shouldDisplay() const override; + void retranslate() override; + + virtual void openedImpl() override; + virtual void closedImpl() override; + + protected: + bool eventFilter(QObject* obj, QEvent* ev) override; + bool worldListFilter(QKeyEvent* ev); + QMenu* createPopupMenu() override; + + protected: + MinecraftInstance* m_inst; + + private: + QModelIndex getSelectedWorld(); + bool isWorldSafe(QModelIndex index); + bool worldSafetyNagQuestion(const QString& actionType); + void mceditError(); + + private: + Ui::WorldListPage* ui; + WorldList* m_worlds; + unique_qobject_ptr m_mceditProcess; + bool m_mceditStarting = false; + + std::shared_ptr m_wide_bar_setting = nullptr; + std::unique_ptr m_datapackModel; + + private slots: + void on_actionCopy_Seed_triggered(); + void on_actionMCEdit_triggered(); + void on_actionRemove_triggered(); + void on_actionAdd_triggered(); + void on_actionCopy_triggered(); + void on_actionRename_triggered(); + void on_actionRefresh_triggered(); + void on_actionView_Folder_triggered(); + void on_actionData_Packs_triggered(); + void on_actionReset_Icon_triggered(); + void worldChanged(const QModelIndex& current, const QModelIndex& previous); + void mceditState(LoggedProcess::State state); + void on_actionJoin_triggered(); + + void ShowContextMenu(const QPoint& pos); +}; diff --git a/launcher/ui/pages/instance/WorldListPage.ui b/launcher/ui/pages/instance/WorldListPage.ui new file mode 100644 index 0000000..2c19b97 --- /dev/null +++ b/launcher/ui/pages/instance/WorldListPage.ui @@ -0,0 +1,170 @@ + + + WorldListPage + + + + 0 + 0 + 800 + 600 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + true + + + QAbstractItemView::DragDrop + + + true + + + false + + + false + + + true + + + true + + + QAbstractItemView::ScrollPerPixel + + + false + + + + + + + + Actions + + + Qt::LeftToolBarArea|Qt::RightToolBarArea + + + Qt::ToolButtonTextOnly + + + false + + + RightToolBarArea + + + false + + + + + + + + + + + + + + + + + + Add + + + + + Join + + + + + Rename + + + + + Copy + + + + + Delete + + + + + MCEdit + + + + + Copy Seed + + + + + Refresh + + + + + View Folder + + + + + Reset Icon + + + Remove world icon to make the game re-generate it on next load. + + + + + Data Packs + + + Manage data packs inside the world. + + + + + + WideBar + QToolBar +
    ui/widgets/WideBar.h
    +
    +
    + + +
    diff --git a/launcher/ui/pages/modplatform/CustomPage.cpp b/launcher/ui/pages/modplatform/CustomPage.cpp new file mode 100644 index 0000000..87e126f --- /dev/null +++ b/launcher/ui/pages/modplatform/CustomPage.cpp @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "CustomPage.h" +#include "ui_CustomPage.h" + +#include + +#include "Application.h" +#include "Filter.h" +#include "Version.h" +#include "meta/Index.h" +#include "meta/VersionList.h" +#include "minecraft/VanillaInstanceCreationTask.h" +#include "ui/dialogs/NewInstanceDialog.h" + +CustomPage::CustomPage(NewInstanceDialog* dialog, QWidget* parent) : QWidget(parent), dialog(dialog), ui(new Ui::CustomPage) +{ + ui->setupUi(this); + connect(ui->versionList, &VersionSelectWidget::selectedVersionChanged, this, &CustomPage::setSelectedVersion); + filterChanged(); + connect(ui->alphaFilter, &QCheckBox::stateChanged, this, &CustomPage::filterChanged); + connect(ui->betaFilter, &QCheckBox::stateChanged, this, &CustomPage::filterChanged); + connect(ui->snapshotFilter, &QCheckBox::stateChanged, this, &CustomPage::filterChanged); + connect(ui->releaseFilter, &QCheckBox::stateChanged, this, &CustomPage::filterChanged); + connect(ui->experimentsFilter, &QCheckBox::stateChanged, this, &CustomPage::filterChanged); + connect(ui->refreshBtn, &QPushButton::clicked, this, &CustomPage::refresh); + + connect(ui->loaderVersionList, &VersionSelectWidget::selectedVersionChanged, this, &CustomPage::setSelectedLoaderVersion); + connect(ui->noneFilter, &QRadioButton::toggled, this, &CustomPage::loaderFilterChanged); + connect(ui->forgeFilter, &QRadioButton::toggled, this, &CustomPage::loaderFilterChanged); + connect(ui->fabricFilter, &QRadioButton::toggled, this, &CustomPage::loaderFilterChanged); + connect(ui->quiltFilter, &QRadioButton::toggled, this, &CustomPage::loaderFilterChanged); + connect(ui->liteLoaderFilter, &QRadioButton::toggled, this, &CustomPage::loaderFilterChanged); + connect(ui->loaderRefreshBtn, &QPushButton::clicked, this, &CustomPage::loaderRefresh); +} + +void CustomPage::openedImpl() +{ + if (!initialized) { + auto vlist = APPLICATION->metadataIndex()->get("net.minecraft"); + ui->versionList->initialize(vlist.get()); + initialized = true; + } else { + suggestCurrent(); + } +} + +void CustomPage::refresh() +{ + ui->versionList->loadList(); +} + +void CustomPage::loaderRefresh() +{ + if (ui->noneFilter->isChecked()) + return; + ui->loaderVersionList->loadList(); +} + +void CustomPage::filterChanged() +{ + QStringList out; + if (ui->alphaFilter->isChecked()) + out << "(alpha)"; + if (ui->betaFilter->isChecked()) + out << "(beta)"; + if (ui->snapshotFilter->isChecked()) + out << "(snapshot)"; + if (ui->releaseFilter->isChecked()) + out << "(release)"; + if (ui->experimentsFilter->isChecked()) + out << "(experiment)"; + auto regexp = out.join('|'); + ui->versionList->setFilter(BaseVersionList::TypeRole, Filters::regexp(QRegularExpression(regexp))); +} + +void CustomPage::loaderFilterChanged() +{ + QString minecraftVersion; + if (m_selectedVersion) { + minecraftVersion = m_selectedVersion->descriptor(); + } else { + ui->loaderVersionList->setExactFilter(BaseVersionList::ParentVersionRole, "AAA"); // empty list + ui->loaderVersionList->setEmptyString(tr("No Minecraft version is selected.")); + ui->loaderVersionList->setEmptyMode(VersionListView::String); + return; + } + if (ui->noneFilter->isChecked()) { + ui->loaderVersionList->setExactFilter(BaseVersionList::ParentVersionRole, "AAA"); // empty list + ui->loaderVersionList->setEmptyString(tr("No mod loader is selected.")); + ui->loaderVersionList->setEmptyMode(VersionListView::String); + return; + } else if (ui->neoForgeFilter->isChecked()) { + ui->loaderVersionList->setExactFilter(BaseVersionList::ParentVersionRole, minecraftVersion); + m_selectedLoader = "net.neoforged"; + } else if (ui->forgeFilter->isChecked()) { + ui->loaderVersionList->setExactFilter(BaseVersionList::ParentVersionRole, minecraftVersion); + m_selectedLoader = "net.minecraftforge"; + } else if (ui->fabricFilter->isChecked()) { + // FIXME: dirty hack because the launcher is unaware of Fabric's dependencies + if (Version(minecraftVersion) >= Version("1.14")) // Fabric/Quilt supported + ui->loaderVersionList->setExactFilter(BaseVersionList::ParentVersionRole, ""); + else // Fabric/Quilt unsupported + ui->loaderVersionList->setExactFilter(BaseVersionList::ParentVersionRole, "AAA"); // clear list + m_selectedLoader = "net.fabricmc.fabric-loader"; + } else if (ui->quiltFilter->isChecked()) { + // FIXME: dirty hack because the launcher is unaware of Quilt's dependencies (same as Fabric) + if (Version(minecraftVersion) >= Version("1.14")) // Fabric/Quilt supported + ui->loaderVersionList->setExactFilter(BaseVersionList::ParentVersionRole, ""); + else // Fabric/Quilt unsupported + ui->loaderVersionList->setExactFilter(BaseVersionList::ParentVersionRole, "AAA"); // clear list + m_selectedLoader = "org.quiltmc.quilt-loader"; + } else if (ui->liteLoaderFilter->isChecked()) { + ui->loaderVersionList->setExactFilter(BaseVersionList::ParentVersionRole, minecraftVersion); + m_selectedLoader = "com.mumfrey.liteloader"; + } + + auto vlist = APPLICATION->metadataIndex()->get(m_selectedLoader); + ui->loaderVersionList->initialize(vlist.get()); + ui->loaderVersionList->selectRecommended(); + ui->loaderVersionList->setEmptyString(tr("No versions are currently available for Minecraft %1").arg(minecraftVersion)); +} + +CustomPage::~CustomPage() +{ + delete ui; +} + +bool CustomPage::shouldDisplay() const +{ + return true; +} + +void CustomPage::retranslate() +{ + ui->retranslateUi(this); +} + +BaseVersion::Ptr CustomPage::selectedVersion() const +{ + return m_selectedVersion; +} + +BaseVersion::Ptr CustomPage::selectedLoaderVersion() const +{ + return m_selectedLoaderVersion; +} + +QString CustomPage::selectedLoader() const +{ + return m_selectedLoader; +} + +void CustomPage::suggestCurrent() +{ + if (!isOpened) { + return; + } + + if (!m_selectedVersion) { + dialog->setSuggestedPack(); + return; + } + + // There isn't a selected version if the version list is empty + if (ui->loaderVersionList->selectedVersion() == nullptr) + dialog->setSuggestedPack(m_selectedVersion->descriptor(), new VanillaCreationTask(m_selectedVersion)); + else { + dialog->setSuggestedPack(m_selectedVersion->descriptor(), + new VanillaCreationTask(m_selectedVersion, m_selectedLoader, m_selectedLoaderVersion)); + } + dialog->setSuggestedIcon("default"); +} + +void CustomPage::setSelectedVersion(BaseVersion::Ptr version) +{ + m_selectedVersion = version; + suggestCurrent(); + loaderFilterChanged(); +} + +void CustomPage::setSelectedLoaderVersion(BaseVersion::Ptr version) +{ + m_selectedLoaderVersion = version; + suggestCurrent(); +} diff --git a/launcher/ui/pages/modplatform/CustomPage.h b/launcher/ui/pages/modplatform/CustomPage.h new file mode 100644 index 0000000..2bfb1de --- /dev/null +++ b/launcher/ui/pages/modplatform/CustomPage.h @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +#include "BaseVersion.h" +#include "tasks/Task.h" +#include "ui/pages/BasePage.h" + +namespace Ui { +class CustomPage; +} + +class NewInstanceDialog; + +class CustomPage : public QWidget, public BasePage { + Q_OBJECT + + public: + explicit CustomPage(NewInstanceDialog* dialog, QWidget* parent = 0); + virtual ~CustomPage(); + virtual QString displayName() const override { return tr("Custom"); } + virtual QIcon icon() const override { return QIcon::fromTheme("minecraft"); } + virtual QString id() const override { return "vanilla"; } + virtual QString helpPage() const override { return "Vanilla-platform"; } + virtual bool shouldDisplay() const override; + void retranslate() override; + + void openedImpl() override; + + BaseVersion::Ptr selectedVersion() const; + BaseVersion::Ptr selectedLoaderVersion() const; + QString selectedLoader() const; + + public slots: + void setSelectedVersion(BaseVersion::Ptr version); + void setSelectedLoaderVersion(BaseVersion::Ptr version); + + private slots: + void filterChanged(); + void loaderFilterChanged(); + + private: + void refresh(); + void loaderRefresh(); + void suggestCurrent(); + + private: + bool initialized = false; + NewInstanceDialog* dialog = nullptr; + Ui::CustomPage* ui = nullptr; + bool m_versionSetByUser = false; + BaseVersion::Ptr m_selectedVersion; + BaseVersion::Ptr m_selectedLoaderVersion; + QString m_selectedLoader; +}; diff --git a/launcher/ui/pages/modplatform/CustomPage.ui b/launcher/ui/pages/modplatform/CustomPage.ui new file mode 100644 index 0000000..39d9aa6 --- /dev/null +++ b/launcher/ui/pages/modplatform/CustomPage.ui @@ -0,0 +1,293 @@ + + + CustomPage + + + + 0 + 0 + 815 + 607 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + true + + + + + 0 + 0 + 813 + 605 + + + + + + + + + + 0 + 0 + + + + + + + + + + Filter + + + Qt::AlignCenter + + + + + + + Releases + + + true + + + true + + + + + + + Snapshots + + + true + + + + + + + Betas + + + true + + + + + + + Alphas + + + true + + + + + + + Experiments + + + true + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + Refresh + + + + + + + + + + + + 0 + 0 + + + + Qt::Horizontal + + + + + + + + + + 0 + 0 + + + + + + + + + + Mod Loader + + + Qt::AlignCenter + + + + + + + None + + + true + + + loaderBtnGroup + + + + + + + NeoForge + + + loaderBtnGroup + + + + + + + Forge + + + loaderBtnGroup + + + + + + + Fabric + + + loaderBtnGroup + + + + + + + Quilt + + + loaderBtnGroup + + + + + + + LiteLoader + + + loaderBtnGroup + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + Refresh + + + + + + + + + + + + + + + + VersionSelectWidget + QWidget +
    ui/widgets/VersionSelectWidget.h
    + 1 +
    +
    + + releaseFilter + snapshotFilter + betaFilter + alphaFilter + experimentsFilter + refreshBtn + + + + + + +
    diff --git a/launcher/ui/pages/modplatform/DataPackModel.cpp b/launcher/ui/pages/modplatform/DataPackModel.cpp new file mode 100644 index 0000000..fb535ae --- /dev/null +++ b/launcher/ui/pages/modplatform/DataPackModel.cpp @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: 2023 flowln +// SPDX-FileCopyrightText: 2023 TheKodeToad +// +// SPDX-License-Identifier: GPL-3.0-only + +#include "DataPackModel.h" + +#include + +namespace ResourceDownload { + +DataPackResourceModel::DataPackResourceModel(BaseInstance const& base_inst, ResourceAPI* api, QString debugName, QString metaEntryBase) + : ResourceModel(api), m_base_instance(base_inst), m_debugName(debugName + " (Model)"), m_metaEntryBase(metaEntryBase) +{} + +/******** Make data requests ********/ + +ResourceAPI::SearchArgs DataPackResourceModel::createSearchArguments() +{ + auto sort = getCurrentSortingMethodByIndex(); + return { ModPlatform::ResourceType::DataPack, m_next_search_offset, m_search_term, sort, ModPlatform::ModLoaderType::DataPack }; +} + +ResourceAPI::VersionSearchArgs DataPackResourceModel::createVersionsArguments(const QModelIndex& entry) +{ + auto pack = m_packs[entry.row()]; + return { pack, {}, ModPlatform::ModLoaderType::DataPack }; +} + +ResourceAPI::ProjectInfoArgs DataPackResourceModel::createInfoArguments(const QModelIndex& entry) +{ + auto pack = m_packs[entry.row()]; + return { pack }; +} + +void DataPackResourceModel::searchWithTerm(const QString& term, unsigned int sort) +{ + if (m_search_term == term && m_search_term.isNull() == term.isNull() && m_current_sort_index == sort) { + return; + } + + setSearchTerm(term); + m_current_sort_index = sort; + + refresh(); +} + +} // namespace ResourceDownload diff --git a/launcher/ui/pages/modplatform/DataPackModel.h b/launcher/ui/pages/modplatform/DataPackModel.h new file mode 100644 index 0000000..29b11ff --- /dev/null +++ b/launcher/ui/pages/modplatform/DataPackModel.h @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: 2023 flowln +// SPDX-FileCopyrightText: 2023 TheKodeToad +// +// SPDX-License-Identifier: GPL-3.0-only + +#pragma once + +#include + +#include "BaseInstance.h" + +#include "modplatform/ModIndex.h" + +#include "ui/pages/modplatform/ResourceModel.h" + +class Version; + +namespace ResourceDownload { + +class DataPackResourceModel : public ResourceModel { + Q_OBJECT + + public: + DataPackResourceModel(BaseInstance const&, ResourceAPI*, QString, QString); + + /* Ask the API for more information */ + void searchWithTerm(const QString& term, unsigned int sort); + + [[nodiscard]] QString debugName() const override { return m_debugName; } + [[nodiscard]] QString metaEntryBase() const override { return m_metaEntryBase; } + + public slots: + ResourceAPI::SearchArgs createSearchArguments() override; + ResourceAPI::VersionSearchArgs createVersionsArguments(const QModelIndex&) override; + ResourceAPI::ProjectInfoArgs createInfoArguments(const QModelIndex&) override; + + protected: + const BaseInstance& m_base_instance; + + private: + QString m_debugName; + QString m_metaEntryBase; +}; + +} // namespace ResourceDownload diff --git a/launcher/ui/pages/modplatform/DataPackPage.cpp b/launcher/ui/pages/modplatform/DataPackPage.cpp new file mode 100644 index 0000000..82892b3 --- /dev/null +++ b/launcher/ui/pages/modplatform/DataPackPage.cpp @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2023 flowln +// SPDX-FileCopyrightText: 2023 TheKodeToad +// +// SPDX-License-Identifier: GPL-3.0-only + +#include "DataPackPage.h" +#include "ui_ResourcePage.h" + +#include "DataPackModel.h" + +#include "ui/dialogs/ResourceDownloadDialog.h" + +#include + +namespace ResourceDownload { + +DataPackResourcePage::DataPackResourcePage(DataPackDownloadDialog* dialog, BaseInstance& instance) : ResourcePage(dialog, instance) {} + +/******** Callbacks to events in the UI (set up in the derived classes) ********/ + +void DataPackResourcePage::triggerSearch() +{ + m_ui->packView->clearSelection(); + m_ui->packDescription->clear(); + m_ui->versionSelectionBox->clear(); + + updateSelectionButton(); + + static_cast(m_model)->searchWithTerm(getSearchTerm(), m_ui->sortByBox->currentData().toUInt()); + m_fetchProgress.watch(m_model->activeSearchJob().get()); +} + +QMap DataPackResourcePage::urlHandlers() const +{ + QMap map; + map.insert(QRegularExpression::anchoredPattern("(?:www\\.)?modrinth\\.com\\/resourcepack\\/([^\\/]+)\\/?"), "modrinth"); + map.insert(QRegularExpression::anchoredPattern("(?:www\\.)?curseforge\\.com\\/minecraft\\/texture-packs\\/([^\\/]+)\\/?"), + "curseforge"); + map.insert(QRegularExpression::anchoredPattern("minecraft\\.curseforge\\.com\\/projects\\/([^\\/]+)\\/?"), "curseforge"); + return map; +} + +} // namespace ResourceDownload diff --git a/launcher/ui/pages/modplatform/DataPackPage.h b/launcher/ui/pages/modplatform/DataPackPage.h new file mode 100644 index 0000000..431fc9a --- /dev/null +++ b/launcher/ui/pages/modplatform/DataPackPage.h @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: 2023 flowln +// SPDX-FileCopyrightText: 2023 TheKodeToad +// +// SPDX-License-Identifier: GPL-3.0-only + +#pragma once + +#include "ui/pages/modplatform/DataPackModel.h" +#include "ui/pages/modplatform/ResourcePage.h" + +namespace Ui { +class ResourcePage; +} + +namespace ResourceDownload { + +class DataPackDownloadDialog; + +class DataPackResourcePage : public ResourcePage { + Q_OBJECT + + public: + template + static T* create(DataPackDownloadDialog* dialog, BaseInstance& instance) + { + auto page = new T(dialog, instance); + auto model = static_cast(page->getModel()); + + connect(model, &ResourceModel::versionListUpdated, page, &ResourcePage::versionListUpdated); + connect(model, &ResourceModel::projectInfoUpdated, page, &ResourcePage::updateUi); + connect(model, &QAbstractListModel::modelReset, page, &ResourcePage::modelReset); + + return page; + } + + //: The plural version of 'data pack' + inline QString resourcesString() const override { return tr("data packs"); } + //: The singular version of 'data packs' + inline QString resourceString() const override { return tr("data pack"); } + + bool supportsFiltering() const override { return false; }; + + QMap urlHandlers() const override; + + protected: + DataPackResourcePage(DataPackDownloadDialog* dialog, BaseInstance& instance); + + protected slots: + void triggerSearch() override; +}; + +} // namespace ResourceDownload diff --git a/launcher/ui/pages/modplatform/ImportPage.cpp b/launcher/ui/pages/modplatform/ImportPage.cpp new file mode 100644 index 0000000..6e78301 --- /dev/null +++ b/launcher/ui/pages/modplatform/ImportPage.cpp @@ -0,0 +1,231 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ImportPage.h" + +#include "ui/dialogs/ProgressDialog.h" +#include "ui_ImportPage.h" + +#include +#include +#include +#include + +#include "ui/dialogs/CustomMessageBox.h" +#include "ui/dialogs/NewInstanceDialog.h" + +#include "modplatform/flame/FlameAPI.h" + +#include "Json.h" + +#include "InstanceImportTask.h" +#include "net/NetJob.h" + +class UrlValidator : public QValidator { + public: + using QValidator::QValidator; + + State validate(QString& in, [[maybe_unused]] int& pos) const + { + const QUrl url(in); + if (url.isValid() && !url.isRelative() && !url.isEmpty()) { + return Acceptable; + } else if (QFile::exists(in)) { + return Acceptable; + } else { + return Intermediate; + } + } +}; + +ImportPage::ImportPage(NewInstanceDialog* dialog, QWidget* parent) : QWidget(parent), ui(new Ui::ImportPage), dialog(dialog) +{ + ui->setupUi(this); + ui->modpackEdit->setValidator(new UrlValidator(ui->modpackEdit)); + connect(ui->modpackEdit, &QLineEdit::textChanged, this, &ImportPage::updateState); +} + +ImportPage::~ImportPage() +{ + delete ui; +} + +bool ImportPage::shouldDisplay() const +{ + return true; +} + +void ImportPage::retranslate() +{ + ui->retranslateUi(this); +} + +void ImportPage::openedImpl() +{ + updateState(); +} + +void ImportPage::updateState() +{ + if (!isOpened) { + return; + } + if (ui->modpackEdit->hasAcceptableInput()) { + QString input = ui->modpackEdit->text().trimmed(); + auto url = QUrl::fromUserInput(input); + if (url.isLocalFile()) { + // FIXME: actually do some validation of what's inside here... this is fake AF + QFileInfo fi(input); + + // Allow non-latin people to use ZIP files! + bool isZip = QMimeDatabase().mimeTypeForUrl(url).suffixes().contains("zip"); + // mrpack is a modrinth pack + bool isMRPack = fi.suffix() == "mrpack"; + + if (fi.exists() && (isZip || isMRPack)) { + auto extra_info = QMap(m_extra_info); + qDebug() << "Pack Extra Info" << extra_info << m_extra_info; + dialog->setSuggestedPack(fi.completeBaseName(), new InstanceImportTask(url, this, std::move(extra_info))); + dialog->setSuggestedIcon("default"); + } + } else if (url.scheme() == "curseforge") { + // need to find the download link for the modpack + // format of url curseforge://install?addonId=IDHERE&fileId=IDHERE + QUrlQuery query(url); + if (query.allQueryItemValues("addonId").isEmpty() || query.allQueryItemValues("fileId").isEmpty()) { + qDebug() << "Invalid curseforge link:" << url; + return; + } + auto addonId = query.allQueryItemValues("addonId")[0]; + auto fileId = query.allQueryItemValues("fileId")[0]; + + auto api = FlameAPI(); + auto [job, array] = api.getFile(addonId, fileId); + + connect(job.get(), &NetJob::failed, this, + [this](QString reason) { CustomMessageBox::selectable(this, tr("Error"), reason, QMessageBox::Critical)->show(); }); + connect(job.get(), &NetJob::succeeded, this, [this, array, addonId, fileId] { + qDebug() << "Returned CFURL Json:\n" << array->toStdString().c_str(); + auto doc = Json::requireDocument(*array); + auto data = doc.object()["data"].toObject(); + // No way to find out if it's a mod or a modpack before here + // And also we need to check if it ends with .zip, instead of any better way + auto fileName = data["fileName"].toString(); + if (fileName.endsWith(".zip")) { + // Have to use ensureString then use QUrl to get proper url encoding + auto dl_url = QUrl(data["downloadUrl"].toString("")); + if (!dl_url.isValid()) { + CustomMessageBox::selectable( + this, tr("Error"), + tr("The modpack %1 is blocked for third-parties! Please download it manually.").arg(fileName), + QMessageBox::Critical) + ->show(); + return; + } + + QFileInfo dl_file(dl_url.fileName()); + QString pack_name = data["displayName"].toString(dl_file.completeBaseName()); + + QMap extra_info; + extra_info.insert("pack_id", addonId); + extra_info.insert("pack_version_id", fileId); + + dialog->setSuggestedPack(pack_name, new InstanceImportTask(dl_url, this, std::move(extra_info))); + dialog->setSuggestedIcon("default"); + + } else { + CustomMessageBox::selectable(this, tr("Error"), tr("This url isn't a valid modpack !"), QMessageBox::Critical)->show(); + } + }); + ProgressDialog dlUrlDialod(this); + dlUrlDialod.setSkipButton(true, tr("Abort")); + dlUrlDialod.execWithTask(job.get()); + return; + } else { + if (input.endsWith("?client=y")) { + input.chop(9); + input.append("/file"); + url = QUrl::fromUserInput(input); + } + // hook, line and sinker. + QFileInfo fi(url.fileName()); + auto extra_info = QMap(m_extra_info); + dialog->setSuggestedPack(fi.completeBaseName(), new InstanceImportTask(url, this, std::move(extra_info))); + dialog->setSuggestedIcon("default"); + } + } else { + dialog->setSuggestedPack(); + } +} + +void ImportPage::setUrl(const QString& url) +{ + ui->modpackEdit->setText(url); + updateState(); +} + +void ImportPage::setExtraInfo(const QMap& extra_info) +{ + m_extra_info = extra_info; + updateState(); +} + +void ImportPage::on_modpackBtn_clicked() +{ + const QMimeType zip = QMimeDatabase().mimeTypeForName("application/zip"); + auto filter = tr("Supported files") + QString(" (%1 *.mrpack)").arg(zip.globPatterns().join(" ")); + filter += ";;" + zip.filterString(); + //: Option for filtering for *.mrpack files when importing + filter += ";;" + tr("Modrinth pack") + " (*.mrpack)"; + const QUrl url = QFileDialog::getOpenFileUrl(this, tr("Choose modpack"), modpackUrl(), filter); + if (url.isValid()) { + if (url.isLocalFile()) { + ui->modpackEdit->setText(url.toLocalFile()); + } else { + ui->modpackEdit->setText(url.toString()); + } + } +} + +QUrl ImportPage::modpackUrl() const +{ + const QUrl url(ui->modpackEdit->text()); + if (url.isValid() && !url.isRelative() && !url.host().isEmpty()) { + return url; + } else { + return QUrl::fromLocalFile(ui->modpackEdit->text()); + } +} diff --git a/launcher/ui/pages/modplatform/ImportPage.h b/launcher/ui/pages/modplatform/ImportPage.h new file mode 100644 index 0000000..1119e70 --- /dev/null +++ b/launcher/ui/pages/modplatform/ImportPage.h @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +#include "tasks/Task.h" +#include "ui/pages/BasePage.h" + +namespace Ui { +class ImportPage; +} + +class NewInstanceDialog; + +class ImportPage : public QWidget, public BasePage { + Q_OBJECT + + public: + explicit ImportPage(NewInstanceDialog* dialog, QWidget* parent = 0); + virtual ~ImportPage(); + virtual QString displayName() const override { return tr("Import"); } + virtual QIcon icon() const override { return QIcon::fromTheme("viewfolder"); } + virtual QString id() const override { return "import"; } + virtual QString helpPage() const override { return "Zip-import"; } + virtual bool shouldDisplay() const override; + void retranslate() override; + + void setUrl(const QString& url); + void openedImpl() override; + void setExtraInfo(const QMap& extra_info); + private slots: + void on_modpackBtn_clicked(); + void updateState(); + + private: + QUrl modpackUrl() const; + + private: + Ui::ImportPage* ui = nullptr; + NewInstanceDialog* dialog = nullptr; + QMap m_extra_info = {}; +}; diff --git a/launcher/ui/pages/modplatform/ImportPage.ui b/launcher/ui/pages/modplatform/ImportPage.ui new file mode 100644 index 0000000..9a9736b --- /dev/null +++ b/launcher/ui/pages/modplatform/ImportPage.ui @@ -0,0 +1,106 @@ + + + ImportPage + + + + 0 + 0 + 546 + 405 + + + + + + + http:// + + + + + + + Browse + + + + + + + + + The following file types are implemented (both for local files and URLs): + + + Qt::AlignCenter + + + + + + + - CurseForge modpacks (ZIP / curseforge:// URL) + + + Qt::AlignCenter + + + + + + + - Modrinth modpacks (ZIP and mrpack) + + + Qt::AlignCenter + + + + + + + - Prism Launcher, PolyMC or MultiMC exported instances (ZIP) + + + Qt::AlignCenter + + + + + + + - Technic modpacks (ZIP) + + + Qt::AlignCenter + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + Local file or link to a direct download: + + + + + + + + diff --git a/launcher/ui/pages/modplatform/ModModel.cpp b/launcher/ui/pages/modplatform/ModModel.cpp new file mode 100644 index 0000000..c0768b9 --- /dev/null +++ b/launcher/ui/pages/modplatform/ModModel.cpp @@ -0,0 +1,135 @@ +// SPDX-FileCopyrightText: 2023 flowln +// +// SPDX-License-Identifier: GPL-3.0-only + +#include "ModModel.h" + +#include "minecraft/MinecraftInstance.h" +#include "minecraft/PackProfile.h" +#include "minecraft/mod/ModFolderModel.h" +#include "modplatform/ModIndex.h" + +#include +#include + +namespace ResourceDownload { + +ModModel::ModModel(BaseInstance& base_inst, ResourceAPI* api, QString debugName, QString metaEntryBase) + : ResourceModel(api), m_base_instance(base_inst), m_debugName(debugName + " (Model)"), m_metaEntryBase(metaEntryBase) +{} + +/******** Make data requests ********/ + +ResourceAPI::SearchArgs ModModel::createSearchArguments() +{ + auto profile = static_cast(m_base_instance).getPackProfile(); + + Q_ASSERT(profile); + Q_ASSERT(m_filter); + + std::optional> versions{}; + std::optional categories{}; + auto loaders = profile->getSupportedModLoaders(); + + // Version filter + if (!m_filter->versions.empty()) + versions = m_filter->versions; + if (m_filter->loaders) + loaders = m_filter->loaders; + if (!m_filter->categoryIds.empty()) + categories = m_filter->categoryIds; + auto side = m_filter->side; + + auto sort = getCurrentSortingMethodByIndex(); + + return { + ModPlatform::ResourceType::Mod, m_next_search_offset, m_search_term, sort, loaders, versions, side, categories, m_filter->openSource + }; +} + +ResourceAPI::VersionSearchArgs ModModel::createVersionsArguments(const QModelIndex& entry) +{ + auto pack = m_packs[entry.row()]; + auto profile = static_cast(m_base_instance).getPackProfile(); + + Q_ASSERT(profile); + Q_ASSERT(m_filter); + + std::optional> versions{}; + auto loaders = profile->getSupportedModLoaders(); + if (!m_filter->versions.empty()) + versions = m_filter->versions; + if (m_filter->loaders) + loaders = m_filter->loaders; + + return { pack, versions, loaders, ModPlatform::ResourceType::Mod }; +} + +ResourceAPI::ProjectInfoArgs ModModel::createInfoArguments(const QModelIndex& entry) +{ + auto pack = m_packs[entry.row()]; + return { pack }; +} + +void ModModel::searchWithTerm(const QString& term, unsigned int sort, bool filter_changed) +{ + if (m_search_term == term && m_search_term.isNull() == term.isNull() && m_current_sort_index == sort && !filter_changed) { + return; + } + + setSearchTerm(term); + m_current_sort_index = sort; + + refresh(); +} + +bool ModModel::isPackInstalled(ModPlatform::IndexedPack::Ptr pack) const +{ + auto allMods = static_cast(m_base_instance).loaderModList()->allMods(); + return std::any_of(allMods.cbegin(), allMods.cend(), [pack](Mod* mod) { + if (auto meta = mod->metadata(); meta) + return meta->provider == pack->provider && meta->project_id == pack->addonId; + return false; + }); +} + +QVariant ModModel::getInstalledPackVersion(ModPlatform::IndexedPack::Ptr pack) const +{ + auto allMods = static_cast(m_base_instance).loaderModList()->allMods(); + for (auto mod : allMods) { + if (auto meta = mod->metadata(); meta && meta->provider == pack->provider && meta->project_id == pack->addonId) { + return meta->version(); + } + } + return {}; +} + +bool checkSide(ModPlatform::Side filter, ModPlatform::Side value) +{ + return (filter != ModPlatform::Side::ClientSide && filter != ModPlatform::Side::ServerSide) || + (value != ModPlatform::Side::ClientSide && value != ModPlatform::Side::ServerSide) || filter == value; +} + +bool ModModel::checkFilters(ModPlatform::IndexedPack::Ptr pack) +{ + if (!m_filter) + return true; + return !(m_filter->hideInstalled && isPackInstalled(pack)) && checkSide(m_filter->side, pack->side); +} + +bool ModModel::checkVersionFilters(const ModPlatform::IndexedVersion& v) +{ + if (!m_filter) + return true; + auto loaders = static_cast(m_base_instance).getPackProfile()->getSupportedModLoaders(); + if (m_filter->loaders) + loaders = m_filter->loaders; + return (!optedOut(v) && // is opted out(aka curseforge download link) + (!loaders.has_value() || !v.loaders || loaders.value() & v.loaders) && // loaders + checkSide(m_filter->side, v.side) && // side + (m_filter->releases.empty() || // releases + std::find(m_filter->releases.cbegin(), m_filter->releases.cend(), v.version_type) != m_filter->releases.cend()) && + m_filter->checkMcVersions(v.mcVersion)); // mcVersions +} + +} // namespace ResourceDownload diff --git a/launcher/ui/pages/modplatform/ModModel.h b/launcher/ui/pages/modplatform/ModModel.h new file mode 100644 index 0000000..873d4c1 --- /dev/null +++ b/launcher/ui/pages/modplatform/ModModel.h @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: 2023 flowln +// +// SPDX-License-Identifier: GPL-3.0-only + +#pragma once + +#include + +#include "BaseInstance.h" + +#include "modplatform/ModIndex.h" +#include "modplatform/ResourceAPI.h" + +#include "ui/pages/modplatform/ResourceModel.h" +#include "ui/widgets/ModFilterWidget.h" + +class Version; + +namespace ResourceDownload { + +class ModPage; + +class ModModel : public ResourceModel { + Q_OBJECT + + public: + ModModel(BaseInstance&, ResourceAPI* api, QString debugName, QString metaEntryBase); + + /* Ask the API for more information */ + void searchWithTerm(const QString& term, unsigned int sort, bool filter_changed); + + void setFilter(std::shared_ptr filter) { m_filter = filter; } + virtual QVariant getInstalledPackVersion(ModPlatform::IndexedPack::Ptr) const override; + + [[nodiscard]] QString debugName() const override { return m_debugName; } + [[nodiscard]] QString metaEntryBase() const override { return m_metaEntryBase; } + + public slots: + ResourceAPI::SearchArgs createSearchArguments() override; + ResourceAPI::VersionSearchArgs createVersionsArguments(const QModelIndex&) override; + ResourceAPI::ProjectInfoArgs createInfoArguments(const QModelIndex&) override; + + protected: + virtual bool isPackInstalled(ModPlatform::IndexedPack::Ptr) const override; + + virtual bool checkFilters(ModPlatform::IndexedPack::Ptr) override; + virtual bool checkVersionFilters(const ModPlatform::IndexedVersion&) override; + + protected: + BaseInstance& m_base_instance; + + std::shared_ptr m_filter = nullptr; + + private: + QString m_debugName; + QString m_metaEntryBase; +}; + +} // namespace ResourceDownload diff --git a/launcher/ui/pages/modplatform/ModPage.cpp b/launcher/ui/pages/modplatform/ModPage.cpp new file mode 100644 index 0000000..706d353 --- /dev/null +++ b/launcher/ui/pages/modplatform/ModPage.cpp @@ -0,0 +1,121 @@ +// SPDX-FileCopyrightText: 2023 flowln +// +// SPDX-License-Identifier: GPL-3.0-only AND Apache-2.0 +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2022 TheKodeToad + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ModPage.h" +#include "ui_ResourcePage.h" + +#include +#include +#include + +#include + +#include "Application.h" +#include "ResourceDownloadTask.h" + +#include "minecraft/MinecraftInstance.h" +#include "minecraft/PackProfile.h" + +#include "ui/dialogs/ResourceDownloadDialog.h" + +namespace ResourceDownload { + +ModPage::ModPage(ModDownloadDialog* dialog, BaseInstance& instance) : ResourcePage(dialog, instance) +{ + connect(m_ui->resourceFilterButton, &QPushButton::clicked, this, &ModPage::filterMods); +} + +void ModPage::setFilterWidget(std::unique_ptr& widget) +{ + if (m_filter_widget) + disconnect(m_filter_widget.get(), nullptr, nullptr, nullptr); + + auto old = m_ui->splitter->replaceWidget(0, widget.get()); + // because we replaced the widget we also need to delete it + if (old) { + delete old; + } + + m_filter_widget.swap(widget); + + m_filter = m_filter_widget->getFilter(); + + connect(m_filter_widget.get(), &ModFilterWidget::filterChanged, this, &ModPage::triggerSearch); + prepareProviderCategories(); +} + +/******** Callbacks to events in the UI (set up in the derived classes) ********/ + +void ModPage::filterMods() +{ + m_filter_widget->setHidden(!m_filter_widget->isHidden()); +} + +void ModPage::triggerSearch() +{ + auto changed = m_filter_widget->changed(); + m_filter = m_filter_widget->getFilter(); + m_ui->packView->selectionModel()->setCurrentIndex({}, QItemSelectionModel::SelectionFlag::ClearAndSelect); + m_ui->packView->clearSelection(); + m_ui->packDescription->clear(); + m_ui->versionSelectionBox->clear(); + updateSelectionButton(); + + static_cast(m_model)->searchWithTerm(getSearchTerm(), m_ui->sortByBox->currentData().toUInt(), changed); + m_fetchProgress.watch(m_model->activeSearchJob().get()); +} + +QMap ModPage::urlHandlers() const +{ + QMap map; + map.insert(QRegularExpression::anchoredPattern("(?:www\\.)?modrinth\\.com\\/mod\\/([^\\/]+)\\/?"), "modrinth"); + map.insert(QRegularExpression::anchoredPattern("(?:www\\.)?curseforge\\.com\\/minecraft\\/mc-mods\\/([^\\/]+)\\/?"), "curseforge"); + map.insert(QRegularExpression::anchoredPattern("minecraft\\.curseforge\\.com\\/projects\\/([^\\/]+)\\/?"), "curseforge"); + return map; +} + +/******** Make changes to the UI ********/ + +void ModPage::addResourceToPage(ModPlatform::IndexedPack::Ptr pack, ModPlatform::IndexedVersion& version, ResourceFolderModel* base_model) +{ + bool is_indexed = !APPLICATION->settings()->get("ModMetadataDisabled").toBool(); + m_model->addPack(pack, version, base_model, is_indexed); +} + +} // namespace ResourceDownload diff --git a/launcher/ui/pages/modplatform/ModPage.h b/launcher/ui/pages/modplatform/ModPage.h new file mode 100644 index 0000000..a69ee53 --- /dev/null +++ b/launcher/ui/pages/modplatform/ModPage.h @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: 2023 flowln +// +// SPDX-License-Identifier: GPL-3.0-only + +#pragma once + +#include + +#include "modplatform/ModIndex.h" + +#include "ui/pages/modplatform/ModModel.h" +#include "ui/pages/modplatform/ResourcePage.h" +#include "ui/widgets/ModFilterWidget.h" + +namespace Ui { +class ResourcePage; +} + +namespace ResourceDownload { + +class ModDownloadDialog; + +/* This page handles most logic related to browsing and selecting mods to download. */ +class ModPage : public ResourcePage { + Q_OBJECT + + public: + template + static T* create(ModDownloadDialog* dialog, BaseInstance& instance) + { + auto page = new T(dialog, instance); + auto model = static_cast(page->getModel()); + + auto filter_widget = page->createFilterWidget(); + page->setFilterWidget(filter_widget); + model->setFilter(page->getFilter()); + + connect(model, &ResourceModel::versionListUpdated, page, &ResourcePage::versionListUpdated); + connect(model, &ResourceModel::projectInfoUpdated, page, &ResourcePage::updateUi); + connect(model, &QAbstractListModel::modelReset, page, &ResourcePage::modelReset); + + return page; + } + + //: The plural version of 'mod' + inline QString resourcesString() const override { return tr("mods"); } + //: The singular version of 'mods' + inline QString resourceString() const override { return tr("mod"); } + + QMap urlHandlers() const override; + + void addResourceToPage(ModPlatform::IndexedPack::Ptr, ModPlatform::IndexedVersion&, ResourceFolderModel*) override; + + virtual std::unique_ptr createFilterWidget() = 0; + + bool supportsFiltering() const override { return true; }; + auto getFilter() const -> const std::shared_ptr { return m_filter; } + void setFilterWidget(std::unique_ptr&); + + protected: + ModPage(ModDownloadDialog* dialog, BaseInstance& instance); + + virtual void prepareProviderCategories() {}; + + protected slots: + virtual void filterMods(); + void triggerSearch() override; + + protected: + std::unique_ptr m_filter_widget; + std::shared_ptr m_filter; +}; + +} // namespace ResourceDownload diff --git a/launcher/ui/pages/modplatform/ModpackProviderBasePage.h b/launcher/ui/pages/modplatform/ModpackProviderBasePage.h new file mode 100644 index 0000000..03faeba --- /dev/null +++ b/launcher/ui/pages/modplatform/ModpackProviderBasePage.h @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include "ui/pages/BasePage.h" + +class ModpackProviderBasePage : public BasePage { + public: + /** Programatically set the term in the search bar. */ + virtual void setSearchTerm(QString) = 0; + /** Get the current term in the search bar. */ + virtual QString getSerachTerm() const = 0; +}; diff --git a/launcher/ui/pages/modplatform/OptionalModDialog.cpp b/launcher/ui/pages/modplatform/OptionalModDialog.cpp new file mode 100644 index 0000000..5dc53d9 --- /dev/null +++ b/launcher/ui/pages/modplatform/OptionalModDialog.cpp @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "OptionalModDialog.h" +#include "ui_OptionalModDialog.h" + +OptionalModDialog::OptionalModDialog(QWidget* parent, const QStringList& mods) : QDialog(parent), ui(new Ui::OptionalModDialog) +{ + ui->setupUi(this); + for (const QString& mod : mods) { + auto item = new QListWidgetItem(mod, ui->list); + item->setFlags(item->flags() | Qt::ItemIsUserCheckable); + item->setCheckState(Qt::Unchecked); + item->setData(Qt::UserRole, mod); + } + + connect(ui->selectAllButton, &QPushButton::clicked, ui->list, [this] { + for (int i = 0; i < ui->list->count(); i++) + ui->list->item(i)->setCheckState(Qt::Checked); + }); + connect(ui->clearAllButton, &QPushButton::clicked, ui->list, [this] { + for (int i = 0; i < ui->list->count(); i++) + ui->list->item(i)->setCheckState(Qt::Unchecked); + }); + connect(ui->list, &QListWidget::itemActivated, [](QListWidgetItem* item) { + if (item->checkState() == Qt::Checked) + item->setCheckState(Qt::Unchecked); + else + item->setCheckState(Qt::Checked); + }); + + ui->buttonBox->button(QDialogButtonBox::Cancel)->setText(tr("Cancel")); + ui->buttonBox->button(QDialogButtonBox::Ok)->setText(tr("OK")); +} + +OptionalModDialog::~OptionalModDialog() +{ + delete ui; +} + +QStringList OptionalModDialog::getResult() +{ + QStringList result; + result.reserve(ui->list->count()); + for (int i = 0; i < ui->list->count(); i++) { + auto item = ui->list->item(i); + if (item->checkState() == Qt::Checked) + result.append(item->data(Qt::UserRole).toString()); + } + return result; +} diff --git a/launcher/ui/pages/modplatform/OptionalModDialog.h b/launcher/ui/pages/modplatform/OptionalModDialog.h new file mode 100644 index 0000000..1897c1f --- /dev/null +++ b/launcher/ui/pages/modplatform/OptionalModDialog.h @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include + +namespace Ui { +class OptionalModDialog; +} + +class OptionalModDialog : public QDialog { + Q_OBJECT + + public: + OptionalModDialog(QWidget* parent, const QStringList& mods); + ~OptionalModDialog() override; + + QStringList getResult(); + + private: + Ui::OptionalModDialog* ui; +}; diff --git a/launcher/ui/pages/modplatform/OptionalModDialog.ui b/launcher/ui/pages/modplatform/OptionalModDialog.ui new file mode 100644 index 0000000..3ac9b5b --- /dev/null +++ b/launcher/ui/pages/modplatform/OptionalModDialog.ui @@ -0,0 +1,116 @@ + + + OptionalModDialog + + + + 0 + 0 + 550 + 310 + + + + Select Optional Mods + + + + + + + + Qt::IgnoreAction + + + true + + + QAbstractItemView::ScrollPerPixel + + + + + + + + + Select All + + + + + + + Deselect All + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Unchecked mods will be disabled. + + + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + + + buttonBox + accepted() + OptionalModDialog + accept() + + + 274 + 284 + + + 274 + 154 + + + + + buttonBox + rejected() + OptionalModDialog + reject() + + + 274 + 284 + + + 274 + 154 + + + + + diff --git a/launcher/ui/pages/modplatform/ResourceModel.cpp b/launcher/ui/pages/modplatform/ResourceModel.cpp new file mode 100644 index 0000000..e90eafb --- /dev/null +++ b/launcher/ui/pages/modplatform/ResourceModel.cpp @@ -0,0 +1,511 @@ +// SPDX-FileCopyrightText: 2023 flowln +// +// SPDX-License-Identifier: GPL-3.0-only + +#include "ResourceModel.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Application.h" +#include "settings/SettingsObject.h" +#include "BuildConfig.h" + +#include "modplatform/ResourceAPI.h" +#include "net/ApiDownload.h" +#include "net/NetJob.h" + +#include "modplatform/ModIndex.h" + +#include "ui/widgets/ProjectItem.h" + +namespace ResourceDownload { + +QHash ResourceModel::s_running_models; + +ResourceModel::ResourceModel(ResourceAPI* api) : QAbstractListModel(), m_api(api) +{ + s_running_models.insert(this, true); + if (APPLICATION_DYN) { + m_current_info_job.setMaxConcurrent(APPLICATION->settings()->get("NumberOfConcurrentDownloads").toInt()); + } +} + +ResourceModel::~ResourceModel() +{ + s_running_models.find(this).value() = false; +} + +auto ResourceModel::data(const QModelIndex& index, int role) const -> QVariant +{ + int pos = index.row(); + if (pos >= m_packs.size() || pos < 0 || !index.isValid()) { + return QString("INVALID INDEX %1").arg(pos); + } + + auto pack = m_packs.at(pos); + switch (role) { + case Qt::ToolTipRole: { + if (pack->description.length() > 100) { + // some magic to prevent to long tooltips and replace html linebreaks + QString edit = pack->description.left(97); + edit = edit.left(edit.lastIndexOf("
    ")).left(edit.lastIndexOf(" ")).append("..."); + return edit; + } + return pack->description; + } + case Qt::DecorationRole: { + if (APPLICATION_DYN) { + if (auto icon_or_none = const_cast(this)->getIcon(const_cast(index), pack->logoUrl); + icon_or_none.has_value()) + return icon_or_none.value(); + + return QIcon::fromTheme("screenshot-placeholder"); + } else { + return {}; + } + } + case Qt::SizeHintRole: + return QSize(0, 58); + case Qt::UserRole: { + QVariant v; + v.setValue(pack); + return v; + } + // Custom data + case UserDataTypes::TITLE: + return pack->name; + case UserDataTypes::DESCRIPTION: + return pack->description; + case Qt::CheckStateRole: + return pack->isAnyVersionSelected() ? Qt::Checked : Qt::Unchecked; + case UserDataTypes::INSTALLED: + return this->isPackInstalled(pack); + default: + break; + } + + return {}; +} + +QHash ResourceModel::roleNames() const +{ + QHash roles; + + roles[Qt::ToolTipRole] = "toolTip"; + roles[Qt::DecorationRole] = "decoration"; + roles[Qt::SizeHintRole] = "sizeHint"; + roles[Qt::UserRole] = "pack"; + roles[UserDataTypes::TITLE] = "title"; + roles[UserDataTypes::DESCRIPTION] = "description"; + roles[UserDataTypes::INSTALLED] = "installed"; + + return roles; +} + +bool ResourceModel::setData(const QModelIndex& index, const QVariant& value, [[maybe_unused]] int role) +{ + int pos = index.row(); + if (pos >= m_packs.size() || pos < 0 || !index.isValid()) + return false; + + m_packs[pos] = value.value(); + emit dataChanged(index, index); + + return true; +} + +QString ResourceModel::debugName() const +{ + return "ResourceDownload (Model)"; +} + +void ResourceModel::fetchMore(const QModelIndex& parent) +{ + if (parent.isValid() || m_search_state == SearchState::Finished) + return; + + search(); +} + +void ResourceModel::search() +{ + if (hasActiveSearchJob()) + return; + + if (m_search_term.startsWith("#")) { + auto projectId = m_search_term.mid(1); + if (!projectId.isEmpty()) { + ResourceAPI::Callback callbacks; + + callbacks.on_fail = [this](QString reason, int) { + if (!s_running_models.constFind(this).value()) + return; + searchRequestFailed(reason, -1); + }; + callbacks.on_abort = [this] { + if (!s_running_models.constFind(this).value()) + return; + searchRequestAborted(); + }; + + callbacks.on_succeed = [this](auto& pack) { + if (!s_running_models.constFind(this).value()) + return; + searchRequestForOneSucceeded(pack); + }; + auto project = std::make_shared(); + project->addonId = projectId; + if (auto job = m_api->getProjectInfo({ project }, std::move(callbacks)); job) + runSearchJob(job); + return; + } + } + auto args{ createSearchArguments() }; + + ResourceAPI::Callback> callbacks{}; + + callbacks.on_succeed = [this](auto& doc) { + if (!s_running_models.constFind(this).value()) + return; + searchRequestSucceeded(doc); + }; + callbacks.on_fail = [this](QString reason, int network_error_code) { + if (!s_running_models.constFind(this).value()) + return; + searchRequestFailed(reason, network_error_code); + }; + callbacks.on_abort = [this] { + if (!s_running_models.constFind(this).value()) + return; + searchRequestAborted(); + }; + + if (auto job = m_api->searchProjects(std::move(args), std::move(callbacks)); job) + runSearchJob(job); +} + +void ResourceModel::loadEntry(const QModelIndex& entry) +{ + auto const& pack = m_packs[entry.row()]; + + if (!hasActiveInfoJob()) + m_current_info_job.clear(); + + if (!pack->versionsLoaded) { + auto args{ createVersionsArguments(entry) }; + ResourceAPI::Callback> callbacks{}; + + auto addonId = pack->addonId; + // Use default if no callbacks are set + if (!callbacks.on_succeed) + callbacks.on_succeed = [this, entry, addonId](auto& doc) { + if (!s_running_models.constFind(this).value()) + return; + versionRequestSucceeded(doc, addonId, entry); + }; + if (!callbacks.on_fail) + callbacks.on_fail = [](QString reason, int) { + QMessageBox::critical(nullptr, tr("Error"), + tr("A network error occurred. Could not load project versions: %1").arg(reason)); + }; + + if (auto job = m_api->getProjectVersions(std::move(args), std::move(callbacks)); job) + runInfoJob(job); + } + + if (!pack->extraDataLoaded) { + auto args{ createInfoArguments(entry) }; + ResourceAPI::Callback callbacks{}; + + callbacks.on_succeed = [this, entry](auto& newpack) { + if (!s_running_models.constFind(this).value()) + return; + infoRequestSucceeded(newpack, entry); + }; + callbacks.on_fail = [this](QString reason, int) { + if (!s_running_models.constFind(this).value()) + return; + QMessageBox::critical(nullptr, tr("Error"), tr("A network error occurred. Could not load project info: %1").arg(reason)); + }; + callbacks.on_abort = [this] { + if (!s_running_models.constFind(this).value()) + return; + qCritical() << tr("The request was aborted for an unknown reason"); + }; + + if (auto job = m_api->getProjectInfo(std::move(args), std::move(callbacks)); job) + runInfoJob(job); + } +} + +void ResourceModel::refresh() +{ + bool reset_requested = false; + + if (hasActiveInfoJob()) { + m_current_info_job.abort(); + reset_requested = true; + } + + if (hasActiveSearchJob()) { + m_current_search_job->abort(); + reset_requested = true; + } + + if (reset_requested) { + m_search_state = SearchState::ResetRequested; + return; + } + + clearData(); + m_search_state = SearchState::None; + + m_next_search_offset = 0; + search(); +} + +void ResourceModel::clearData() +{ + beginResetModel(); + m_packs.clear(); + endResetModel(); +} + +void ResourceModel::runSearchJob(Task::Ptr ptr) +{ + m_current_search_job.reset(ptr); // clean up first + m_current_search_job->start(); +} +void ResourceModel::runInfoJob(Task::Ptr ptr) +{ + if (!m_current_info_job.isRunning()) + m_current_info_job.clear(); + + m_current_info_job.addTask(ptr); + + if (!m_current_info_job.isRunning()) + m_current_info_job.run(); +} + +std::optional ResourceModel::getCurrentSortingMethodByIndex() const +{ + std::optional sort{}; + + { // Find sorting method by ID + auto sorting_methods = getSortingMethods(); + auto method = std::find_if(sorting_methods.constBegin(), sorting_methods.constEnd(), + [this](auto const& e) { return m_current_sort_index == e.index; }); + if (method != sorting_methods.constEnd()) + sort = *method; + } + + return sort; +} + +std::optional ResourceModel::getIcon(QModelIndex& index, const QUrl& url) +{ + QPixmap pixmap; + if (QPixmapCache::find(url.toString(), &pixmap)) + return { pixmap }; + + if (!m_current_icon_job) { + m_current_icon_job.reset(new NetJob("IconJob", APPLICATION->network())); + m_current_icon_job->setAskRetry(false); + } + + if (m_currently_running_icon_actions.contains(url)) + return {}; + if (m_failed_icon_actions.contains(url)) + return {}; + + auto cache_entry = APPLICATION->metacache()->resolveEntry( + metaEntryBase(), + QString("logos/%1").arg(QString(QCryptographicHash::hash(url.toEncoded(), QCryptographicHash::Algorithm::Sha1).toHex()))); + auto icon_fetch_action = Net::ApiDownload::makeCached(url, cache_entry); + + auto full_file_path = cache_entry->getFullPath(); + connect(icon_fetch_action.get(), &Task::succeeded, this, [this, url, full_file_path, index] { + auto icon = QIcon(full_file_path); + QPixmapCache::insert(url.toString(), icon.pixmap(icon.actualSize({ 64, 64 }))); + + m_currently_running_icon_actions.remove(url); + + emit dataChanged(index, index, { Qt::DecorationRole }); + }); + connect(icon_fetch_action.get(), &Task::failed, this, [this, url] { + m_currently_running_icon_actions.remove(url); + m_failed_icon_actions.insert(url); + }); + + m_currently_running_icon_actions.insert(url); + + m_current_icon_job->addNetAction(icon_fetch_action); + if (!m_current_icon_job->isRunning()) + QMetaObject::invokeMethod(m_current_icon_job.get(), &NetJob::start); + + return {}; +} + +/* Default callbacks */ + +void ResourceModel::searchRequestSucceeded(QList& newList) +{ + QList filteredNewList; + for (auto pack : newList) { + ModPlatform::IndexedPack::Ptr p; + if (auto sel = std::find_if(m_selected.begin(), m_selected.end(), + [&pack](const DownloadTaskPtr i) { + const auto ipack = i->getPack(); + return ipack->provider == pack->provider && ipack->addonId == pack->addonId; + }); + sel != m_selected.end()) { + p = sel->get()->getPack(); + } else { + p = pack; + } + if (checkFilters(p)) { + filteredNewList << p; + } + } + + if (newList.size() < 25) { + m_search_state = SearchState::Finished; + } else { + m_next_search_offset += 25; + m_search_state = SearchState::CanFetchMore; + } + + // When you have a Qt build with assertions turned on, proceeding here will abort the application + if (filteredNewList.size() == 0) + return; + + beginInsertRows(QModelIndex(), m_packs.size(), m_packs.size() + filteredNewList.size() - 1); + m_packs.append(filteredNewList); + endInsertRows(); +} + +void ResourceModel::searchRequestForOneSucceeded(ModPlatform::IndexedPack::Ptr pack) +{ + m_search_state = SearchState::Finished; + + beginInsertRows(QModelIndex(), m_packs.size(), m_packs.size() + 1); + m_packs.append(pack); + endInsertRows(); +} + +void ResourceModel::searchRequestFailed([[maybe_unused]] QString reason, int network_error_code) +{ + switch (network_error_code) { + default: + // Network error + QMessageBox::critical(nullptr, tr("Error"), tr("A network error occurred. Could not load mods.")); + break; + case 409: + // 409 Gone, notify user to update + QMessageBox::critical(nullptr, tr("Error"), + QString("%1").arg(tr("API version too old!\nPlease update %1!").arg(BuildConfig.LAUNCHER_DISPLAYNAME))); + break; + } + + m_search_state = SearchState::Finished; +} + +void ResourceModel::searchRequestAborted() +{ + if (m_search_state != SearchState::ResetRequested) + qCritical() << "Search task in" << debugName() << "aborted by an unknown reason!"; + + // Retry fetching + clearData(); + + m_next_search_offset = 0; + search(); +} + +void ResourceModel::versionRequestSucceeded(QVector& doc, QVariant pack, const QModelIndex& index) +{ + auto current_pack = data(index, Qt::UserRole).value(); + + // Check if the index is still valid for this resource or not + if (pack != current_pack->addonId) + return; + + current_pack->versions = doc; + current_pack->versionsLoaded = true; + + // Cache info :^) + QVariant new_pack; + new_pack.setValue(current_pack); + if (!setData(index, new_pack, Qt::UserRole)) { + qWarning() << "Failed to cache resource versions!"; + return; + } + + emit versionListUpdated(index); +} + +void ResourceModel::infoRequestSucceeded(ModPlatform::IndexedPack::Ptr pack, const QModelIndex& index) +{ + auto current_pack = data(index, Qt::UserRole).value(); + + // Check if the index is still valid for this resource or not + if (pack->addonId != current_pack->addonId) + return; + + // Cache info :^) + QVariant new_pack; + new_pack.setValue(pack); + if (!setData(index, new_pack, Qt::UserRole)) { + qWarning() << "Failed to cache resource info!"; + return; + } + + emit projectInfoUpdated(index); +} + +void ResourceModel::addPack(ModPlatform::IndexedPack::Ptr pack, + ModPlatform::IndexedVersion& version, + ResourceFolderModel* packs, + bool is_indexed) +{ + version.is_currently_selected = true; + m_selected.append(makeShared(pack, version, packs, is_indexed)); +} + +void ResourceModel::removePack(const QString& rem) +{ + auto pred = [&rem](const DownloadTaskPtr i) { return rem == i->getName(); }; +#if QT_VERSION >= QT_VERSION_CHECK(6, 1, 0) + m_selected.removeIf(pred); +#else + { + for (auto it = m_selected.begin(); it != m_selected.end();) + if (pred(*it)) + it = m_selected.erase(it); + else + ++it; + } +#endif + auto pack = std::find_if(m_packs.begin(), m_packs.end(), [&rem](const ModPlatform::IndexedPack::Ptr i) { return rem == i->name; }); + if (pack == m_packs.end()) { // ignore it if is not in the current search + return; + } + if (!pack->get()->versionsLoaded) { + return; + } + for (auto& ver : pack->get()->versions) + ver.is_currently_selected = false; +} + +bool ResourceModel::checkVersionFilters(const ModPlatform::IndexedVersion& v) +{ + return (!optedOut(v)); +} +} // namespace ResourceDownload diff --git a/launcher/ui/pages/modplatform/ResourceModel.h b/launcher/ui/pages/modplatform/ResourceModel.h new file mode 100644 index 0000000..573ad8b --- /dev/null +++ b/launcher/ui/pages/modplatform/ResourceModel.h @@ -0,0 +1,153 @@ +// SPDX-FileCopyrightText: 2023 flowln +// +// SPDX-License-Identifier: GPL-3.0-only + +#pragma once + +#include + +#include + +#include "QObjectPtr.h" + +#include "ResourceDownloadTask.h" +#include "modplatform/ModIndex.h" +#include "modplatform/ResourceAPI.h" + +#include "tasks/ConcurrentTask.h" + +class NetJob; +class ResourceAPI; + +namespace ModPlatform { +struct IndexedPack; +} + +namespace ResourceDownload { + +class ResourceModel : public QAbstractListModel { + Q_OBJECT + + Q_PROPERTY(QString search_term MEMBER m_search_term WRITE setSearchTerm) + + public: + using DownloadTaskPtr = shared_qobject_ptr; + + ResourceModel(ResourceAPI* api); + ~ResourceModel() override; + + auto data(const QModelIndex&, int role) const -> QVariant override; + auto roleNames() const -> QHash override; + bool setData(const QModelIndex& index, const QVariant& value, int role) override; + + virtual auto debugName() const -> QString; + virtual auto metaEntryBase() const -> QString = 0; + + inline int rowCount(const QModelIndex& parent) const override { return parent.isValid() ? 0 : static_cast(m_packs.size()); } + inline int columnCount(const QModelIndex& parent) const override { return parent.isValid() ? 0 : 1; } + inline auto flags(const QModelIndex& index) const -> Qt::ItemFlags override { return QAbstractListModel::flags(index); } + + bool hasActiveSearchJob() const { return m_current_search_job && m_current_search_job->isRunning(); } + bool hasActiveInfoJob() const { return m_current_info_job.isRunning(); } + Task::Ptr activeSearchJob() { return hasActiveSearchJob() ? m_current_search_job : nullptr; } + + auto getSortingMethods() const { return m_api->getSortingMethods(); } + + virtual QVariant getInstalledPackVersion(ModPlatform::IndexedPack::Ptr) const { return {}; } + /** Whether the version is opted out or not. Currently only makes sense in CF. */ + virtual bool optedOut(const ModPlatform::IndexedVersion& ver) const + { + Q_UNUSED(ver); + return false; + }; + + virtual bool checkFilters(ModPlatform::IndexedPack::Ptr) { return true; } + virtual bool checkVersionFilters(const ModPlatform::IndexedVersion&); + + public slots: + void fetchMore(const QModelIndex& parent) override; + inline bool canFetchMore(const QModelIndex& parent) const override + { + return parent.isValid() ? false : m_search_state == SearchState::CanFetchMore; + } + + void setSearchTerm(QString term) { m_search_term = term; } + + virtual ResourceAPI::SearchArgs createSearchArguments() = 0; + + virtual ResourceAPI::VersionSearchArgs createVersionsArguments(const QModelIndex&) = 0; + + virtual ResourceAPI::ProjectInfoArgs createInfoArguments(const QModelIndex&) = 0; + + /** Requests the API for more entries. */ + virtual void search(); + + /** Applies any processing / extra requests needed to fully load the specified entry's information. */ + virtual void loadEntry(const QModelIndex&); + + /** Schedule a refresh, clearing the current state. */ + void refresh(); + + /** Gets the icon at the URL for the given index. If it's not fetched yet, fetch it and update when fisinhed. */ + std::optional getIcon(QModelIndex&, const QUrl&); + + void addPack(ModPlatform::IndexedPack::Ptr pack, + ModPlatform::IndexedVersion& version, + ResourceFolderModel* packs, + bool is_indexed = false); + void removePack(const QString& rem); + QList selectedPacks() { return m_selected; } + + protected: + /** Resets the model's data. */ + void clearData(); + + void runSearchJob(Task::Ptr); + void runInfoJob(Task::Ptr); + + auto getCurrentSortingMethodByIndex() const -> std::optional; + + virtual bool isPackInstalled(ModPlatform::IndexedPack::Ptr) const { return false; } + + protected: + /* Basic search parameters */ + enum class SearchState { None, CanFetchMore, ResetRequested, Finished } m_search_state = SearchState::None; + int m_next_search_offset = 0; + QString m_search_term; + unsigned int m_current_sort_index = 0; + + std::unique_ptr m_api; + + // Job for searching for new entries + shared_qobject_ptr m_current_search_job; + // Job for fetching versions and extra info on existing entries + ConcurrentTask m_current_info_job; + + shared_qobject_ptr m_current_icon_job; + QSet m_currently_running_icon_actions; + QSet m_failed_icon_actions; + + QList m_packs; + QList m_selected; + + // HACK: We need this to prevent callbacks from calling the model after it has already been deleted. + // This leaks a tiny bit of memory per time the user has opened a resource dialog. How to make this better? + static QHash s_running_models; + + private: + /* Default search request callbacks */ + void searchRequestSucceeded(QList&); + void searchRequestForOneSucceeded(ModPlatform::IndexedPack::Ptr); + void searchRequestFailed(QString reason, int network_error_code); + void searchRequestAborted(); + + void versionRequestSucceeded(QVector&, QVariant, const QModelIndex&); + + void infoRequestSucceeded(ModPlatform::IndexedPack::Ptr, const QModelIndex&); + + signals: + void versionListUpdated(const QModelIndex& index); + void projectInfoUpdated(const QModelIndex& index); +}; + +} // namespace ResourceDownload diff --git a/launcher/ui/pages/modplatform/ResourcePackModel.cpp b/launcher/ui/pages/modplatform/ResourcePackModel.cpp new file mode 100644 index 0000000..ac5121f --- /dev/null +++ b/launcher/ui/pages/modplatform/ResourcePackModel.cpp @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: 2023 flowln +// +// SPDX-License-Identifier: GPL-3.0-only + +#include "ResourcePackModel.h" + +#include +#include + +namespace ResourceDownload { + +ResourcePackResourceModel::ResourcePackResourceModel(const BaseInstance& base_inst, + ResourceAPI* api, + const QString& debugName, + QString metaEntryBase) + : ResourceModel(api), m_base_instance(base_inst), m_debugName(debugName + " (Model)"), m_metaEntryBase(std::move(metaEntryBase)) +{} + +/******** Make data requests ********/ + +ResourceAPI::SearchArgs ResourcePackResourceModel::createSearchArguments() +{ + auto sort = getCurrentSortingMethodByIndex(); + return { + .type = ModPlatform::ResourceType::ResourcePack, + .offset = m_next_search_offset, + .search = m_search_term, + .sorting = sort, + .loaders = {}, + .versions = {}, + .side = {}, + .categoryIds = {}, + .openSource = {}, + }; +} + +ResourceAPI::VersionSearchArgs ResourcePackResourceModel::createVersionsArguments(const QModelIndex& entry) +{ + auto pack = m_packs[entry.row()]; + return { .pack = pack, .mcVersions = {}, .loaders = {}, .resourceType = ModPlatform::ResourceType::ResourcePack }; +} + +ResourceAPI::ProjectInfoArgs ResourcePackResourceModel::createInfoArguments(const QModelIndex& entry) +{ + auto pack = m_packs[entry.row()]; + return { .pack = pack }; +} + +void ResourcePackResourceModel::searchWithTerm(const QString& term, unsigned int sort) +{ + if (m_search_term == term && m_search_term.isNull() == term.isNull() && m_current_sort_index == sort) { + return; + } + + setSearchTerm(term); + m_current_sort_index = sort; + + refresh(); +} + +} // namespace ResourceDownload diff --git a/launcher/ui/pages/modplatform/ResourcePackModel.h b/launcher/ui/pages/modplatform/ResourcePackModel.h new file mode 100644 index 0000000..92e3c4d --- /dev/null +++ b/launcher/ui/pages/modplatform/ResourcePackModel.h @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2023 flowln +// +// SPDX-License-Identifier: GPL-3.0-only + +#pragma once + +#include + +#include "BaseInstance.h" + +#include "ui/pages/modplatform/ResourceModel.h" + +class Version; + +namespace ResourceDownload { + +class ResourcePackResourceModel : public ResourceModel { + Q_OBJECT + + public: + ResourcePackResourceModel(const BaseInstance&, ResourceAPI*, const QString& debugName, QString metaEntryBase); + + /* Ask the API for more information */ + void searchWithTerm(const QString& term, unsigned int sort); + + [[nodiscard]] QString debugName() const override { return m_debugName; } + [[nodiscard]] QString metaEntryBase() const override { return m_metaEntryBase; } + + public slots: + ResourceAPI::SearchArgs createSearchArguments() override; + ResourceAPI::VersionSearchArgs createVersionsArguments(const QModelIndex&) override; + ResourceAPI::ProjectInfoArgs createInfoArguments(const QModelIndex&) override; + + protected: + const BaseInstance& m_base_instance; + + private: + QString m_debugName; + QString m_metaEntryBase; +}; + +} // namespace ResourceDownload diff --git a/launcher/ui/pages/modplatform/ResourcePackPage.cpp b/launcher/ui/pages/modplatform/ResourcePackPage.cpp new file mode 100644 index 0000000..8a7ed27 --- /dev/null +++ b/launcher/ui/pages/modplatform/ResourcePackPage.cpp @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: 2023 flowln +// +// SPDX-License-Identifier: GPL-3.0-only + +#include "ResourcePackPage.h" +#include "ui_ResourcePage.h" + +#include "ResourcePackModel.h" + +#include "ui/dialogs/ResourceDownloadDialog.h" + +#include + +namespace ResourceDownload { + +ResourcePackResourcePage::ResourcePackResourcePage(ResourceDownloadDialog* dialog, BaseInstance& instance) : ResourcePage(dialog, instance) +{} + +/******** Callbacks to events in the UI (set up in the derived classes) ********/ + +void ResourcePackResourcePage::triggerSearch() +{ + m_ui->packView->selectionModel()->setCurrentIndex({}, QItemSelectionModel::SelectionFlag::ClearAndSelect); + m_ui->packView->clearSelection(); + m_ui->packDescription->clear(); + m_ui->versionSelectionBox->clear(); + + updateSelectionButton(); + + static_cast(m_model)->searchWithTerm(getSearchTerm(), m_ui->sortByBox->currentData().toUInt()); + m_fetchProgress.watch(m_model->activeSearchJob().get()); +} + +QMap ResourcePackResourcePage::urlHandlers() const +{ + QMap map; + map.insert(QRegularExpression::anchoredPattern("(?:www\\.)?modrinth\\.com\\/resourcepack\\/([^\\/]+)\\/?"), "modrinth"); + map.insert(QRegularExpression::anchoredPattern("(?:www\\.)?curseforge\\.com\\/minecraft\\/texture-packs\\/([^\\/]+)\\/?"), + "curseforge"); + map.insert(QRegularExpression::anchoredPattern("minecraft\\.curseforge\\.com\\/projects\\/([^\\/]+)\\/?"), "curseforge"); + return map; +} + +} // namespace ResourceDownload diff --git a/launcher/ui/pages/modplatform/ResourcePackPage.h b/launcher/ui/pages/modplatform/ResourcePackPage.h new file mode 100644 index 0000000..f8d4d5b --- /dev/null +++ b/launcher/ui/pages/modplatform/ResourcePackPage.h @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2023 flowln +// +// SPDX-License-Identifier: GPL-3.0-only + +#pragma once + +#include "ui/pages/modplatform/ResourcePackModel.h" +#include "ui/pages/modplatform/ResourcePage.h" + +namespace Ui { +class ResourcePage; +} + +namespace ResourceDownload { + +class ResourcePackDownloadDialog; + +class ResourcePackResourcePage : public ResourcePage { + Q_OBJECT + + public: + template + static T* create(ResourcePackDownloadDialog* dialog, BaseInstance& instance) + { + auto page = new T(dialog, instance); + auto model = static_cast(page->getModel()); + + connect(model, &ResourceModel::versionListUpdated, page, &ResourcePage::versionListUpdated); + connect(model, &ResourceModel::projectInfoUpdated, page, &ResourcePage::updateUi); + connect(model, &QAbstractListModel::modelReset, page, &ResourcePage::modelReset); + + return page; + } + + //: The plural version of 'resource pack' + inline QString resourcesString() const override { return tr("resource packs"); } + //: The singular version of 'resource packs' + inline QString resourceString() const override { return tr("resource pack"); } + + bool supportsFiltering() const override { return false; }; + + QMap urlHandlers() const override; + + inline auto helpPage() const -> QString override { return "resourcepack-platform"; } + + protected: + ResourcePackResourcePage(ResourceDownloadDialog* dialog, BaseInstance& instance); + + protected slots: + void triggerSearch() override; +}; + +} // namespace ResourceDownload diff --git a/launcher/ui/pages/modplatform/ResourcePage.cpp b/launcher/ui/pages/modplatform/ResourcePage.cpp new file mode 100644 index 0000000..98aa650 --- /dev/null +++ b/launcher/ui/pages/modplatform/ResourcePage.cpp @@ -0,0 +1,575 @@ +// SPDX-FileCopyrightText: 2023 flowln +// +// SPDX-License-Identifier: GPL-3.0-only AND Apache-2.0 +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2023 TheKodeToad + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ResourcePage.h" +#include "modplatform/ModIndex.h" +#include "ui/dialogs/CustomMessageBox.h" +#include "ui_ResourcePage.h" + +#include +#include +#include + +#include "Markdown.h" + +#include "Application.h" +#include "ui/dialogs/ResourceDownloadDialog.h" +#include "ui/pages/modplatform/ResourceModel.h" +#include "ui/widgets/ProjectItem.h" + +namespace ResourceDownload { + +ResourcePage::ResourcePage(ResourceDownloadDialog* parent, BaseInstance& base_instance) + : QWidget(parent), m_baseInstance(base_instance), m_ui(new Ui::ResourcePage), m_parentDialog(parent), m_fetchProgress(this, false) +{ + m_ui->setupUi(this); + + m_ui->searchEdit->installEventFilter(this); + + m_ui->versionSelectionBox->view()->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + m_ui->versionSelectionBox->view()->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); + m_ui->versionSelectionBox->view()->parentWidget()->setMaximumHeight(300); + + m_searchTimer.setTimerType(Qt::TimerType::CoarseTimer); + m_searchTimer.setSingleShot(true); + + connect(&m_searchTimer, &QTimer::timeout, this, &ResourcePage::triggerSearch); + + // hide progress bar to prevent weird artifact + m_fetchProgress.hide(); + m_fetchProgress.hideIfInactive(true); + m_fetchProgress.setFixedHeight(24); + m_fetchProgress.progressFormat(""); + + m_ui->verticalLayout->insertWidget(1, &m_fetchProgress); + + auto delegate = new ProjectItemDelegate(this); + m_ui->packView->setItemDelegate(delegate); + m_ui->packView->installEventFilter(this); + m_ui->packView->viewport()->installEventFilter(this); + + connect(m_ui->packDescription, &QTextBrowser::anchorClicked, this, &ResourcePage::openUrl); + + connect(m_ui->packView, &QAbstractItemView::doubleClicked, this, &ResourcePage::onResourceToggle); + connect(delegate, &ProjectItemDelegate::checkboxClicked, this, &ResourcePage::onResourceToggle); +} + +ResourcePage::~ResourcePage() +{ + delete m_ui; + if (m_model) + delete m_model; +} + +void ResourcePage::retranslate() +{ + m_ui->retranslateUi(this); +} + +void ResourcePage::openedImpl() +{ + if (!supportsFiltering()) { + m_ui->resourceFilterButton->setVisible(false); + m_ui->filterWidget->hide(); + } + + //: String in the search bar of the mod downloading dialog + m_ui->searchEdit->setPlaceholderText(tr("Search for %1...").arg(resourcesString())); + m_ui->resourceSelectionButton->setText(tr("Select %1 for download").arg(resourceString())); + + updateSelectionButton(); + triggerSearch(); + m_ui->searchEdit->setFocus(); +} + +auto ResourcePage::eventFilter(QObject* watched, QEvent* event) -> bool +{ + if (event->type() == QEvent::KeyPress) { + auto* keyEvent = static_cast(event); + if (watched == m_ui->searchEdit) { + if (keyEvent->key() == Qt::Key_Return) { + triggerSearch(); + keyEvent->accept(); + return true; + } else { + if (m_searchTimer.isActive()) + m_searchTimer.stop(); + + m_searchTimer.start(350); + } + } else if (watched == m_ui->packView) { + // stop the event from going to the confirm button + if (keyEvent->key() == Qt::Key_Return) { + onResourceToggle(m_ui->packView->currentIndex()); + keyEvent->accept(); + return true; + } + } + } else if (watched == m_ui->packView->viewport() && event->type() == QEvent::MouseButtonPress) { + auto* mouseEvent = static_cast(event); + + if (mouseEvent->button() == Qt::MiddleButton) { + onResourceToggle(m_ui->packView->indexAt(mouseEvent->pos())); + return true; + } + } + + return QWidget::eventFilter(watched, event); +} + +QString ResourcePage::getSearchTerm() const +{ + return m_ui->searchEdit->text(); +} + +void ResourcePage::setSearchTerm(QString term) +{ + m_ui->searchEdit->setText(term); +} + +void ResourcePage::addSortings() +{ + Q_ASSERT(m_model); + + auto sorts = m_model->getSortingMethods(); + std::sort(sorts.begin(), sorts.end(), [](auto const& l, auto const& r) { return l.index < r.index; }); + + for (auto&& sorting : sorts) + m_ui->sortByBox->addItem(sorting.readable_name, QVariant(sorting.index)); +} + +bool ResourcePage::setCurrentPack(ModPlatform::IndexedPack::Ptr pack) +{ + QVariant v; + v.setValue(pack); + return m_model->setData(m_ui->packView->currentIndex(), v, Qt::UserRole); +} + +ModPlatform::IndexedPack::Ptr ResourcePage::getCurrentPack() const +{ + return m_model->data(m_ui->packView->currentIndex(), Qt::UserRole).value(); +} + +void ResourcePage::updateUi(const QModelIndex& index) +{ + if (index != m_ui->packView->currentIndex()) + return; + + auto current_pack = getCurrentPack(); + if (!current_pack) { + m_ui->packDescription->setHtml({}); + m_ui->packDescription->flush(); + return; + } + QString text = ""; + QString name = current_pack->name; + + if (current_pack->websiteUrl.isEmpty()) + text = name; + else + text = "websiteUrl + "\">" + name + ""; + + if (!current_pack->authors.empty()) { + auto authorToStr = [](ModPlatform::ModpackAuthor& author) -> QString { + if (author.url.isEmpty()) { + return author.name; + } + return QString("%2").arg(author.url, author.name); + }; + QStringList authorStrs; + for (auto& author : current_pack->authors) { + authorStrs.push_back(authorToStr(author)); + } + text += "
    " + tr(" by ") + authorStrs.join(", "); + } + + if (current_pack->extraDataLoaded) { + if (current_pack->extraData.status == "archived") { + text += "

    " + tr("This project has been archived. It will not receive any further updates unless the author decides " + "to unarchive the project."); + } + + if (!current_pack->extraData.donate.isEmpty()) { + text += "

    " + tr("Donate information: "); + auto donateToStr = [](ModPlatform::DonationData& donate) -> QString { + return QString("%2").arg(donate.url, donate.platform); + }; + QStringList donates; + for (auto& donate : current_pack->extraData.donate) { + donates.append(donateToStr(donate)); + } + text += donates.join(", "); + } + + if (!current_pack->extraData.issuesUrl.isEmpty() || !current_pack->extraData.sourceUrl.isEmpty() || + !current_pack->extraData.wikiUrl.isEmpty() || !current_pack->extraData.discordUrl.isEmpty()) { + text += "

    " + tr("External links:") + "
    "; + } + + if (!current_pack->extraData.issuesUrl.isEmpty()) + text += "- " + tr("Issues: %1").arg(current_pack->extraData.issuesUrl) + "
    "; + if (!current_pack->extraData.wikiUrl.isEmpty()) + text += "- " + tr("Wiki: %1").arg(current_pack->extraData.wikiUrl) + "
    "; + if (!current_pack->extraData.sourceUrl.isEmpty()) + text += "- " + tr("Source code: %1").arg(current_pack->extraData.sourceUrl) + "
    "; + if (!current_pack->extraData.discordUrl.isEmpty()) + text += "- " + tr("Discord: %1").arg(current_pack->extraData.discordUrl) + "
    "; + } + + text += "
    "; + + m_ui->packDescription->setHtml(StringUtils::htmlListPatch( + text + (current_pack->extraData.body.isEmpty() ? current_pack->description : markdownToHTML(current_pack->extraData.body)))); + m_ui->packDescription->flush(); +} + +void ResourcePage::updateSelectionButton() +{ + if (!isOpened || m_selectedVersionIndex < 0) { + m_ui->resourceSelectionButton->setEnabled(false); + return; + } + + m_ui->resourceSelectionButton->setEnabled(true); + if (auto current_pack = getCurrentPack(); current_pack) { + if (current_pack->versionsLoaded && current_pack->versions.empty()) { + m_ui->resourceSelectionButton->setEnabled(false); + qWarning() << tr("No version available for the selected pack"); + } else if (!current_pack->isVersionSelected(m_selectedVersionIndex)) + m_ui->resourceSelectionButton->setText(tr("Select %1 for download").arg(resourceString())); + else + m_ui->resourceSelectionButton->setText(tr("Deselect %1 for download").arg(resourceString())); + } else { + qWarning() << "Tried to update the selected button but there is not a pack selected"; + } +} + +void ResourcePage::versionListUpdated(const QModelIndex& index) +{ + if (index == m_ui->packView->currentIndex()) { + auto current_pack = getCurrentPack(); + + m_ui->versionSelectionBox->blockSignals(true); + m_ui->versionSelectionBox->clear(); + m_ui->versionSelectionBox->blockSignals(false); + + if (current_pack) { + auto installedVersion = m_model->getInstalledPackVersion(current_pack); + + for (int i = 0; i < current_pack->versions.size(); i++) { + auto& version = current_pack->versions[i]; + if (!m_model->checkVersionFilters(version)) + continue; + + auto versionText = version.version; + if (version.version_type.isValid()) { + versionText += QString(" [%1]").arg(version.version_type.toString()); + } + if (version.fileId == installedVersion) { + versionText += tr(" [installed]", "Mod version select"); + } + + m_ui->versionSelectionBox->addItem(versionText, QVariant(i)); + } + } + if (m_ui->versionSelectionBox->count() == 0) { + m_ui->versionSelectionBox->addItem(tr("No valid version found."), QVariant(-1)); + m_ui->resourceSelectionButton->setText(tr("Cannot select invalid version :(")); + } + + if (m_enableQueue.contains(index.row())) { + m_enableQueue.remove(index.row()); + onResourceToggle(index); + } else + updateSelectionButton(); + } else if (m_enableQueue.contains(index.row())) { + m_enableQueue.remove(index.row()); + onResourceToggle(index); + } +} + +void ResourcePage::onSelectionChanged(QModelIndex curr, [[maybe_unused]] QModelIndex prev) +{ + if (!curr.isValid()) { + return; + } + + auto current_pack = getCurrentPack(); + + bool request_load = false; + if (!current_pack || !current_pack->versionsLoaded) { + m_ui->resourceSelectionButton->setText(tr("Loading versions...")); + m_ui->resourceSelectionButton->setEnabled(false); + + request_load = true; + } else { + versionListUpdated(curr); + } + + if (current_pack && !current_pack->extraDataLoaded) + request_load = true; + + // we are already requesting this + if (m_enableQueue.contains(curr.row())) + request_load = false; + + if (request_load) + m_model->loadEntry(curr); + + updateUi(curr); +} + +void ResourcePage::onVersionSelectionChanged(int index) +{ + m_selectedVersionIndex = m_ui->versionSelectionBox->itemData(index).toInt(); + updateSelectionButton(); +} + +void ResourcePage::addResourceToDialog(ModPlatform::IndexedPack::Ptr pack, ModPlatform::IndexedVersion& version) +{ + m_parentDialog->addResource(pack, version); +} + +void ResourcePage::removeResourceFromDialog(const QString& pack_name) +{ + m_parentDialog->removeResource(pack_name); +} + +void ResourcePage::addResourceToPage(ModPlatform::IndexedPack::Ptr pack, ModPlatform::IndexedVersion& ver, ResourceFolderModel* base_model) +{ + bool is_indexed = !APPLICATION->settings()->get("ModMetadataDisabled").toBool(); + m_model->addPack(pack, ver, base_model, is_indexed); +} + +void ResourcePage::modelReset() +{ + m_enableQueue.clear(); +} + +void ResourcePage::removeResourceFromPage(const QString& name) +{ + m_model->removePack(name); +} + +void ResourcePage::onResourceSelected() +{ + if (m_selectedVersionIndex < 0) + return; + + auto current_pack = getCurrentPack(); + if (!current_pack || !current_pack->versionsLoaded || current_pack->versions.size() < m_selectedVersionIndex) + return; + + auto& version = current_pack->versions[m_selectedVersionIndex]; + Q_ASSERT(!version.downloadUrl.isNull()); + if (version.is_currently_selected) + removeResourceFromDialog(current_pack->name); + else + addResourceToDialog(current_pack, version); + + // Save the modified pack (and prevent warning in release build) + [[maybe_unused]] bool set = setCurrentPack(current_pack); + Q_ASSERT(set); + + updateSelectionButton(); + + /* Force redraw on the resource list when the selection changes */ + m_ui->packView->repaint(); +} + +void ResourcePage::onResourceToggle(const QModelIndex& index) +{ + const bool isSelected = index == m_ui->packView->currentIndex(); + auto pack = m_model->data(index, Qt::UserRole).value(); + + if (pack->versionsLoaded) { + if (pack->isAnyVersionSelected()) + removeResourceFromDialog(pack->name); + else { + auto version = std::find_if(pack->versions.begin(), pack->versions.end(), [this](const ModPlatform::IndexedVersion& version) { + return m_model->checkVersionFilters(version); + }); + + if (version == pack->versions.end()) { + auto errorMessage = new QMessageBox( + QMessageBox::Warning, tr("No versions available"), + tr("No versions for '%1' are available.\nThe author likely blocked third-party launchers.").arg(pack->name), + QMessageBox::Ok, this); + + errorMessage->open(); + } else + addResourceToDialog(pack, *version); + } + + if (isSelected) + updateSelectionButton(); + + // force update + QVariant variant; + variant.setValue(pack); + m_model->setData(index, variant, Qt::UserRole); + } else { + // the model is just 1 dimensional so this is fine + m_enableQueue.insert(index.row()); + + // we can't be sure that this hasn't already been requested... + // but this does the job well enough and there's not much point preventing edgecases + if (!isSelected) + m_model->loadEntry(index); + } +} + +void ResourcePage::openUrl(const QUrl& url) +{ + // do not allow other url schemes for security reasons + if (!(url.scheme() == "http" || url.scheme() == "https")) { + qWarning() << "Unsupported scheme" << url.scheme(); + return; + } + + // detect URLs and search instead + + const QString address = url.host() + url.path(); + QRegularExpressionMatch match; + QString page; + + auto handlers = urlHandlers(); + for (auto it = handlers.constKeyValueBegin(); it != handlers.constKeyValueEnd(); it++) { + auto&& [regex, candidate] = *it; + if (match = QRegularExpression(regex).match(address); match.hasMatch()) { + page = candidate; + break; + } + } + + if (!page.isNull() && !m_doNotJumpToMod) { + const QString slug = match.captured(1); + + // ensure the user isn't opening the same mod + if (auto current_pack = getCurrentPack(); current_pack && slug != current_pack->slug) { + m_parentDialog->selectPage(page); + + auto newPage = m_parentDialog->selectedPage(); + + QLineEdit* searchEdit = newPage->m_ui->searchEdit; + auto model = newPage->m_model; + QListView* view = newPage->m_ui->packView; + + auto jump = [url, slug, model, view] { + for (int row = 0; row < model->rowCount({}); row++) { + const QModelIndex index = model->index(row); + const auto pack = model->data(index, Qt::UserRole).value(); + + if (pack->slug == slug) { + view->setCurrentIndex(index); + return; + } + } + + // The final fallback. + QDesktopServices::openUrl(url); + }; + + searchEdit->setText(slug); + newPage->triggerSearch(); + + if (model->hasActiveSearchJob()) + connect(model->activeSearchJob().get(), &Task::finished, jump); + else + jump(); + + return; + } + } + + // open in the user's web browser + QDesktopServices::openUrl(url); +} + +void ResourcePage::openProject(QVariant projectID) +{ + m_ui->sortByBox->hide(); + m_ui->searchEdit->hide(); + m_ui->resourceFilterButton->hide(); + m_ui->packView->hide(); + m_ui->resourceSelectionButton->hide(); + m_doNotJumpToMod = true; + + auto buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + + auto okBtn = buttonBox->button(QDialogButtonBox::Ok); + okBtn->setDefault(true); + okBtn->setAutoDefault(true); + okBtn->setText(tr("Reinstall")); + okBtn->setShortcut(tr("Ctrl+Return")); + okBtn->setEnabled(false); + + auto cancelBtn = buttonBox->button(QDialogButtonBox::Cancel); + cancelBtn->setDefault(false); + cancelBtn->setAutoDefault(false); + cancelBtn->setText(tr("Cancel")); + + connect(okBtn, &QPushButton::clicked, this, [this] { + onResourceSelected(); + m_parentDialog->accept(); + }); + + connect(cancelBtn, &QPushButton::clicked, m_parentDialog, &ResourceDownloadDialog::reject); + m_ui->gridLayout_4->addWidget(buttonBox, 1, 2); + + connect(m_ui->versionSelectionBox, &QComboBox::currentIndexChanged, this, + [this, okBtn](int index) { okBtn->setEnabled(m_ui->versionSelectionBox->itemData(index).toInt() >= 0); }); + + auto jump = [this] { + if (m_model->rowCount({}) > 0) { + m_ui->packView->setCurrentIndex(m_model->index(0)); + return; + } + m_ui->packDescription->setText(tr("The resource was not found")); + }; + + m_ui->searchEdit->setText("#" + projectID.toString()); + triggerSearch(); + + if (m_model->hasActiveSearchJob()) + connect(m_model->activeSearchJob().get(), &Task::finished, jump); + else + jump(); +} +} // namespace ResourceDownload diff --git a/launcher/ui/pages/modplatform/ResourcePage.h b/launcher/ui/pages/modplatform/ResourcePage.h new file mode 100644 index 0000000..6e219bf --- /dev/null +++ b/launcher/ui/pages/modplatform/ResourcePage.h @@ -0,0 +1,123 @@ +// SPDX-FileCopyrightText: 2023 flowln +// +// SPDX-License-Identifier: GPL-3.0-only + +#pragma once + +#include +#include + +#include "ResourceDownloadTask.h" +#include "modplatform/ModIndex.h" +#include "modplatform/ResourceAPI.h" + +#include "ui/pages/BasePage.h" +#include "ui/pages/modplatform/ResourceModel.h" +#include "ui/widgets/ProgressWidget.h" + +namespace Ui { +class ResourcePage; +} + +class BaseInstance; + +namespace ResourceDownload { + +class ResourceDownloadDialog; +class ResourceModel; + +class ResourcePage : public QWidget, public BasePage { + Q_OBJECT + public: + using DownloadTaskPtr = shared_qobject_ptr; + ~ResourcePage() override; + + /* Affects what the user sees */ + auto displayName() const -> QString override = 0; + auto icon() const -> QIcon override = 0; + auto id() const -> QString override = 0; + auto helpPage() const -> QString override = 0; + bool shouldDisplay() const override = 0; + + /* Used internally */ + virtual auto metaEntryBase() const -> QString = 0; + virtual auto debugName() const -> QString = 0; + + //: The plural version of 'resource' + virtual inline QString resourcesString() const { return tr("resources"); } + //: The singular version of 'resources' + virtual inline QString resourceString() const { return tr("resource"); } + + /* Features this resource's page supports */ + virtual bool supportsFiltering() const = 0; + + void retranslate() override; + void openedImpl() override; + auto eventFilter(QObject* watched, QEvent* event) -> bool override; + + /** Get the current term in the search bar. */ + auto getSearchTerm() const -> QString; + /** Programatically set the term in the search bar. */ + void setSearchTerm(QString); + + bool setCurrentPack(ModPlatform::IndexedPack::Ptr); + auto getCurrentPack() const -> ModPlatform::IndexedPack::Ptr; + auto getDialog() const -> const ResourceDownloadDialog* { return m_parentDialog; } + auto getModel() const -> ResourceModel* { return m_model; } + + protected: + ResourcePage(ResourceDownloadDialog* parent, BaseInstance&); + + void addSortings(); + + public slots: + virtual void updateUi(const QModelIndex& index); + virtual void updateSelectionButton(); + virtual void versionListUpdated(const QModelIndex& index); + + void addResourceToDialog(ModPlatform::IndexedPack::Ptr, ModPlatform::IndexedVersion&); + void removeResourceFromDialog(const QString& pack_name); + virtual void removeResourceFromPage(const QString& name); + virtual void addResourceToPage(ModPlatform::IndexedPack::Ptr, ModPlatform::IndexedVersion&, ResourceFolderModel*); + + virtual void modelReset(); + + QList selectedPacks() { return m_model->selectedPacks(); } + bool hasSelectedPacks() { return !(m_model->selectedPacks().isEmpty()); } + + virtual void openProject(QVariant projectID); + + protected slots: + virtual void triggerSearch() = 0; + + void onSelectionChanged(QModelIndex first, QModelIndex second); + void onVersionSelectionChanged(int index); + void onResourceSelected(); + void onResourceToggle(const QModelIndex& index); + + /** Associates regex expressions to pages in the order they're given in the map. */ + virtual QMap urlHandlers() const = 0; + virtual void openUrl(const QUrl&); + + public: + BaseInstance& m_baseInstance; + + protected: + Ui::ResourcePage* m_ui; + + ResourceDownloadDialog* m_parentDialog = nullptr; + ResourceModel* m_model = nullptr; + + int m_selectedVersionIndex = -1; + + ProgressWidget m_fetchProgress; + + // Used to do instant searching with a delay to cache quick changes + QTimer m_searchTimer; + + bool m_doNotJumpToMod = false; + + QSet m_enableQueue; +}; + +} // namespace ResourceDownload diff --git a/launcher/ui/pages/modplatform/ResourcePage.ui b/launcher/ui/pages/modplatform/ResourcePage.ui new file mode 100644 index 0000000..a0eb408 --- /dev/null +++ b/launcher/ui/pages/modplatform/ResourcePage.ui @@ -0,0 +1,104 @@ + + + ResourcePage + + + + 0 + 0 + 837 + 685 + + + + + + + + + Filter options + + + + + + + + + + + + Qt::Horizontal + + + false + + + + + Qt::ScrollBarAlwaysOff + + + true + + + + 48 + 48 + + + + QAbstractItemView::ScrollPerPixel + + + + + false + + + false + + + + + + + + + + + + + Version selected: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + + + + + + + + + ProjectDescriptionPage + QTextBrowser +
    ui/widgets/ProjectDescriptionPage.h
    +
    +
    + + packView + packDescription + sortByBox + versionSelectionBox + + + +
    diff --git a/launcher/ui/pages/modplatform/ShaderPackModel.cpp b/launcher/ui/pages/modplatform/ShaderPackModel.cpp new file mode 100644 index 0000000..e8a6bca --- /dev/null +++ b/launcher/ui/pages/modplatform/ShaderPackModel.cpp @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: 2023 flowln +// +// SPDX-License-Identifier: GPL-3.0-only + +#include "ShaderPackModel.h" + +#include +#include + +namespace ResourceDownload { + +ShaderPackResourceModel::ShaderPackResourceModel(const BaseInstance& base_inst, + ResourceAPI* api, + const QString& debugName, + QString metaEntryBase) + : ResourceModel(api), m_base_instance(base_inst), m_debugName(debugName + " (Model)"), m_metaEntryBase(std::move(metaEntryBase)) +{} + +/******** Make data requests ********/ + +ResourceAPI::SearchArgs ShaderPackResourceModel::createSearchArguments() +{ + auto sort = getCurrentSortingMethodByIndex(); + return { + .type = ModPlatform::ResourceType::ShaderPack, + .offset = m_next_search_offset, + .search = m_search_term, + .sorting = sort, + .loaders = {}, + .versions = {}, + .side = {}, + .categoryIds = {}, + .openSource = {}, + }; +} + +ResourceAPI::VersionSearchArgs ShaderPackResourceModel::createVersionsArguments(const QModelIndex& entry) +{ + auto pack = m_packs[entry.row()]; + return { .pack = pack, .mcVersions = {}, .loaders = {}, .resourceType = ModPlatform::ResourceType::ShaderPack }; +} + +ResourceAPI::ProjectInfoArgs ShaderPackResourceModel::createInfoArguments(const QModelIndex& entry) +{ + auto pack = m_packs[entry.row()]; + return { .pack = pack }; +} + +void ShaderPackResourceModel::searchWithTerm(const QString& term, unsigned int sort) +{ + if (m_search_term == term && m_search_term.isNull() == term.isNull() && m_current_sort_index == sort) { + return; + } + + setSearchTerm(term); + m_current_sort_index = sort; + + refresh(); +} + +} // namespace ResourceDownload diff --git a/launcher/ui/pages/modplatform/ShaderPackModel.h b/launcher/ui/pages/modplatform/ShaderPackModel.h new file mode 100644 index 0000000..cadaf17 --- /dev/null +++ b/launcher/ui/pages/modplatform/ShaderPackModel.h @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: 2023 flowln +// +// SPDX-License-Identifier: GPL-3.0-only + +#pragma once + +#include + +#include "BaseInstance.h" + +#include "modplatform/ModIndex.h" + +#include "ui/pages/modplatform/ResourceModel.h" + +class Version; + +namespace ResourceDownload { + +class ShaderPackResourceModel : public ResourceModel { + Q_OBJECT + + public: + ShaderPackResourceModel(const BaseInstance&, ResourceAPI*, const QString& debugName, QString metaEntryBase); + + /* Ask the API for more information */ + void searchWithTerm(const QString& term, unsigned int sort); + + [[nodiscard]] QString debugName() const override { return m_debugName; } + [[nodiscard]] QString metaEntryBase() const override { return m_metaEntryBase; } + + public slots: + ResourceAPI::SearchArgs createSearchArguments() override; + ResourceAPI::VersionSearchArgs createVersionsArguments(const QModelIndex&) override; + ResourceAPI::ProjectInfoArgs createInfoArguments(const QModelIndex&) override; + + protected: + const BaseInstance& m_base_instance; + + private: + QString m_debugName; + QString m_metaEntryBase; +}; + +} // namespace ResourceDownload diff --git a/launcher/ui/pages/modplatform/ShaderPackPage.cpp b/launcher/ui/pages/modplatform/ShaderPackPage.cpp new file mode 100644 index 0000000..99c5035 --- /dev/null +++ b/launcher/ui/pages/modplatform/ShaderPackPage.cpp @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2023 flowln +// +// SPDX-License-Identifier: GPL-3.0-only + +#include "ShaderPackPage.h" +#include "modplatform/ModIndex.h" +#include "ui_ResourcePage.h" + +#include "ShaderPackModel.h" + +#include "Application.h" +#include "ui/dialogs/ResourceDownloadDialog.h" + +#include + +namespace ResourceDownload { + +ShaderPackResourcePage::ShaderPackResourcePage(ShaderPackDownloadDialog* dialog, BaseInstance& instance) : ResourcePage(dialog, instance) {} + +/******** Callbacks to events in the UI (set up in the derived classes) ********/ + +void ShaderPackResourcePage::triggerSearch() +{ + m_ui->packView->selectionModel()->setCurrentIndex({}, QItemSelectionModel::SelectionFlag::ClearAndSelect); + m_ui->packView->clearSelection(); + m_ui->packDescription->clear(); + m_ui->versionSelectionBox->clear(); + + updateSelectionButton(); + + static_cast(m_model)->searchWithTerm(getSearchTerm(), m_ui->sortByBox->currentData().toUInt()); + m_fetchProgress.watch(m_model->activeSearchJob().get()); +} + +QMap ShaderPackResourcePage::urlHandlers() const +{ + QMap map; + map.insert(QRegularExpression::anchoredPattern("(?:www\\.)?modrinth\\.com\\/shaders\\/([^\\/]+)\\/?"), "modrinth"); + map.insert(QRegularExpression::anchoredPattern("(?:www\\.)?curseforge\\.com\\/minecraft\\/customization\\/([^\\/]+)\\/?"), + "curseforge"); + map.insert(QRegularExpression::anchoredPattern("minecraft\\.curseforge\\.com\\/projects\\/([^\\/]+)\\/?"), "curseforge"); + return map; +} + +void ShaderPackResourcePage::addResourceToPage(ModPlatform::IndexedPack::Ptr pack, + ModPlatform::IndexedVersion& version, + ResourceFolderModel* base_model) +{ + bool is_indexed = !APPLICATION->settings()->get("ModMetadataDisabled").toBool(); + m_model->addPack(pack, version, base_model, is_indexed); +} + +} // namespace ResourceDownload diff --git a/launcher/ui/pages/modplatform/ShaderPackPage.h b/launcher/ui/pages/modplatform/ShaderPackPage.h new file mode 100644 index 0000000..92ddd9f --- /dev/null +++ b/launcher/ui/pages/modplatform/ShaderPackPage.h @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2023 flowln +// +// SPDX-License-Identifier: GPL-3.0-only + +#pragma once + +#include "ui/pages/modplatform/ResourcePage.h" +#include "ui/pages/modplatform/ShaderPackModel.h" + +namespace Ui { +class ResourcePage; +} + +namespace ResourceDownload { + +class ShaderPackDownloadDialog; + +class ShaderPackResourcePage : public ResourcePage { + Q_OBJECT + + public: + template + static T* create(ShaderPackDownloadDialog* dialog, BaseInstance& instance) + { + auto page = new T(dialog, instance); + auto model = static_cast(page->getModel()); + + connect(model, &ResourceModel::versionListUpdated, page, &ResourcePage::versionListUpdated); + connect(model, &ResourceModel::projectInfoUpdated, page, &ResourcePage::updateUi); + connect(model, &QAbstractListModel::modelReset, page, &ResourcePage::modelReset); + + return page; + } + + //: The plural version of 'shader pack' + inline QString resourcesString() const override { return tr("shader packs"); } + //: The singular version of 'shader packs' + inline QString resourceString() const override { return tr("shader pack"); } + + bool supportsFiltering() const override { return false; }; + + void addResourceToPage(ModPlatform::IndexedPack::Ptr, ModPlatform::IndexedVersion&, ResourceFolderModel*) override; + + QMap urlHandlers() const override; + + inline auto helpPage() const -> QString override { return "shaderpack-platform"; } + + protected: + ShaderPackResourcePage(ShaderPackDownloadDialog* dialog, BaseInstance& instance); + + protected slots: + void triggerSearch() override; +}; + +} // namespace ResourceDownload diff --git a/launcher/ui/pages/modplatform/TexturePackModel.cpp b/launcher/ui/pages/modplatform/TexturePackModel.cpp new file mode 100644 index 0000000..32ec488 --- /dev/null +++ b/launcher/ui/pages/modplatform/TexturePackModel.cpp @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: 2023 flowln +// +// SPDX-License-Identifier: GPL-3.0-only + +#include "TexturePackModel.h" + +#include + +#include "Application.h" + +#include "meta/Index.h" +#include "meta/Version.h" + +static std::vector s_availableVersions = {}; + +namespace ResourceDownload { +TexturePackResourceModel::TexturePackResourceModel(const BaseInstance& inst, + ResourceAPI* api, + const QString& debugName, + QString metaEntryBase) + : ResourcePackResourceModel(inst, api, debugName, std::move(metaEntryBase)) + , m_version_list(APPLICATION->metadataIndex()->get("net.minecraft")) +{ + if (!m_version_list->isLoaded()) { + qDebug() << "Loading version list..."; + m_task = m_version_list->getLoadTask(); + if (!m_task->isRunning()) + m_task->start(); + } +} + +void waitOnVersionListLoad(Meta::VersionList::Ptr version_list) +{ + QEventLoop load_version_list_loop; + + QTimer time_limit_for_list_load; + time_limit_for_list_load.setTimerType(Qt::TimerType::CoarseTimer); + time_limit_for_list_load.setSingleShot(true); + time_limit_for_list_load.callOnTimeout(&load_version_list_loop, &QEventLoop::quit); + time_limit_for_list_load.start(4000); + + auto task = version_list->getLoadTask(); + QObject::connect(task.get(), &Task::finished, &load_version_list_loop, &QEventLoop::quit); + if (!task->isRunning()) + task->start(); + load_version_list_loop.exec(); + if (time_limit_for_list_load.isActive()) + time_limit_for_list_load.stop(); +} + +ResourceAPI::SearchArgs TexturePackResourceModel::createSearchArguments() +{ + if (s_availableVersions.empty()) + waitOnVersionListLoad(m_version_list); + + auto args = ResourcePackResourceModel::createSearchArguments(); + + if (!m_version_list->isLoaded()) { + qCritical() << "The version list could not be loaded. Falling back to showing all entries."; + return args; + } + + if (s_availableVersions.empty()) { + for (auto&& version : m_version_list->versions()) { + // FIXME: This duplicates the logic in meta for the 'texturepacks' trait. However, we don't have access to that + // information from the index file alone. Also, downloading every version's file isn't a very good idea. + if (auto ver = version->toComparableVersion(); ver <= maximumTexturePackVersion()) + s_availableVersions.push_back(ver); + } + } + + Q_ASSERT(!s_availableVersions.empty()); + + args.versions = s_availableVersions; + + return args; +} + +ResourceAPI::VersionSearchArgs TexturePackResourceModel::createVersionsArguments(const QModelIndex& entry) +{ + auto args = ResourcePackResourceModel::createVersionsArguments(entry); + args.resourceType = ModPlatform::ResourceType::TexturePack; + if (!m_version_list->isLoaded()) { + qCritical() << "The version list could not be loaded. Falling back to showing all entries."; + return args; + } + + args.mcVersions = s_availableVersions; + return args; +} + +} // namespace ResourceDownload diff --git a/launcher/ui/pages/modplatform/TexturePackModel.h b/launcher/ui/pages/modplatform/TexturePackModel.h new file mode 100644 index 0000000..0e1e3f3 --- /dev/null +++ b/launcher/ui/pages/modplatform/TexturePackModel.h @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2023 flowln +// +// SPDX-License-Identifier: GPL-3.0-only + +#pragma once + +#include "meta/VersionList.h" +#include "ui/pages/modplatform/ResourcePackModel.h" + +namespace ResourceDownload { + +class TexturePackResourceModel : public ResourcePackResourceModel { + Q_OBJECT + + public: + TexturePackResourceModel(const BaseInstance& inst, ResourceAPI* api, const QString& debugName, QString metaEntryBase); + + inline ::Version maximumTexturePackVersion() const { return { "1.6" }; } + + ResourceAPI::SearchArgs createSearchArguments() override; + ResourceAPI::VersionSearchArgs createVersionsArguments(const QModelIndex&) override; + + protected: + Meta::VersionList::Ptr m_version_list; + Task::Ptr m_task; +}; + +} // namespace ResourceDownload diff --git a/launcher/ui/pages/modplatform/TexturePackPage.h b/launcher/ui/pages/modplatform/TexturePackPage.h new file mode 100644 index 0000000..262004d --- /dev/null +++ b/launcher/ui/pages/modplatform/TexturePackPage.h @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2023 flowln +// +// SPDX-License-Identifier: GPL-3.0-only + +#pragma once + +#include "ui/dialogs/ResourceDownloadDialog.h" +#include "ui/pages/modplatform/ResourcePackPage.h" +#include "ui/pages/modplatform/TexturePackModel.h" +#include "ui_ResourcePage.h" + +namespace Ui { +class ResourcePage; +} + +namespace ResourceDownload { + +class TexturePackDownloadDialog; + +class TexturePackResourcePage : public ResourcePackResourcePage { + Q_OBJECT + + public: + template + static T* create(TexturePackDownloadDialog* dialog, BaseInstance& instance) + { + auto page = new T(dialog, instance); + auto model = static_cast(page->getModel()); + + connect(model, &ResourceModel::versionListUpdated, page, &ResourcePage::versionListUpdated); + connect(model, &ResourceModel::projectInfoUpdated, page, &ResourcePage::updateUi); + connect(model, &QAbstractListModel::modelReset, page, &ResourcePage::modelReset); + + return page; + } + + //: The plural version of 'texture pack' + inline QString resourcesString() const override { return tr("texture packs"); } + //: The singular version of 'texture packs' + inline QString resourceString() const override { return tr("texture pack"); } + + protected: + TexturePackResourcePage(TexturePackDownloadDialog* dialog, BaseInstance& instance) : ResourcePackResourcePage(dialog, instance) {} +}; + +} // namespace ResourceDownload diff --git a/launcher/ui/pages/modplatform/atlauncher/AtlFilterModel.cpp b/launcher/ui/pages/modplatform/atlauncher/AtlFilterModel.cpp new file mode 100644 index 0000000..6868ce7 --- /dev/null +++ b/launcher/ui/pages/modplatform/atlauncher/AtlFilterModel.cpp @@ -0,0 +1,104 @@ +/* + * Copyright 2020-2021 Jamie Mansfield + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "AtlFilterModel.h" + +#include + +#include +#include + +#include "StringUtils.h" + +namespace Atl { + +FilterModel::FilterModel(QObject* parent) : QSortFilterProxyModel(parent) +{ + currentSorting = Sorting::ByPopularity; + sortings.insert(tr("Sort by Popularity"), Sorting::ByPopularity); + sortings.insert(tr("Sort by Name"), Sorting::ByName); + sortings.insert(tr("Sort by Game Version"), Sorting::ByGameVersion); + + searchTerm = ""; +} + +const QMap FilterModel::getAvailableSortings() +{ + return sortings; +} + +QString FilterModel::translateCurrentSorting() +{ + return sortings.key(currentSorting); +} + +void FilterModel::setSorting(Sorting sorting) +{ + currentSorting = sorting; + invalidate(); +} + +FilterModel::Sorting FilterModel::getCurrentSorting() +{ + return currentSorting; +} + +void FilterModel::setSearchTerm(const QString term) +{ + searchTerm = term.trimmed(); + invalidate(); +} + +bool FilterModel::filterAcceptsRow(int sourceRow, const QModelIndex& sourceParent) const +{ + if (searchTerm.isEmpty()) { + return true; + } + QModelIndex index = sourceModel()->index(sourceRow, 0, sourceParent); + QVariant raw = sourceModel()->data(index, Qt::UserRole); + Q_ASSERT(raw.canConvert()); + auto pack = raw.value(); + + if (searchTerm.startsWith("#")) + return QString::number(pack.id) == searchTerm.mid(1); + return pack.name.contains(searchTerm, Qt::CaseInsensitive); +} + +bool FilterModel::lessThan(const QModelIndex& left, const QModelIndex& right) const +{ + QVariant leftRaw = sourceModel()->data(left, Qt::UserRole); + Q_ASSERT(leftRaw.canConvert()); + auto leftPack = leftRaw.value(); + QVariant rightRaw = sourceModel()->data(right, Qt::UserRole); + Q_ASSERT(rightRaw.canConvert()); + auto rightPack = rightRaw.value(); + + if (currentSorting == ByPopularity) { + return leftPack.position > rightPack.position; + } else if (currentSorting == ByGameVersion) { + Version lv(leftPack.versions.at(0).minecraft); + Version rv(rightPack.versions.at(0).minecraft); + return lv < rv; + } else if (currentSorting == ByName) { + return StringUtils::naturalCompare(leftPack.name, rightPack.name, Qt::CaseSensitive) >= 0; + } + + // Invalid sorting set, somehow... + qWarning() << "Invalid sorting set!"; + return true; +} + +} // namespace Atl diff --git a/launcher/ui/pages/modplatform/atlauncher/AtlFilterModel.h b/launcher/ui/pages/modplatform/atlauncher/AtlFilterModel.h new file mode 100644 index 0000000..0ab46ed --- /dev/null +++ b/launcher/ui/pages/modplatform/atlauncher/AtlFilterModel.h @@ -0,0 +1,48 @@ +/* + * Copyright 2020-2021 Jamie Mansfield + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +namespace Atl { + +class FilterModel : public QSortFilterProxyModel { + Q_OBJECT + public: + FilterModel(QObject* parent = Q_NULLPTR); + enum Sorting { + ByPopularity, + ByGameVersion, + ByName, + }; + const QMap getAvailableSortings(); + QString translateCurrentSorting(); + void setSorting(Sorting sorting); + Sorting getCurrentSorting(); + void setSearchTerm(QString term); + + protected: + bool filterAcceptsRow(int sourceRow, const QModelIndex& sourceParent) const override; + bool lessThan(const QModelIndex& left, const QModelIndex& right) const override; + + private: + QMap sortings; + Sorting currentSorting; + QString searchTerm; +}; + +} // namespace Atl diff --git a/launcher/ui/pages/modplatform/atlauncher/AtlListModel.cpp b/launcher/ui/pages/modplatform/atlauncher/AtlListModel.cpp new file mode 100644 index 0000000..91668fb --- /dev/null +++ b/launcher/ui/pages/modplatform/atlauncher/AtlListModel.cpp @@ -0,0 +1,221 @@ +/* + * Copyright 2020-2021 Jamie Mansfield + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "AtlListModel.h" + +#include +#include +#include + +#include "net/ApiDownload.h" +#include "ui/widgets/ProjectItem.h" + +namespace Atl { + +ListModel::ListModel(QObject* parent) : QAbstractListModel(parent) {} + +ListModel::~ListModel() {} + +int ListModel::rowCount(const QModelIndex& parent) const +{ + return parent.isValid() ? 0 : modpacks.size(); +} + +int ListModel::columnCount(const QModelIndex& parent) const +{ + return parent.isValid() ? 0 : 1; +} + +QVariant ListModel::data(const QModelIndex& index, int role) const +{ + int pos = index.row(); + if (pos >= modpacks.size() || pos < 0 || !index.isValid()) { + return QString("INVALID INDEX %1").arg(pos); + } + + ATLauncher::IndexedPack pack = modpacks.at(pos); + switch (role) { + case Qt::ToolTipRole: { + if (pack.description.length() > 100) { + // some magic to prevent to long tooltips and replace html linebreaks + QString edit = pack.description.left(97); + edit = edit.left(edit.lastIndexOf("
    ")).left(edit.lastIndexOf(" ")).append("..."); + return edit; + } + return pack.description; + } + case Qt::DecorationRole: { + if (m_logoMap.contains(pack.safeName)) { + return (m_logoMap.value(pack.safeName)); + } + auto icon = QIcon::fromTheme("atlauncher-placeholder"); + + auto url = QString(BuildConfig.ATL_DOWNLOAD_SERVER_URL + "launcher/images/%1").arg(pack.safeName); + ((ListModel*)this)->requestLogo(pack.safeName, url); + + return icon; + } + case Qt::UserRole: { + QVariant v; + v.setValue(pack); + return v; + } + case Qt::DisplayRole: + return pack.name; + case Qt::SizeHintRole: + return QSize(0, 58); + // Custom data + case UserDataTypes::TITLE: + return pack.name; + case UserDataTypes::DESCRIPTION: + return pack.description; + case UserDataTypes::INSTALLED: + return false; + default: + break; + } + + return {}; +} + +void ListModel::request() +{ + beginResetModel(); + modpacks.clear(); + endResetModel(); + + auto netJob = makeShared("Atl::Request", APPLICATION->network()); + auto url = QString(BuildConfig.ATL_DOWNLOAD_SERVER_URL + "launcher/json/packsnew.json"); + auto [action, response] = Net::ApiDownload::makeByteArray(QUrl(url)); + netJob->addNetAction(action); + jobPtr = netJob; + jobPtr->start(); + + connect(netJob.get(), &NetJob::succeeded, this, [this, response] { requestFinished(response); }); + connect(netJob.get(), &NetJob::failed, this, &ListModel::requestFailed); +} + +void ListModel::requestFinished(QByteArray* responsePtr) +{ + // NOTE(TheKodeToad): moving the response out to avoid it from being destroyed by jobPtr.reset() + QByteArray response = std::move(*responsePtr); + jobPtr.reset(); + + QJsonParseError parse_error; + QJsonDocument doc = QJsonDocument::fromJson(response, &parse_error); + if (parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from ATL at" << parse_error.offset << "reason:" << parse_error.errorString(); + qWarning() << response; + return; + } + + QList newList; + + auto packs = doc.array(); + for (auto packRaw : packs) { + auto packObj = packRaw.toObject(); + + ATLauncher::IndexedPack pack; + + try { + ATLauncher::loadIndexedPack(pack, packObj); + } catch (const JSONValidationError& e) { + qDebug() << QString::fromUtf8(response); + qWarning() << "Error while reading pack manifest from ATLauncher:" << e.cause(); + return; + } + + // ignore packs without a published version + if (pack.versions.length() == 0) + continue; + // only display public packs (for now) + if (pack.type != ATLauncher::PackType::Public) + continue; + // ignore "system" packs (Vanilla, Vanilla with Forge, etc) + if (pack.system) + continue; + + newList.append(pack); + } + + beginInsertRows(QModelIndex(), modpacks.size(), modpacks.size() + newList.size() - 1); + modpacks.append(newList); + endInsertRows(); +} + +void ListModel::requestFailed(QString reason) +{ + jobPtr.reset(); +} + +void ListModel::getLogo(const QString& logo, const QString& logoUrl, LogoCallback callback) +{ + if (m_logoMap.contains(logo)) { + callback(APPLICATION->metacache()->resolveEntry("ATLauncherPacks", QString("logos/%1").arg(logo))->getFullPath()); + } else { + requestLogo(logo, logoUrl); + } +} + +void ListModel::logoFailed(QString logo) +{ + m_failedLogos.append(logo); + m_loadingLogos.removeAll(logo); +} + +void ListModel::logoLoaded(QString logo, QIcon out) +{ + m_loadingLogos.removeAll(logo); + m_logoMap.insert(logo, out); + + for (int i = 0; i < modpacks.size(); i++) { + if (modpacks[i].safeName == logo) { + emit dataChanged(createIndex(i, 0), createIndex(i, 0), { Qt::DecorationRole }); + } + } +} + +void ListModel::requestLogo(QString file, QString url) +{ + if (m_loadingLogos.contains(file) || m_failedLogos.contains(file)) { + return; + } + + MetaEntryPtr entry = APPLICATION->metacache()->resolveEntry("ATLauncherPacks", QString("logos/%1").arg(file)); + auto job = new NetJob(QString("ATLauncher Icon Download %1").arg(file), APPLICATION->network()); + job->setAskRetry(false); + job->addNetAction(Net::ApiDownload::makeCached(QUrl(url), entry)); + + auto fullPath = entry->getFullPath(); + connect(job, &NetJob::succeeded, this, [this, file, fullPath, job] { + job->deleteLater(); + emit logoLoaded(file, QIcon(fullPath)); + if (waitingCallbacks.contains(file)) { + waitingCallbacks.value(file)(fullPath); + } + }); + + connect(job, &NetJob::failed, this, [this, file, job] { + job->deleteLater(); + emit logoFailed(file); + }); + + job->start(); + + m_loadingLogos.append(file); +} + +} // namespace Atl diff --git a/launcher/ui/pages/modplatform/atlauncher/AtlListModel.h b/launcher/ui/pages/modplatform/atlauncher/AtlListModel.h new file mode 100644 index 0000000..51c5c78 --- /dev/null +++ b/launcher/ui/pages/modplatform/atlauncher/AtlListModel.h @@ -0,0 +1,66 @@ +/* + * Copyright 2020-2021 Jamie Mansfield + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +#include +#include +#include "net/NetJob.h" + +namespace Atl { + +using LogoMap = QMap; +using LogoCallback = std::function; + +class ListModel : public QAbstractListModel { + Q_OBJECT + + public: + ListModel(QObject* parent); + virtual ~ListModel(); + + int rowCount(const QModelIndex& parent) const override; + int columnCount(const QModelIndex& parent) const override; + QVariant data(const QModelIndex& index, int role) const override; + + void request(); + + void getLogo(const QString& logo, const QString& logoUrl, LogoCallback callback); + + private slots: + void requestFinished(QByteArray* responsePtr); + void requestFailed(QString reason); + + void logoFailed(QString logo); + void logoLoaded(QString logo, QIcon out); + + private: + void requestLogo(QString file, QString url); + + private: + QList modpacks; + + QStringList m_failedLogos; + QStringList m_loadingLogos; + LogoMap m_logoMap; + QMap waitingCallbacks; + + NetJob::Ptr jobPtr; +}; + +} // namespace Atl diff --git a/launcher/ui/pages/modplatform/atlauncher/AtlOptionalModDialog.cpp b/launcher/ui/pages/modplatform/atlauncher/AtlOptionalModDialog.cpp new file mode 100644 index 0000000..421060e --- /dev/null +++ b/launcher/ui/pages/modplatform/atlauncher/AtlOptionalModDialog.cpp @@ -0,0 +1,363 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2021 Jamie Mansfield + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "AtlOptionalModDialog.h" +#include "ui_AtlOptionalModDialog.h" + +#include +#include +#include "Application.h" +#include "BuildConfig.h" +#include "Json.h" +#include "modplatform/atlauncher/ATLShareCode.h" + +#include "net/ApiDownload.h" + +AtlOptionalModListModel::AtlOptionalModListModel(QWidget* parent, + const ATLauncher::PackVersion& version, + QList mods) + : QAbstractListModel(parent), m_version(version), m_mods(mods) +{ + // fill mod index + for (int i = 0; i < m_mods.size(); i++) { + auto mod = m_mods.at(i); + m_index[mod.name] = i; + } + + // set initial state + for (int i = 0; i < m_mods.size(); i++) { + auto mod = m_mods.at(i); + m_selection[mod.name] = false; + setMod(mod, i, mod.selected, false); + } +} + +QList AtlOptionalModListModel::getResult() +{ + QList result; + + for (const auto& mod : m_mods) { + if (m_selection[mod.name]) { + result.push_back(mod.name); + } + } + + return result; +} + +int AtlOptionalModListModel::rowCount(const QModelIndex& parent) const +{ + return parent.isValid() ? 0 : m_mods.size(); +} + +int AtlOptionalModListModel::columnCount(const QModelIndex& parent) const +{ + // Enabled, Name, Description + return parent.isValid() ? 0 : 3; +} + +QVariant AtlOptionalModListModel::data(const QModelIndex& index, int role) const +{ + auto row = index.row(); + auto mod = m_mods.at(row); + + if (role == Qt::DisplayRole) { + if (index.column() == NameColumn) { + return mod.name; + } + if (index.column() == DescriptionColumn) { + return mod.description; + } + } else if (role == Qt::ToolTipRole) { + if (index.column() == DescriptionColumn) { + return mod.description; + } + } else if (role == Qt::ForegroundRole) { + if (!mod.colour.isEmpty() && m_version.colours.contains(mod.colour)) { + return QColor(QString("#%1").arg(m_version.colours[mod.colour])); + } + } else if (role == Qt::CheckStateRole) { + if (index.column() == EnabledColumn) { + return m_selection[mod.name] ? Qt::Checked : Qt::Unchecked; + } + } + + return {}; +} + +bool AtlOptionalModListModel::setData(const QModelIndex& index, [[maybe_unused]] const QVariant& value, int role) +{ + if (role == Qt::CheckStateRole) { + auto row = index.row(); + auto mod = m_mods.at(row); + + toggleMod(mod, row); + return true; + } + + return false; +} + +QVariant AtlOptionalModListModel::headerData(int section, Qt::Orientation orientation, int role) const +{ + if (role == Qt::DisplayRole && orientation == Qt::Horizontal) { + switch (section) { + case EnabledColumn: + return QString(); + case NameColumn: + return QString("Name"); + case DescriptionColumn: + return QString("Description"); + } + } + + return {}; +} + +Qt::ItemFlags AtlOptionalModListModel::flags(const QModelIndex& index) const +{ + auto flags = QAbstractListModel::flags(index); + if (index.isValid() && index.column() == EnabledColumn) { + flags |= Qt::ItemIsUserCheckable; + } + return flags; +} + +void AtlOptionalModListModel::useShareCode(const QString& code) +{ + m_jobPtr.reset(new NetJob("Atl::Request", APPLICATION->network())); + auto url = QString(BuildConfig.ATL_API_BASE_URL + "share-codes/" + code); + auto [action, response] = Net::ApiDownload::makeByteArray(QUrl(url)); + m_jobPtr->addNetAction(action); + + connect(m_jobPtr.get(), &NetJob::succeeded, this, [this, response] { shareCodeSuccess(response); }); + connect(m_jobPtr.get(), &NetJob::failed, this, &AtlOptionalModListModel::shareCodeFailure); + + m_jobPtr->start(); +} + +void AtlOptionalModListModel::shareCodeSuccess(QByteArray* responsePtr) +{ + // NOTE(TheKodeToad): moving the response out to avoid it from being destroyed by jobPtr.reset() + QByteArray responseData = *std::move(responsePtr); + m_jobPtr.reset(); + + QJsonParseError parse_error{}; + auto doc = QJsonDocument::fromJson(responseData, &parse_error); + if (parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from ATL at" << parse_error.offset << "reason:" << parse_error.errorString(); + qWarning() << responseData; + return; + } + auto obj = doc.object(); + + ATLauncher::ShareCodeResponse response; + try { + ATLauncher::loadShareCodeResponse(response, obj); + } catch (const JSONValidationError& e) { + qDebug() << QString::fromUtf8(responseData); + qWarning() << "Error while reading response from ATLauncher:" << e.cause(); + return; + } + + if (response.error) { + // fixme: plumb in an error message + qWarning() << "ATLauncher API Response Error" << response.message; + return; + } + + // FIXME: verify pack and version, error if not matching. + + // Clear the current selection + for (const auto& mod : m_mods) { + m_selection[mod.name] = false; + } + + // Make the selections, as per the share code. + for (const auto& mod : response.data.mods) { + m_selection[mod.name] = mod.selected; + } + + emit dataChanged(AtlOptionalModListModel::index(0, EnabledColumn), AtlOptionalModListModel::index(m_mods.size() - 1, EnabledColumn)); +} + +void AtlOptionalModListModel::shareCodeFailure([[maybe_unused]] const QString& reason) +{ + m_jobPtr.reset(); + + // fixme: plumb in an error message +} + +void AtlOptionalModListModel::selectRecommended() +{ + for (const auto& mod : m_mods) { + m_selection[mod.name] = mod.recommended; + } + + emit dataChanged(AtlOptionalModListModel::index(0, EnabledColumn), AtlOptionalModListModel::index(m_mods.size() - 1, EnabledColumn)); +} + +void AtlOptionalModListModel::clearAll() +{ + for (const auto& mod : m_mods) { + m_selection[mod.name] = false; + } + + emit dataChanged(AtlOptionalModListModel::index(0, EnabledColumn), AtlOptionalModListModel::index(m_mods.size() - 1, EnabledColumn)); +} + +void AtlOptionalModListModel::toggleMod(const ATLauncher::VersionMod& mod, int index) +{ + auto enable = !m_selection[mod.name]; + + // If there is a warning for the mod, display that first (if we would be enabling the mod) + if (enable && !mod.warning.isEmpty() && m_version.warnings.contains(mod.warning)) { + auto message = QString("%1

    %2").arg(m_version.warnings[mod.warning], tr("Are you sure that you want to enable this mod?")); + + // fixme: avoid casting here + auto result = QMessageBox::warning((QWidget*)this->parent(), tr("Warning"), message, QMessageBox::Yes | QMessageBox::No); + if (result != QMessageBox::Yes) { + return; + } + } + + setMod(mod, index, enable); +} + +void AtlOptionalModListModel::setMod(const ATLauncher::VersionMod& mod, int index, bool enable, bool shouldEmit) +{ + if (m_selection[mod.name] == enable) + return; + + m_selection[mod.name] = enable; + + // disable other mods in the group, if applicable + if (enable && !mod.group.isEmpty()) { + for (int i = 0; i < m_mods.size(); i++) { + if (index == i) + continue; + auto other = m_mods.at(i); + + if (mod.group == other.group) { + setMod(other, i, false, shouldEmit); + } + } + } + + for (const auto& dependencyName : mod.depends) { + auto dependencyIndex = m_index[dependencyName]; + auto dependencyMod = m_mods.at(dependencyIndex); + + // enable/disable dependencies + if (enable) { + setMod(dependencyMod, dependencyIndex, true, shouldEmit); + } + + // if the dependency is 'effectively hidden', then track which mods + // depend on it - so we can efficiently disable it when no more dependents + // depend on it. + auto dependents = m_dependents[dependencyName]; + + if (enable) { + dependents.append(mod.name); + } else { + dependents.removeAll(mod.name); + + // if there are no longer any dependents, let's disable the mod + if (dependencyMod.effectively_hidden && dependents.isEmpty()) { + setMod(dependencyMod, dependencyIndex, false, shouldEmit); + } + } + } + + // disable mods that depend on this one, if disabling + if (!enable) { + auto dependents = m_dependents[mod.name]; + for (const auto& dependencyName : dependents) { + auto dependencyIndex = m_index[dependencyName]; + auto dependencyMod = m_mods.at(dependencyIndex); + + setMod(dependencyMod, dependencyIndex, false, shouldEmit); + } + } + + if (shouldEmit) { + emit dataChanged(AtlOptionalModListModel::index(index, EnabledColumn), AtlOptionalModListModel::index(index, EnabledColumn)); + } +} + +AtlOptionalModDialog::AtlOptionalModDialog(QWidget* parent, const ATLauncher::PackVersion& version, QList mods) + : QDialog(parent), ui(new Ui::AtlOptionalModDialog) +{ + ui->setupUi(this); + + listModel = new AtlOptionalModListModel(this, version, mods); + ui->treeView->setModel(listModel); + + ui->treeView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + ui->treeView->header()->setSectionResizeMode(AtlOptionalModListModel::NameColumn, QHeaderView::ResizeToContents); + ui->treeView->header()->setSectionResizeMode(AtlOptionalModListModel::DescriptionColumn, QHeaderView::Stretch); + + connect(ui->shareCodeButton, &QPushButton::clicked, this, &AtlOptionalModDialog::useShareCode); + connect(ui->selectRecommendedButton, &QPushButton::clicked, listModel, &AtlOptionalModListModel::selectRecommended); + connect(ui->clearAllButton, &QPushButton::clicked, listModel, &AtlOptionalModListModel::clearAll); + connect(ui->installButton, &QPushButton::clicked, this, &QDialog::accept); +} + +AtlOptionalModDialog::~AtlOptionalModDialog() +{ + delete ui; +} + +void AtlOptionalModDialog::useShareCode() +{ + bool ok; + auto shareCode = QInputDialog::getText(this, tr("Select a share code"), tr("Share code:"), QLineEdit::Normal, "", &ok); + + if (!ok) { + // If the user cancels the dialog, we don't need to show any error dialogs. + return; + } + + if (shareCode.isEmpty()) { + QMessageBox box; + box.setIcon(QMessageBox::Warning); + box.setText(tr("No share code specified!")); + box.exec(); + return; + } + + listModel->useShareCode(shareCode); +} diff --git a/launcher/ui/pages/modplatform/atlauncher/AtlOptionalModDialog.h b/launcher/ui/pages/modplatform/atlauncher/AtlOptionalModDialog.h new file mode 100644 index 0000000..8c36320 --- /dev/null +++ b/launcher/ui/pages/modplatform/atlauncher/AtlOptionalModDialog.h @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2021 Jamie Mansfield + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +#include "modplatform/atlauncher/ATLPackManifest.h" +#include "net/NetJob.h" + +namespace Ui { +class AtlOptionalModDialog; +} + +class AtlOptionalModListModel : public QAbstractListModel { + Q_OBJECT + + public: + enum Columns { + EnabledColumn = 0, + NameColumn, + DescriptionColumn, + }; + + AtlOptionalModListModel(QWidget* parent, const ATLauncher::PackVersion& version, QList mods); + + QList getResult(); + + int rowCount(const QModelIndex& parent) const override; + int columnCount(const QModelIndex& parent) const override; + + QVariant data(const QModelIndex& index, int role) const override; + bool setData(const QModelIndex& index, const QVariant& value, int role) override; + QVariant headerData(int section, Qt::Orientation orientation, int role) const override; + + Qt::ItemFlags flags(const QModelIndex& index) const override; + + void useShareCode(const QString& code); + + public slots: + void shareCodeSuccess(QByteArray* responsePtr); + void shareCodeFailure(const QString& reason); + + void selectRecommended(); + void clearAll(); + + private: + void toggleMod(const ATLauncher::VersionMod& mod, int index); + void setMod(const ATLauncher::VersionMod& mod, int index, bool enable, bool shouldEmit = true); + + private: + NetJob::Ptr m_jobPtr; + + ATLauncher::PackVersion m_version; + QList m_mods; + + QMap m_selection; + QMap m_index; + QMap> m_dependents; +}; + +class AtlOptionalModDialog : public QDialog { + Q_OBJECT + + public: + AtlOptionalModDialog(QWidget* parent, const ATLauncher::PackVersion& version, QList mods); + ~AtlOptionalModDialog() override; + + QList getResult() { return listModel->getResult(); } + + void useShareCode(); + + private: + Ui::AtlOptionalModDialog* ui; + + AtlOptionalModListModel* listModel; +}; diff --git a/launcher/ui/pages/modplatform/atlauncher/AtlOptionalModDialog.ui b/launcher/ui/pages/modplatform/atlauncher/AtlOptionalModDialog.ui new file mode 100644 index 0000000..717d0cc --- /dev/null +++ b/launcher/ui/pages/modplatform/atlauncher/AtlOptionalModDialog.ui @@ -0,0 +1,69 @@ + + + AtlOptionalModDialog + + + + 0 + 0 + 550 + 310 + + + + Select Mods To Install + + + + + + Install + + + true + + + + + + + true + + + Use Share Code + + + + + + + Select Recommended + + + + + + + Clear All + + + + + + + QAbstractItemView::ScrollPerPixel + + + + + + + + ModListView + QTreeView +
    ui/widgets/ModListView.h
    +
    +
    + + +
    diff --git a/launcher/ui/pages/modplatform/atlauncher/AtlPage.cpp b/launcher/ui/pages/modplatform/atlauncher/AtlPage.cpp new file mode 100644 index 0000000..14267bb --- /dev/null +++ b/launcher/ui/pages/modplatform/atlauncher/AtlPage.cpp @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2020-2021 Jamie Mansfield + * Copyright 2021 Philip T + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "AtlPage.h" +#include "ui/widgets/ProjectItem.h" +#include "ui_AtlPage.h" + +#include "BuildConfig.h" +#include "StringUtils.h" + +#include "AtlUserInteractionSupportImpl.h" +#include "modplatform/atlauncher/ATLPackInstallTask.h" +#include "ui/dialogs/NewInstanceDialog.h" + +#include + +AtlPage::AtlPage(NewInstanceDialog* dialog, QWidget* parent) : QWidget(parent), ui(new Ui::AtlPage), dialog(dialog) +{ + ui->setupUi(this); + + filterModel = new Atl::FilterModel(this); + listModel = new Atl::ListModel(this); + filterModel->setSourceModel(listModel); + ui->packView->setModel(filterModel); + ui->packView->setSortingEnabled(true); + + ui->packView->header()->hide(); + ui->packView->setIndentation(0); + + ui->versionSelectionBox->view()->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + ui->versionSelectionBox->view()->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); + ui->versionSelectionBox->view()->parentWidget()->setMaximumHeight(300); + + for (int i = 0; i < filterModel->getAvailableSortings().size(); i++) { + ui->sortByBox->addItem(filterModel->getAvailableSortings().keys().at(i)); + } + ui->sortByBox->setCurrentText(filterModel->translateCurrentSorting()); + + connect(ui->searchEdit, &QLineEdit::textChanged, this, &AtlPage::triggerSearch); + connect(ui->sortByBox, &QComboBox::currentTextChanged, this, &AtlPage::onSortingSelectionChanged); + connect(ui->packView->selectionModel(), &QItemSelectionModel::currentChanged, this, &AtlPage::onSelectionChanged); + connect(ui->versionSelectionBox, &QComboBox::currentTextChanged, this, &AtlPage::onVersionSelectionChanged); + + ui->packView->setItemDelegate(new ProjectItemDelegate(this)); +} + +AtlPage::~AtlPage() +{ + delete ui; +} + +bool AtlPage::shouldDisplay() const +{ + return true; +} + +void AtlPage::retranslate() +{ + ui->retranslateUi(this); +} + +void AtlPage::openedImpl() +{ + if (!initialized) { + listModel->request(); + initialized = true; + } + + suggestCurrent(); +} + +void AtlPage::suggestCurrent() +{ + if (!isOpened) { + return; + } + + if (selectedVersion.isEmpty()) { + dialog->setSuggestedPack(); + return; + } + + auto uiSupport = new AtlUserInteractionSupportImpl(this); + dialog->setSuggestedPack(selected.name, selectedVersion, new ATLauncher::PackInstallTask(uiSupport, selected.name, selectedVersion)); + + auto editedLogoName = "atl_" + selected.safeName; + auto url = QString(BuildConfig.ATL_DOWNLOAD_SERVER_URL + "launcher/images/%1").arg(selected.safeName); + listModel->getLogo(selected.safeName, url, + [this, editedLogoName](QString logo) { dialog->setSuggestedIconFromFile(logo, editedLogoName); }); +} + +void AtlPage::triggerSearch() +{ + filterModel->setSearchTerm(ui->searchEdit->text()); +} + +void AtlPage::onSortingSelectionChanged(QString sort) +{ + auto toSet = filterModel->getAvailableSortings().value(sort); + filterModel->setSorting(toSet); +} + +void AtlPage::onSelectionChanged(QModelIndex first, [[maybe_unused]] QModelIndex second) +{ + ui->versionSelectionBox->clear(); + + if (!first.isValid()) { + if (isOpened) { + dialog->setSuggestedPack(); + } + return; + } + + QVariant raw = filterModel->data(first, Qt::UserRole); + Q_ASSERT(raw.canConvert()); + selected = raw.value(); + + ui->packDescription->setHtml(StringUtils::htmlListPatch(selected.description.replace("\n", "
    "))); + + for (const auto& version : selected.versions) { + ui->versionSelectionBox->addItem(version.version); + } + + suggestCurrent(); +} + +void AtlPage::onVersionSelectionChanged(QString version) +{ + if (version.isNull() || version.isEmpty()) { + selectedVersion = ""; + return; + } + + selectedVersion = version; + suggestCurrent(); +} + +void AtlPage::setSearchTerm(QString term) +{ + ui->searchEdit->setText(term); +} + +QString AtlPage::getSerachTerm() const +{ + return ui->searchEdit->text(); +} diff --git a/launcher/ui/pages/modplatform/atlauncher/AtlPage.h b/launcher/ui/pages/modplatform/atlauncher/AtlPage.h new file mode 100644 index 0000000..8c8bf53 --- /dev/null +++ b/launcher/ui/pages/modplatform/atlauncher/AtlPage.h @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2020-2021 Jamie Mansfield + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "AtlFilterModel.h" +#include "AtlListModel.h" + +#include +#include + +#include "ui/pages/modplatform/ModpackProviderBasePage.h" + +namespace Ui { +class AtlPage; +} + +class NewInstanceDialog; + +class AtlPage : public QWidget, public ModpackProviderBasePage { + Q_OBJECT + + public: + explicit AtlPage(NewInstanceDialog* dialog, QWidget* parent = 0); + virtual ~AtlPage(); + virtual QString displayName() const override { return "ATLauncher"; } + virtual QIcon icon() const override { return QIcon::fromTheme("atlauncher"); } + virtual QString id() const override { return "atl"; } + virtual QString helpPage() const override { return "ATL-platform"; } + virtual bool shouldDisplay() const override; + void retranslate() override; + + void openedImpl() override; + + /** Programatically set the term in the search bar. */ + virtual void setSearchTerm(QString) override; + /** Get the current term in the search bar. */ + virtual QString getSerachTerm() const override; + + private: + void suggestCurrent(); + + private slots: + void triggerSearch(); + + void onSortingSelectionChanged(QString data); + + void onSelectionChanged(QModelIndex first, QModelIndex second); + void onVersionSelectionChanged(QString data); + + private: + Ui::AtlPage* ui = nullptr; + NewInstanceDialog* dialog = nullptr; + Atl::ListModel* listModel = nullptr; + Atl::FilterModel* filterModel = nullptr; + + ATLauncher::IndexedPack selected; + QString selectedVersion; + + bool initialized = false; +}; diff --git a/launcher/ui/pages/modplatform/atlauncher/AtlPage.ui b/launcher/ui/pages/modplatform/atlauncher/AtlPage.ui new file mode 100644 index 0000000..3fc0e55 --- /dev/null +++ b/launcher/ui/pages/modplatform/atlauncher/AtlPage.ui @@ -0,0 +1,103 @@ + + + AtlPage + + + + 0 + 0 + 837 + 685 + + + + + + + + true + + + + Warning: This is still a work in progress. If you run into issues with the imported modpack, it may be a bug. + + + Qt::AlignCenter + + + true + + + + + + + Search and filter... + + + true + + + + + + + + + true + + + + 96 + 48 + + + + QAbstractItemView::ScrollPerPixel + + + + + + + true + + + true + + + + + + + + + + + + + + Version selected: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + + + + + searchEdit + packView + packDescription + sortByBox + versionSelectionBox + + + + diff --git a/launcher/ui/pages/modplatform/atlauncher/AtlUserInteractionSupportImpl.cpp b/launcher/ui/pages/modplatform/atlauncher/AtlUserInteractionSupportImpl.cpp new file mode 100644 index 0000000..dc9a475 --- /dev/null +++ b/launcher/ui/pages/modplatform/atlauncher/AtlUserInteractionSupportImpl.cpp @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2020-2021 Jamie Mansfield + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "AtlUserInteractionSupportImpl.h" +#include + +#include "AtlOptionalModDialog.h" +#include "ui/dialogs/VersionSelectDialog.h" + +AtlUserInteractionSupportImpl::AtlUserInteractionSupportImpl(QWidget* parent) : m_parent(parent) {} + +std::optional> AtlUserInteractionSupportImpl::chooseOptionalMods(const ATLauncher::PackVersion& version, + QList mods) +{ + AtlOptionalModDialog optionalModDialog(m_parent, version, mods); + auto result = optionalModDialog.exec(); + if (result == QDialog::Rejected) { + return {}; + } + return optionalModDialog.getResult(); +} + +QString AtlUserInteractionSupportImpl::chooseVersion(Meta::VersionList::Ptr vlist, QString minecraftVersion) +{ + VersionSelectDialog vselect(vlist.get(), "Choose Version", m_parent, false); + if (minecraftVersion != nullptr) { + vselect.setExactFilter(BaseVersionList::ParentVersionRole, minecraftVersion); + vselect.setEmptyString(tr("No versions are currently available for Minecraft %1").arg(minecraftVersion)); + } else { + vselect.setEmptyString(tr("No versions are currently available")); + } + vselect.setEmptyErrorString(tr("Couldn't load or download the version lists!")); + + // select recommended build + for (int i = 0; i < vlist->versions().size(); i++) { + auto version = vlist->versions().at(i); + auto reqs = version->requiredSet(); + + // filter by minecraft version, if the loader depends on a certain version. + if (minecraftVersion != nullptr) { + auto iter = std::find_if(reqs.begin(), reqs.end(), [](const Meta::Require& req) { return req.uid == "net.minecraft"; }); + if (iter == reqs.end()) + continue; + if (iter->equalsVersion != minecraftVersion) + continue; + } + + // first recommended build we find, we use. + if (version->isRecommended()) { + vselect.setCurrentVersion(version->descriptor()); + break; + } + } + + vselect.exec(); + return vselect.selectedVersion()->descriptor(); +} + +void AtlUserInteractionSupportImpl::displayMessage(QString message) +{ + QMessageBox::information(m_parent, tr("Installing"), message); +} diff --git a/launcher/ui/pages/modplatform/atlauncher/AtlUserInteractionSupportImpl.h b/launcher/ui/pages/modplatform/atlauncher/AtlUserInteractionSupportImpl.h new file mode 100644 index 0000000..99f907a --- /dev/null +++ b/launcher/ui/pages/modplatform/atlauncher/AtlUserInteractionSupportImpl.h @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2020-2021 Jamie Mansfield + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +#include "modplatform/atlauncher/ATLPackInstallTask.h" + +class AtlUserInteractionSupportImpl : public QObject, public ATLauncher::UserInteractionSupport { + Q_OBJECT + + public: + AtlUserInteractionSupportImpl(QWidget* parent); + virtual ~AtlUserInteractionSupportImpl() = default; + + private: + QString chooseVersion(Meta::VersionList::Ptr vlist, QString minecraftVersion) override; + std::optional> chooseOptionalMods(const ATLauncher::PackVersion& version, QList mods) override; + void displayMessage(QString message) override; + + private: + QWidget* m_parent; +}; diff --git a/launcher/ui/pages/modplatform/flame/FlameModel.cpp b/launcher/ui/pages/modplatform/flame/FlameModel.cpp new file mode 100644 index 0000000..5d968d6 --- /dev/null +++ b/launcher/ui/pages/modplatform/flame/FlameModel.cpp @@ -0,0 +1,276 @@ +#include "FlameModel.h" +#include +#include "Application.h" +#include "modplatform/ModIndex.h" +#include "modplatform/ResourceAPI.h" +#include "modplatform/flame/FlameAPI.h" +#include "ui/widgets/ProjectItem.h" + +#include "net/ApiDownload.h" + +#include + +#include +#include + +namespace Flame { + +ListModel::ListModel(QObject* parent) : QAbstractListModel(parent) {} + +ListModel::~ListModel() {} + +int ListModel::rowCount(const QModelIndex& parent) const +{ + return parent.isValid() ? 0 : m_modpacks.size(); +} + +int ListModel::columnCount(const QModelIndex& parent) const +{ + return parent.isValid() ? 0 : 1; +} + +QVariant ListModel::data(const QModelIndex& index, int role) const +{ + int pos = index.row(); + if (pos >= m_modpacks.size() || pos < 0 || !index.isValid()) { + return QString("INVALID INDEX %1").arg(pos); + } + + auto pack = m_modpacks.at(pos); + switch (role) { + case Qt::ToolTipRole: { + if (pack->description.length() > 100) { + // some magic to prevent to long tooltips and replace html linebreaks + QString edit = pack->description.left(97); + edit = edit.left(edit.lastIndexOf("
    ")).left(edit.lastIndexOf(" ")).append("..."); + return edit; + } + return pack->description; + } + case Qt::DecorationRole: { + if (m_logoMap.contains(pack->logoName)) { + return (m_logoMap.value(pack->logoName)); + } + QIcon icon = QIcon::fromTheme("screenshot-placeholder"); + ((ListModel*)this)->requestLogo(pack->logoName, pack->logoUrl); + return icon; + } + case Qt::UserRole: { + QVariant v; + v.setValue(pack); + return v; + } + case Qt::SizeHintRole: + return QSize(0, 58); + case UserDataTypes::TITLE: + return pack->name; + case UserDataTypes::DESCRIPTION: + return pack->description; + case UserDataTypes::INSTALLED: + return false; + default: + break; + } + return QVariant(); +} + +bool ListModel::setData(const QModelIndex& index, const QVariant& value, [[maybe_unused]] int role) +{ + int pos = index.row(); + if (pos >= m_modpacks.size() || pos < 0 || !index.isValid()) + return false; + + m_modpacks[pos] = value.value(); + + return true; +} + +void ListModel::logoLoaded(QString logo, QIcon out) +{ + m_loadingLogos.removeAll(logo); + m_logoMap.insert(logo, out); + for (int i = 0; i < m_modpacks.size(); i++) { + if (m_modpacks[i]->logoName == logo) { + emit dataChanged(createIndex(i, 0), createIndex(i, 0), { Qt::DecorationRole }); + } + } +} + +void ListModel::logoFailed(QString logo) +{ + m_failedLogos.append(logo); + m_loadingLogos.removeAll(logo); +} + +void ListModel::requestLogo(QString logo, QString url) +{ + if (m_loadingLogos.contains(logo) || m_failedLogos.contains(logo)) { + return; + } + + MetaEntryPtr entry = APPLICATION->metacache()->resolveEntry("FlamePacks", QString("logos/%1").arg(logo)); + auto job = new NetJob(QString("Flame Icon Download %1").arg(logo), APPLICATION->network()); + job->setAskRetry(false); + job->addNetAction(Net::ApiDownload::makeCached(QUrl(url), entry)); + + auto fullPath = entry->getFullPath(); + connect(job, &NetJob::succeeded, this, [this, logo, fullPath, job] { + job->deleteLater(); + emit logoLoaded(logo, QIcon(fullPath)); + if (m_waitingCallbacks.contains(logo)) { + m_waitingCallbacks.value(logo)(fullPath); + } + }); + + connect(job, &NetJob::failed, this, [this, logo, job] { + job->deleteLater(); + emit logoFailed(logo); + }); + + job->start(); + + m_loadingLogos.append(logo); +} + +void ListModel::getLogo(const QString& logo, const QString& logoUrl, LogoCallback callback) +{ + if (m_logoMap.contains(logo)) { + callback(APPLICATION->metacache()->resolveEntry("FlamePacks", QString("logos/%1").arg(logo))->getFullPath()); + } else { + requestLogo(logo, logoUrl); + } +} + +Qt::ItemFlags ListModel::flags(const QModelIndex& index) const +{ + return QAbstractListModel::flags(index); +} + +bool ListModel::canFetchMore([[maybe_unused]] const QModelIndex& parent) const +{ + return m_searchState == CanPossiblyFetchMore; +} + +void ListModel::fetchMore(const QModelIndex& parent) +{ + if (parent.isValid()) + return; + if (m_nextSearchOffset == 0) { + qWarning() << "fetchMore with 0 offset is wrong..."; + return; + } + performPaginatedSearch(); +} + +void ListModel::performPaginatedSearch() +{ + static const FlameAPI api; + if (m_currentSearchTerm.startsWith("#")) { + auto projectId = m_currentSearchTerm.mid(1); + if (!projectId.isEmpty()) { + ResourceAPI::Callback callbacks; + + callbacks.on_fail = [this](QString reason, int) { searchRequestFailed(reason); }; + callbacks.on_succeed = [this](auto& pack) { searchRequestForOneSucceeded(pack); }; + callbacks.on_abort = [this] { + qCritical() << "Search task aborted by an unknown reason!"; + searchRequestFailed("Aborted"); + }; + auto project = std::make_shared(); + project->addonId = projectId; + if (auto job = api.getProjectInfo({ project }, std::move(callbacks)); job) { + m_jobPtr = job; + m_jobPtr->start(); + } + return; + } + } + ResourceAPI::SortingMethod sort{}; + sort.index = m_currentSort + 1; + + ResourceAPI::Callback> callbacks{}; + + callbacks.on_succeed = [this](auto& doc) { searchRequestFinished(doc); }; + callbacks.on_fail = [this](QString reason, int) { searchRequestFailed(reason); }; + callbacks.on_abort = [this] { + qCritical() << "Search task aborted by an unknown reason!"; + searchRequestFailed("Aborted"); + }; + + auto netJob = api.searchProjects({ ModPlatform::ResourceType::Modpack, m_nextSearchOffset, m_currentSearchTerm, sort, m_filter->loaders, + m_filter->versions, ModPlatform::Side::NoSide, m_filter->categoryIds, m_filter->openSource }, + std::move(callbacks)); + + m_jobPtr = netJob; + m_jobPtr->start(); +} + +void ListModel::searchWithTerm(const QString& term, int sort, std::shared_ptr filter, bool filterChanged) +{ + if (m_currentSearchTerm == term && m_currentSearchTerm.isNull() == term.isNull() && m_currentSort == sort && !filterChanged) { + return; + } + m_currentSearchTerm = term; + m_currentSort = sort; + m_filter = filter; + if (hasActiveSearchJob()) { + m_jobPtr->abort(); + m_searchState = ResetRequested; + return; + } + beginResetModel(); + m_modpacks.clear(); + endResetModel(); + m_searchState = None; + + m_nextSearchOffset = 0; + performPaginatedSearch(); +} + +void Flame::ListModel::searchRequestFinished(QList& newList) +{ + if (hasActiveSearchJob()) + return; + + if (newList.size() < 25) { + m_searchState = Finished; + } else { + m_nextSearchOffset += 25; + m_searchState = CanPossiblyFetchMore; + } + + // When you have a Qt build with assertions turned on, proceeding here will abort the application + if (newList.size() == 0) + return; + + beginInsertRows(QModelIndex(), m_modpacks.size(), m_modpacks.size() + newList.size() - 1); + m_modpacks.append(newList); + endInsertRows(); +} + +void Flame::ListModel::searchRequestForOneSucceeded(ModPlatform::IndexedPack::Ptr pack) +{ + m_jobPtr.reset(); + + beginInsertRows(QModelIndex(), m_modpacks.size(), m_modpacks.size() + 1); + m_modpacks.append(pack); + endInsertRows(); +} + +void Flame::ListModel::searchRequestFailed(QString reason) +{ + m_jobPtr.reset(); + + if (m_searchState == ResetRequested) { + beginResetModel(); + m_modpacks.clear(); + endResetModel(); + + m_nextSearchOffset = 0; + performPaginatedSearch(); + } else { + m_searchState = Finished; + } +} + +} // namespace Flame diff --git a/launcher/ui/pages/modplatform/flame/FlameModel.h b/launcher/ui/pages/modplatform/flame/FlameModel.h new file mode 100644 index 0000000..92ff098 --- /dev/null +++ b/launcher/ui/pages/modplatform/flame/FlameModel.h @@ -0,0 +1,73 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include "ui/widgets/ModFilterWidget.h" + +namespace Flame { + +using LogoMap = QMap; +using LogoCallback = std::function; + +class ListModel : public QAbstractListModel { + Q_OBJECT + + public: + ListModel(QObject* parent); + virtual ~ListModel(); + + int rowCount(const QModelIndex& parent) const override; + int columnCount(const QModelIndex& parent) const override; + QVariant data(const QModelIndex& index, int role) const override; + bool setData(const QModelIndex& index, const QVariant& value, int role) override; + Qt::ItemFlags flags(const QModelIndex& index) const override; + bool canFetchMore(const QModelIndex& parent) const override; + void fetchMore(const QModelIndex& parent) override; + + void getLogo(const QString& logo, const QString& logoUrl, LogoCallback callback); + void searchWithTerm(const QString& term, int sort, std::shared_ptr filter, bool filterChanged); + + bool hasActiveSearchJob() const { return m_jobPtr && m_jobPtr->isRunning(); } + Task::Ptr activeSearchJob() { return hasActiveSearchJob() ? m_jobPtr : nullptr; } + + private slots: + void performPaginatedSearch(); + + void logoFailed(QString logo); + void logoLoaded(QString logo, QIcon out); + + void searchRequestFinished(QList&); + void searchRequestFailed(QString reason); + void searchRequestForOneSucceeded(ModPlatform::IndexedPack::Ptr); + + private: + void requestLogo(QString file, QString url); + + private: + QList m_modpacks; + QStringList m_failedLogos; + QStringList m_loadingLogos; + LogoMap m_logoMap; + QMap m_waitingCallbacks; + + QString m_currentSearchTerm; + int m_currentSort = 0; + std::shared_ptr m_filter; + int m_nextSearchOffset = 0; + enum SearchState { None, CanPossiblyFetchMore, ResetRequested, Finished } m_searchState = None; + Task::Ptr m_jobPtr; +}; + +} // namespace Flame diff --git a/launcher/ui/pages/modplatform/flame/FlamePage.cpp b/launcher/ui/pages/modplatform/flame/FlamePage.cpp new file mode 100644 index 0000000..3361338 --- /dev/null +++ b/launcher/ui/pages/modplatform/flame/FlamePage.cpp @@ -0,0 +1,340 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "FlamePage.h" +#include "Version.h" +#include "modplatform/ModIndex.h" +#include "modplatform/ResourceAPI.h" +#include "ui/dialogs/CustomMessageBox.h" +#include "ui/widgets/ModFilterWidget.h" +#include "ui_FlamePage.h" + +#include +#include + +#include "FlameModel.h" +#include "InstanceImportTask.h" +#include "StringUtils.h" +#include "modplatform/flame/FlameAPI.h" +#include "ui/dialogs/NewInstanceDialog.h" +#include "ui/widgets/ProjectItem.h" + +static FlameAPI api; + +FlamePage::FlamePage(NewInstanceDialog* dialog, QWidget* parent) + : QWidget(parent), m_ui(new Ui::FlamePage), m_dialog(dialog), m_fetch_progress(this, false) +{ + m_ui->setupUi(this); + m_ui->searchEdit->installEventFilter(this); + m_listModel = new Flame::ListModel(this); + m_ui->packView->setModel(m_listModel); + + m_ui->versionSelectionBox->view()->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + m_ui->versionSelectionBox->view()->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); + m_ui->versionSelectionBox->view()->parentWidget()->setMaximumHeight(300); + + m_search_timer.setTimerType(Qt::TimerType::CoarseTimer); + m_search_timer.setSingleShot(true); + + connect(&m_search_timer, &QTimer::timeout, this, &FlamePage::triggerSearch); + + m_fetch_progress.hideIfInactive(true); + m_fetch_progress.setFixedHeight(24); + m_fetch_progress.progressFormat(""); + + m_ui->verticalLayout->insertWidget(2, &m_fetch_progress); + + // index is used to set the sorting with the curseforge api + m_ui->sortByBox->addItem(tr("Sort by Featured")); + m_ui->sortByBox->addItem(tr("Sort by Popularity")); + m_ui->sortByBox->addItem(tr("Sort by Last Updated")); + m_ui->sortByBox->addItem(tr("Sort by Name")); + m_ui->sortByBox->addItem(tr("Sort by Author")); + m_ui->sortByBox->addItem(tr("Sort by Total Downloads")); + + connect(m_ui->sortByBox, &QComboBox::currentIndexChanged, this, &FlamePage::triggerSearch); + connect(m_ui->packView->selectionModel(), &QItemSelectionModel::currentChanged, this, &FlamePage::onSelectionChanged); + connect(m_ui->versionSelectionBox, &QComboBox::currentIndexChanged, this, &FlamePage::onVersionSelectionChanged); + + m_ui->packView->setItemDelegate(new ProjectItemDelegate(this)); + m_ui->packDescription->setMetaEntry("FlamePacks"); + createFilterWidget(); +} + +FlamePage::~FlamePage() +{ + delete m_ui; +} + +bool FlamePage::eventFilter(QObject* watched, QEvent* event) +{ + if (watched == m_ui->searchEdit && event->type() == QEvent::KeyPress) { + QKeyEvent* keyEvent = static_cast(event); + if (keyEvent->key() == Qt::Key_Return) { + triggerSearch(); + keyEvent->accept(); + return true; + } else { + if (m_search_timer.isActive()) + m_search_timer.stop(); + + m_search_timer.start(350); + } + } + return QWidget::eventFilter(watched, event); +} + +bool FlamePage::shouldDisplay() const +{ + return true; +} + +void FlamePage::retranslate() +{ + m_ui->retranslateUi(this); +} + +void FlamePage::openedImpl() +{ + suggestCurrent(); + triggerSearch(); +} + +void FlamePage::triggerSearch() +{ + m_ui->packView->selectionModel()->setCurrentIndex({}, QItemSelectionModel::SelectionFlag::ClearAndSelect); + m_ui->packView->clearSelection(); + m_ui->packDescription->clear(); + m_ui->versionSelectionBox->clear(); + bool filterChanged = m_filterWidget->changed(); + m_listModel->searchWithTerm(m_ui->searchEdit->text(), m_ui->sortByBox->currentIndex(), m_filterWidget->getFilter(), filterChanged); + m_fetch_progress.watch(m_listModel->activeSearchJob().get()); +} + +void FlamePage::onSelectionChanged(QModelIndex curr, [[maybe_unused]] QModelIndex prev) +{ + m_ui->versionSelectionBox->clear(); + + if (!curr.isValid()) { + if (isOpened) { + m_dialog->setSuggestedPack(); + } + return; + } + + m_current = m_listModel->data(curr, Qt::UserRole).value(); + + if (!m_current->versionsLoaded || m_filterWidget->changed()) { + qDebug() << "Loading flame modpack versions"; + + ResourceAPI::Callback > callbacks{}; + + auto addonId = m_current->addonId; + // Use default if no callbacks are set + callbacks.on_succeed = [this, curr, addonId](auto& doc) { + if (addonId != m_current->addonId) { + return; // wrong request + } + + m_current->versions = doc; + m_current->versionsLoaded = true; + auto pred = [this](const ModPlatform::IndexedVersion& v) { + if (auto filter = m_filterWidget->getFilter()) + return !filter->checkModpackFilters(v); + return false; + }; +#if QT_VERSION >= QT_VERSION_CHECK(6, 1, 0) + m_current->versions.removeIf(pred); +#else + for (auto it = m_current->versions.begin(); it != m_current->versions.end();) + if (pred(*it)) + it = m_current->versions.erase(it); + else + ++it; +#endif + for (auto version : m_current->versions) { + m_ui->versionSelectionBox->addItem(version.getVersionDisplayString(), QVariant(version.downloadUrl)); + } + + QVariant current_updated; + current_updated.setValue(m_current); + + if (!m_listModel->setData(curr, current_updated, Qt::UserRole)) + qWarning() << "Failed to cache versions for the current pack!"; + + // TODO: Check whether it's a connection issue or the project disabled 3rd-party distribution. + if (m_current->versionsLoaded && m_ui->versionSelectionBox->count() < 1) { + m_ui->versionSelectionBox->addItem(tr("No version is available!"), -1); + } + suggestCurrent(); + }; + callbacks.on_fail = [this](QString reason, int) { + CustomMessageBox::selectable(this, tr("Error"), reason, QMessageBox::Critical)->exec(); + }; + + auto netJob = api.getProjectVersions({ m_current, {}, {}, ModPlatform::ResourceType::Modpack }, std::move(callbacks)); + + m_job = netJob; + netJob->start(); + } else { + for (auto version : m_current->versions) { + m_ui->versionSelectionBox->addItem(version.version, QVariant(version.downloadUrl)); + } + + suggestCurrent(); + } + + // TODO: Check whether it's a connection issue or the project disabled 3rd-party distribution. + if (m_current->versionsLoaded && m_ui->versionSelectionBox->count() < 1) { + m_ui->versionSelectionBox->addItem(tr("No version is available!"), -1); + } + + updateUi(); +} + +void FlamePage::suggestCurrent() +{ + if (!isOpened) { + return; + } + + if (m_selected_version_index == -1) { + m_dialog->setSuggestedPack(); + return; + } + + auto version = m_current->versions.at(m_selected_version_index); + + QMap extra_info; + extra_info.insert("pack_id", m_current->addonId.toString()); + extra_info.insert("pack_version_id", version.fileId.toString()); + + m_dialog->setSuggestedPack(m_current->name, new InstanceImportTask(version.downloadUrl, this, std::move(extra_info))); + QString editedLogoName = "curseforge_" + m_current->logoName; + m_listModel->getLogo(m_current->logoName, m_current->logoUrl, + [this, editedLogoName](QString logo) { m_dialog->setSuggestedIconFromFile(logo, editedLogoName); }); +} + +void FlamePage::onVersionSelectionChanged(int index) +{ + bool is_blocked = false; + m_ui->versionSelectionBox->itemData(index).toInt(&is_blocked); + + if (index == -1 || is_blocked) { + m_selected_version_index = -1; + return; + } + + m_selected_version_index = index; + + Q_ASSERT(m_current->versions.at(m_selected_version_index).downloadUrl == m_ui->versionSelectionBox->currentData().toString()); + + suggestCurrent(); +} + +void FlamePage::updateUi() +{ + QString text = ""; + QString name = m_current->name; + + if (m_current->websiteUrl.isEmpty()) + text = name; + else + text = "websiteUrl + "\">" + name + ""; + if (!m_current->authors.empty()) { + auto authorToStr = [](ModPlatform::ModpackAuthor& author) { + if (author.url.isEmpty()) { + return author.name; + } + return QString("%2").arg(author.url, author.name); + }; + QStringList authorStrs; + for (auto& author : m_current->authors) { + authorStrs.push_back(authorToStr(author)); + } + text += "
    " + tr(" by ") + authorStrs.join(", "); + } + + if (m_current->extraDataLoaded) { + if (!m_current->extraData.issuesUrl.isEmpty() || !m_current->extraData.sourceUrl.isEmpty() || + !m_current->extraData.wikiUrl.isEmpty()) { + text += "

    " + tr("External links:") + "
    "; + } + + if (!m_current->extraData.issuesUrl.isEmpty()) + text += "- " + tr("Issues: %1").arg(m_current->extraData.issuesUrl) + "
    "; + if (!m_current->extraData.wikiUrl.isEmpty()) + text += "- " + tr("Wiki: %1").arg(m_current->extraData.wikiUrl) + "
    "; + if (!m_current->extraData.sourceUrl.isEmpty()) + text += "- " + tr("Source code: %1").arg(m_current->extraData.sourceUrl) + "
    "; + } + + text += "
    "; + text += api.getModDescription(m_current->addonId.toInt()).toUtf8(); + + m_ui->packDescription->setHtml(StringUtils::htmlListPatch(text + m_current->description)); + m_ui->packDescription->flush(); +} +QString FlamePage::getSerachTerm() const +{ + return m_ui->searchEdit->text(); +} + +void FlamePage::setSearchTerm(QString term) +{ + m_ui->searchEdit->setText(term); +} + +void FlamePage::createFilterWidget() +{ + auto widget = ModFilterWidget::create(nullptr, false); + m_filterWidget.swap(widget); + auto old = m_ui->splitter->replaceWidget(0, m_filterWidget.get()); + // because we replaced the widget we also need to delete it + if (old) { + delete old; + } + + connect(m_ui->filterButton, &QPushButton::clicked, this, [this] { m_filterWidget->setHidden(!m_filterWidget->isHidden()); }); + + connect(m_filterWidget.get(), &ModFilterWidget::filterChanged, this, &FlamePage::triggerSearch); + auto [task, response] = FlameAPI::getCategories(ModPlatform::ResourceType::Modpack); + m_categoriesTask = task; + connect(m_categoriesTask.get(), &Task::succeeded, [this, response]() { + auto categories = FlameAPI::loadModCategories(*response); + m_filterWidget->setCategories(categories); + }); + m_categoriesTask->start(); +} diff --git a/launcher/ui/pages/modplatform/flame/FlamePage.h b/launcher/ui/pages/modplatform/flame/FlamePage.h new file mode 100644 index 0000000..eb76322 --- /dev/null +++ b/launcher/ui/pages/modplatform/flame/FlamePage.h @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +#include +#include "modplatform/ModIndex.h" +#include "ui/pages/modplatform/ModpackProviderBasePage.h" +#include "ui/widgets/ModFilterWidget.h" +#include "ui/widgets/ProgressWidget.h" + +namespace Ui { +class FlamePage; +} + +class NewInstanceDialog; + +namespace Flame { +class ListModel; +} + +class FlamePage : public QWidget, public ModpackProviderBasePage { + Q_OBJECT + + public: + explicit FlamePage(NewInstanceDialog* dialog, QWidget* parent = 0); + virtual ~FlamePage(); + virtual QString displayName() const override { return "CurseForge"; } + virtual QIcon icon() const override { return QIcon::fromTheme("flame"); } + virtual QString id() const override { return "flame"; } + virtual QString helpPage() const override { return "Flame-platform"; } + virtual bool shouldDisplay() const override; + void retranslate() override; + + void updateUi(); + + void openedImpl() override; + + bool eventFilter(QObject* watched, QEvent* event) override; + + /** Programatically set the term in the search bar. */ + virtual void setSearchTerm(QString) override; + /** Get the current term in the search bar. */ + virtual QString getSerachTerm() const override; + + private: + void suggestCurrent(); + + private slots: + void triggerSearch(); + void onSelectionChanged(QModelIndex first, QModelIndex second); + void onVersionSelectionChanged(int index); + void createFilterWidget(); + + private: + Ui::FlamePage* m_ui = nullptr; + NewInstanceDialog* m_dialog = nullptr; + Flame::ListModel* m_listModel = nullptr; + ModPlatform::IndexedPack::Ptr m_current; + + int m_selected_version_index = -1; + + ProgressWidget m_fetch_progress; + + // Used to do instant searching with a delay to cache quick changes + QTimer m_search_timer; + + std::unique_ptr m_filterWidget; + Task::Ptr m_categoriesTask; + Task::Ptr m_job; +}; diff --git a/launcher/ui/pages/modplatform/flame/FlamePage.ui b/launcher/ui/pages/modplatform/flame/FlamePage.ui new file mode 100644 index 0000000..5d72f75 --- /dev/null +++ b/launcher/ui/pages/modplatform/flame/FlamePage.ui @@ -0,0 +1,126 @@ + + + FlamePage + + + + 0 + 0 + 800 + 600 + + + + + + + + true + + + + Note: CurseForge allows creators to block access to third-party tools like Prism Launcher. As such, you may need to manually download some mods to be able to install a modpack. + + + Qt::AlignCenter + + + true + + + + + + + + + Filter options + + + + + + + Search and filter... + + + + + + + + + + 0 + 0 + + + + Qt::Horizontal + + + + + Qt::ScrollBarAlwaysOff + + + true + + + + 48 + 48 + + + + QAbstractItemView::ScrollPerPixel + + + + + true + + + true + + + + + + + + + + + + + Version selected: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + + + + + + ProjectDescriptionPage + QTextBrowser +
    ui/widgets/ProjectDescriptionPage.h
    +
    +
    + + packView + packDescription + sortByBox + versionSelectionBox + + + +
    diff --git a/launcher/ui/pages/modplatform/flame/FlameResourceModels.cpp b/launcher/ui/pages/modplatform/flame/FlameResourceModels.cpp new file mode 100644 index 0000000..a40e6d5 --- /dev/null +++ b/launcher/ui/pages/modplatform/flame/FlameResourceModels.cpp @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: 2023 flowln +// +// SPDX-License-Identifier: GPL-3.0-only + +#include "FlameResourceModels.h" + +#include "Json.h" + +#include "minecraft/PackProfile.h" +#include "modplatform/flame/FlameAPI.h" +#include "ui/pages/modplatform/flame/FlameResourcePages.h" + +namespace ResourceDownload { + +static bool isOptedOut(const ModPlatform::IndexedVersion& ver) +{ + return ver.downloadUrl.isEmpty(); +} + +FlameTexturePackModel::FlameTexturePackModel(const BaseInstance& base) + : TexturePackResourceModel(base, new FlameAPI, Flame::debugName(), Flame::metaEntryBase()) +{} + +ResourceAPI::SearchArgs FlameTexturePackModel::createSearchArguments() +{ + auto args = TexturePackResourceModel::createSearchArguments(); + + auto profile = static_cast(m_base_instance).getPackProfile(); + QString instance_minecraft_version = profile->getComponentVersion("net.minecraft"); + + // Bypass the texture pack logic, because we can't do multiple versions in the API query + args.versions = { instance_minecraft_version }; + + return args; +} + +ResourceAPI::VersionSearchArgs FlameTexturePackModel::createVersionsArguments(const QModelIndex& entry) +{ + auto args = TexturePackResourceModel::createVersionsArguments(entry); + + // Bypass the texture pack logic, because we can't do multiple versions in the API query + args.mcVersions = {}; + + return args; +} + +bool FlameTexturePackModel::optedOut(const ModPlatform::IndexedVersion& ver) const +{ + return isOptedOut(ver); +} + +} // namespace ResourceDownload diff --git a/launcher/ui/pages/modplatform/flame/FlameResourceModels.h b/launcher/ui/pages/modplatform/flame/FlameResourceModels.h new file mode 100644 index 0000000..76062f8 --- /dev/null +++ b/launcher/ui/pages/modplatform/flame/FlameResourceModels.h @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2023 flowln +// +// SPDX-License-Identifier: GPL-3.0-only + +#pragma once + +#include "ui/pages/modplatform/ModModel.h" +#include "ui/pages/modplatform/flame/FlameResourcePages.h" + +namespace ResourceDownload { + +class FlameTexturePackModel : public TexturePackResourceModel { + Q_OBJECT + + public: + FlameTexturePackModel(const BaseInstance&); + ~FlameTexturePackModel() override = default; + + bool optedOut(const ModPlatform::IndexedVersion& ver) const override; + + private: + QString debugName() const override { return Flame::debugName() + " (Model)"; } + QString metaEntryBase() const override { return Flame::metaEntryBase(); } + + ResourceAPI::SearchArgs createSearchArguments() override; + ResourceAPI::VersionSearchArgs createVersionsArguments(const QModelIndex&) override; +}; + +} // namespace ResourceDownload diff --git a/launcher/ui/pages/modplatform/flame/FlameResourcePages.cpp b/launcher/ui/pages/modplatform/flame/FlameResourcePages.cpp new file mode 100644 index 0000000..99a57f2 --- /dev/null +++ b/launcher/ui/pages/modplatform/flame/FlameResourcePages.cpp @@ -0,0 +1,258 @@ +// SPDX-FileCopyrightText: 2023 flowln +// +// SPDX-License-Identifier: GPL-3.0-only AND Apache-2.0 +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2022 TheKodeToad + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "FlameResourcePages.h" +#include +#include +#include "modplatform/flame/FlameAPI.h" +#include "ui_ResourcePage.h" + +#include "FlameResourceModels.h" +#include "ui/dialogs/ResourceDownloadDialog.h" + +namespace ResourceDownload { + +FlameModPage::FlameModPage(ModDownloadDialog* dialog, BaseInstance& instance) : ModPage(dialog, instance) +{ + m_model = new ModModel(instance, new FlameAPI(), Flame::debugName(), Flame::metaEntryBase()); + m_ui->packView->setModel(m_model); + + addSortings(); + + // sometimes Qt just ignores virtual slots and doesn't work as intended it seems, + // so it's best not to connect them in the parent's contructor... + connect(m_ui->sortByBox, &QComboBox::currentIndexChanged, this, &FlameModPage::triggerSearch); + connect(m_ui->packView->selectionModel(), &QItemSelectionModel::currentChanged, this, &FlameModPage::onSelectionChanged); + connect(m_ui->versionSelectionBox, &QComboBox::currentIndexChanged, this, &FlameModPage::onVersionSelectionChanged); + connect(m_ui->resourceSelectionButton, &QPushButton::clicked, this, &FlameModPage::onResourceSelected); + + m_ui->packDescription->setMetaEntry(metaEntryBase()); +} + +void FlameModPage::openUrl(const QUrl& url) +{ + if (url.scheme().isEmpty()) { + QString query = url.query(QUrl::FullyDecoded); + + if (query.startsWith("remoteUrl=")) { + // attempt to resolve url from warning page + query.remove(0, 10); + ModPage::openUrl({ QUrl::fromPercentEncoding(query.toUtf8()) }); // double decoding is necessary + return; + } + } + + ModPage::openUrl(url); +} + +FlameResourcePackPage::FlameResourcePackPage(ResourcePackDownloadDialog* dialog, BaseInstance& instance) + : ResourcePackResourcePage(dialog, instance) +{ + m_model = new ResourcePackResourceModel(instance, new FlameAPI(), Flame::debugName(), Flame::metaEntryBase()); + m_ui->packView->setModel(m_model); + + addSortings(); + + // sometimes Qt just ignores virtual slots and doesn't work as intended it seems, + // so it's best not to connect them in the parent's contructor... + connect(m_ui->sortByBox, &QComboBox::currentIndexChanged, this, &FlameResourcePackPage::triggerSearch); + connect(m_ui->packView->selectionModel(), &QItemSelectionModel::currentChanged, this, &FlameResourcePackPage::onSelectionChanged); + connect(m_ui->versionSelectionBox, &QComboBox::currentIndexChanged, this, &FlameResourcePackPage::onVersionSelectionChanged); + connect(m_ui->resourceSelectionButton, &QPushButton::clicked, this, &FlameResourcePackPage::onResourceSelected); + + m_ui->packDescription->setMetaEntry(metaEntryBase()); +} + +void FlameResourcePackPage::openUrl(const QUrl& url) +{ + if (url.scheme().isEmpty()) { + QString query = url.query(QUrl::FullyDecoded); + + if (query.startsWith("remoteUrl=")) { + // attempt to resolve url from warning page + query.remove(0, 10); + ResourcePackResourcePage::openUrl({ QUrl::fromPercentEncoding(query.toUtf8()) }); // double decoding is necessary + return; + } + } + + ResourcePackResourcePage::openUrl(url); +} + +FlameTexturePackPage::FlameTexturePackPage(TexturePackDownloadDialog* dialog, BaseInstance& instance) + : TexturePackResourcePage(dialog, instance) +{ + m_model = new FlameTexturePackModel(instance); + m_ui->packView->setModel(m_model); + + addSortings(); + + // sometimes Qt just ignores virtual slots and doesn't work as intended it seems, + // so it's best not to connect them in the parent's contructor... + connect(m_ui->sortByBox, &QComboBox::currentIndexChanged, this, &FlameTexturePackPage::triggerSearch); + connect(m_ui->packView->selectionModel(), &QItemSelectionModel::currentChanged, this, &FlameTexturePackPage::onSelectionChanged); + connect(m_ui->versionSelectionBox, &QComboBox::currentIndexChanged, this, &FlameTexturePackPage::onVersionSelectionChanged); + connect(m_ui->resourceSelectionButton, &QPushButton::clicked, this, &FlameTexturePackPage::onResourceSelected); + + m_ui->packDescription->setMetaEntry(metaEntryBase()); +} + +void FlameTexturePackPage::openUrl(const QUrl& url) +{ + if (url.scheme().isEmpty()) { + QString query = url.query(QUrl::FullyDecoded); + + if (query.startsWith("remoteUrl=")) { + // attempt to resolve url from warning page + query.remove(0, 10); + ResourcePackResourcePage::openUrl({ QUrl::fromPercentEncoding(query.toUtf8()) }); // double decoding is necessary + return; + } + } + + TexturePackResourcePage::openUrl(url); +} + +void FlameDataPackPage::openUrl(const QUrl& url) +{ + if (url.scheme().isEmpty()) { + QString query = url.query(QUrl::FullyDecoded); + + if (query.startsWith("remoteUrl=")) { + // attempt to resolve url from warning page + query.remove(0, 10); + DataPackResourcePage::openUrl({ QUrl::fromPercentEncoding(query.toUtf8()) }); // double decoding is necessary + return; + } + } + + DataPackResourcePage::openUrl(url); +} + +FlameShaderPackPage::FlameShaderPackPage(ShaderPackDownloadDialog* dialog, BaseInstance& instance) + : ShaderPackResourcePage(dialog, instance) +{ + m_model = new ShaderPackResourceModel(instance, new FlameAPI(), Flame::debugName(), Flame::metaEntryBase()); + m_ui->packView->setModel(m_model); + + addSortings(); + + // sometimes Qt just ignores virtual slots and doesn't work as intended it seems, + // so it's best not to connect them in the parent's constructor... + connect(m_ui->sortByBox, &QComboBox::currentIndexChanged, this, &FlameShaderPackPage::triggerSearch); + connect(m_ui->packView->selectionModel(), &QItemSelectionModel::currentChanged, this, &FlameShaderPackPage::onSelectionChanged); + connect(m_ui->versionSelectionBox, &QComboBox::currentIndexChanged, this, &FlameShaderPackPage::onVersionSelectionChanged); + connect(m_ui->resourceSelectionButton, &QPushButton::clicked, this, &FlameShaderPackPage::onResourceSelected); + + m_ui->packDescription->setMetaEntry(metaEntryBase()); +} + +FlameDataPackPage::FlameDataPackPage(DataPackDownloadDialog* dialog, BaseInstance& instance) : DataPackResourcePage(dialog, instance) +{ + m_model = new DataPackResourceModel(instance, new FlameAPI(), Flame::debugName(), Flame::metaEntryBase()); + m_ui->packView->setModel(m_model); + + addSortings(); + + // sometimes Qt just ignores virtual slots and doesn't work as intended it seems, + // so it's best not to connect them in the parent's constructor... + connect(m_ui->sortByBox, &QComboBox::currentIndexChanged, this, &FlameDataPackPage::triggerSearch); + connect(m_ui->packView->selectionModel(), &QItemSelectionModel::currentChanged, this, &FlameDataPackPage::onSelectionChanged); + connect(m_ui->versionSelectionBox, &QComboBox::currentIndexChanged, this, &FlameDataPackPage::onVersionSelectionChanged); + connect(m_ui->resourceSelectionButton, &QPushButton::clicked, this, &FlameDataPackPage::onResourceSelected); + + m_ui->packDescription->setMetaEntry(metaEntryBase()); +} + +void FlameShaderPackPage::openUrl(const QUrl& url) +{ + if (url.scheme().isEmpty()) { + QString query = url.query(QUrl::FullyDecoded); + + if (query.startsWith("remoteUrl=")) { + // attempt to resolve url from warning page + query.remove(0, 10); + ShaderPackResourcePage::openUrl({ QUrl::fromPercentEncoding(query.toUtf8()) }); // double decoding is necessary + return; + } + } + + ShaderPackResourcePage::openUrl(url); +} + +// I don't know why, but doing this on the parent class makes it so that +// other mod providers start loading before being selected, at least with +// my Qt, so we need to implement this in every derived class... +auto FlameModPage::shouldDisplay() const -> bool +{ + return true; +} +auto FlameResourcePackPage::shouldDisplay() const -> bool +{ + return true; +} +auto FlameTexturePackPage::shouldDisplay() const -> bool +{ + return true; +} +auto FlameShaderPackPage::shouldDisplay() const -> bool +{ + return true; +} +auto FlameDataPackPage::shouldDisplay() const -> bool +{ + return true; +} + +std::unique_ptr FlameModPage::createFilterWidget() +{ + return ModFilterWidget::create(&static_cast(m_baseInstance), false); +} + +void FlameModPage::prepareProviderCategories() +{ + auto [task, response] = FlameAPI::getModCategories(); + m_categoriesTask = task; + connect(m_categoriesTask.get(), &Task::succeeded, [this, response]() { + auto categories = FlameAPI::loadModCategories(*response); + m_filter_widget->setCategories(categories); + }); + m_categoriesTask->start(); +}; +} // namespace ResourceDownload diff --git a/launcher/ui/pages/modplatform/flame/FlameResourcePages.h b/launcher/ui/pages/modplatform/flame/FlameResourcePages.h new file mode 100644 index 0000000..d4b697a --- /dev/null +++ b/launcher/ui/pages/modplatform/flame/FlameResourcePages.h @@ -0,0 +1,208 @@ +// SPDX-FileCopyrightText: 2023 flowln +// +// SPDX-License-Identifier: GPL-3.0-only AND Apache-2.0 +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2022 TheKodeToad + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +#include "modplatform/ResourceAPI.h" + +#include "ui/pages/modplatform/ModPage.h" +#include "ui/pages/modplatform/ResourcePackPage.h" +#include "ui/pages/modplatform/ShaderPackPage.h" +#include "ui/pages/modplatform/TexturePackPage.h" + +namespace ResourceDownload { + +namespace Flame { +static inline QString displayName() +{ + return "CurseForge"; +} +static inline QIcon icon() +{ + return QIcon::fromTheme("flame"); +} +static inline QString id() +{ + return "curseforge"; +} +static inline QString debugName() +{ + return "Flame"; +} +static inline QString metaEntryBase() +{ + return "FlameMods"; +} +} // namespace Flame + +class FlameModPage : public ModPage { + Q_OBJECT + + public: + static FlameModPage* create(ModDownloadDialog* dialog, BaseInstance& instance) + { + return ModPage::create(dialog, instance); + } + + FlameModPage(ModDownloadDialog* dialog, BaseInstance& instance); + ~FlameModPage() override = default; + + bool shouldDisplay() const override; + + inline auto displayName() const -> QString override { return Flame::displayName(); } + inline auto icon() const -> QIcon override { return Flame::icon(); } + inline auto id() const -> QString override { return Flame::id(); } + inline auto debugName() const -> QString override { return Flame::debugName(); } + inline auto metaEntryBase() const -> QString override { return Flame::metaEntryBase(); } + + inline auto helpPage() const -> QString override { return "Mod-platform"; } + + void openUrl(const QUrl& url) override; + std::unique_ptr createFilterWidget() override; + + protected: + virtual void prepareProviderCategories() override; + + private: + Task::Ptr m_categoriesTask; +}; + +class FlameResourcePackPage : public ResourcePackResourcePage { + Q_OBJECT + + public: + static FlameResourcePackPage* create(ResourcePackDownloadDialog* dialog, BaseInstance& instance) + { + return ResourcePackResourcePage::create(dialog, instance); + } + + FlameResourcePackPage(ResourcePackDownloadDialog* dialog, BaseInstance& instance); + ~FlameResourcePackPage() override = default; + + bool shouldDisplay() const override; + + inline auto displayName() const -> QString override { return Flame::displayName(); } + inline auto icon() const -> QIcon override { return Flame::icon(); } + inline auto id() const -> QString override { return Flame::id(); } + inline auto debugName() const -> QString override { return Flame::debugName(); } + inline auto metaEntryBase() const -> QString override { return Flame::metaEntryBase(); } + + inline auto helpPage() const -> QString override { return ""; } + + void openUrl(const QUrl& url) override; +}; + +class FlameTexturePackPage : public TexturePackResourcePage { + Q_OBJECT + + public: + static FlameTexturePackPage* create(TexturePackDownloadDialog* dialog, BaseInstance& instance) + { + return TexturePackResourcePage::create(dialog, instance); + } + + FlameTexturePackPage(TexturePackDownloadDialog* dialog, BaseInstance& instance); + ~FlameTexturePackPage() override = default; + + bool shouldDisplay() const override; + + inline auto displayName() const -> QString override { return Flame::displayName(); } + inline auto icon() const -> QIcon override { return Flame::icon(); } + inline auto id() const -> QString override { return Flame::id(); } + inline auto debugName() const -> QString override { return Flame::debugName(); } + inline auto metaEntryBase() const -> QString override { return Flame::metaEntryBase(); } + + inline auto helpPage() const -> QString override { return ""; } + + void openUrl(const QUrl& url) override; +}; + +class FlameShaderPackPage : public ShaderPackResourcePage { + Q_OBJECT + + public: + static FlameShaderPackPage* create(ShaderPackDownloadDialog* dialog, BaseInstance& instance) + { + return ShaderPackResourcePage::create(dialog, instance); + } + + FlameShaderPackPage(ShaderPackDownloadDialog* dialog, BaseInstance& instance); + ~FlameShaderPackPage() override = default; + + bool shouldDisplay() const override; + + inline auto displayName() const -> QString override { return Flame::displayName(); } + inline auto icon() const -> QIcon override { return Flame::icon(); } + inline auto id() const -> QString override { return Flame::id(); } + inline auto debugName() const -> QString override { return Flame::debugName(); } + inline auto metaEntryBase() const -> QString override { return Flame::metaEntryBase(); } + + inline auto helpPage() const -> QString override { return ""; } + + void openUrl(const QUrl& url) override; +}; + +class FlameDataPackPage : public DataPackResourcePage { + Q_OBJECT + + public: + static FlameDataPackPage* create(DataPackDownloadDialog* dialog, BaseInstance& instance) + { + return DataPackResourcePage::create(dialog, instance); + } + + FlameDataPackPage(DataPackDownloadDialog* dialog, BaseInstance& instance); + ~FlameDataPackPage() override = default; + + bool shouldDisplay() const override; + + inline auto displayName() const -> QString override { return Flame::displayName(); } + inline auto icon() const -> QIcon override { return Flame::icon(); } + inline auto id() const -> QString override { return Flame::id(); } + inline auto debugName() const -> QString override { return Flame::debugName(); } + inline auto metaEntryBase() const -> QString override { return Flame::metaEntryBase(); } + + inline auto helpPage() const -> QString override { return ""; } + + void openUrl(const QUrl& url) override; +}; + +} // namespace ResourceDownload diff --git a/launcher/ui/pages/modplatform/ftb/FtbFilterModel.cpp b/launcher/ui/pages/modplatform/ftb/FtbFilterModel.cpp new file mode 100644 index 0000000..e33dda9 --- /dev/null +++ b/launcher/ui/pages/modplatform/ftb/FtbFilterModel.cpp @@ -0,0 +1,91 @@ +/* + * Copyright 2020-2021 Jamie Mansfield + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "FtbFilterModel.h" + +#include + +#include "modplatform/ftb/FTBPackManifest.h" + +#include "StringUtils.h" + +namespace Ftb { + +FilterModel::FilterModel(QObject* parent) : QSortFilterProxyModel(parent) +{ + m_currentSorting = Sorting::ByPlays; + m_sortings.insert(tr("Sort by Plays"), Sorting::ByPlays); + m_sortings.insert(tr("Sort by Installs"), Sorting::ByInstalls); + m_sortings.insert(tr("Sort by Name"), Sorting::ByName); +} + +const QMap FilterModel::getAvailableSortings() +{ + return m_sortings; +} + +QString FilterModel::translateCurrentSorting() +{ + return m_sortings.key(m_currentSorting); +} + +void FilterModel::setSorting(Sorting sorting) +{ + m_currentSorting = sorting; + invalidate(); +} + +FilterModel::Sorting FilterModel::getCurrentSorting() +{ + return m_currentSorting; +} + +void FilterModel::setSearchTerm(const QString& term) +{ + m_searchTerm = term.trimmed(); + invalidate(); +} + +bool FilterModel::filterAcceptsRow(int sourceRow, const QModelIndex& sourceParent) const +{ + if (m_searchTerm.isEmpty()) { + return true; + } + + auto index = sourceModel()->index(sourceRow, 0, sourceParent); + auto pack = sourceModel()->data(index, Qt::UserRole).value(); + return pack.name.contains(m_searchTerm, Qt::CaseInsensitive); +} + +bool FilterModel::lessThan(const QModelIndex& left, const QModelIndex& right) const +{ + FTB::Modpack leftPack = sourceModel()->data(left, Qt::UserRole).value(); + FTB::Modpack rightPack = sourceModel()->data(right, Qt::UserRole).value(); + + if (m_currentSorting == ByPlays) { + return leftPack.plays < rightPack.plays; + } else if (m_currentSorting == ByInstalls) { + return leftPack.installs < rightPack.installs; + } else if (m_currentSorting == ByName) { + return StringUtils::naturalCompare(leftPack.name, rightPack.name, Qt::CaseSensitive) >= 0; + } + + // Invalid sorting set, somehow... + qWarning() << "Invalid sorting set!"; + return true; +} + +} // namespace Ftb diff --git a/launcher/ui/pages/modplatform/ftb/FtbFilterModel.h b/launcher/ui/pages/modplatform/ftb/FtbFilterModel.h new file mode 100644 index 0000000..b9b958f --- /dev/null +++ b/launcher/ui/pages/modplatform/ftb/FtbFilterModel.h @@ -0,0 +1,49 @@ +/* + * Copyright 2020-2021 Jamie Mansfield + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +namespace Ftb { + +class FilterModel : public QSortFilterProxyModel { + Q_OBJECT + + public: + FilterModel(QObject* parent = Q_NULLPTR); + enum Sorting { + ByPlays, + ByInstalls, + ByName, + }; + const QMap getAvailableSortings(); + QString translateCurrentSorting(); + void setSorting(Sorting sorting); + Sorting getCurrentSorting(); + void setSearchTerm(const QString& term); + + protected: + bool filterAcceptsRow(int sourceRow, const QModelIndex& sourceParent) const override; + bool lessThan(const QModelIndex& left, const QModelIndex& right) const override; + + private: + QMap m_sortings; + Sorting m_currentSorting; + QString m_searchTerm{ "" }; +}; + +} // namespace Ftb diff --git a/launcher/ui/pages/modplatform/ftb/FtbListModel.cpp b/launcher/ui/pages/modplatform/ftb/FtbListModel.cpp new file mode 100644 index 0000000..29d73a4 --- /dev/null +++ b/launcher/ui/pages/modplatform/ftb/FtbListModel.cpp @@ -0,0 +1,254 @@ +/* + * Copyright 2020-2021 Jamie Mansfield + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "FtbListModel.h" + +#include "Application.h" +#include "BuildConfig.h" +#include "Json.h" + +#include + +namespace Ftb { + +ListModel::ListModel(QObject* parent) : QAbstractListModel(parent) {} + +ListModel::~ListModel() {} + +int ListModel::rowCount(const QModelIndex& parent) const +{ + return parent.isValid() ? 0 : m_modpacks.size(); +} + +int ListModel::columnCount(const QModelIndex& parent) const +{ + return parent.isValid() ? 0 : 1; +} + +QVariant ListModel::data(const QModelIndex& index, int role) const +{ + int pos = index.row(); + if (pos >= m_modpacks.size() || pos < 0 || !index.isValid()) { + return QString("INVALID INDEX %1").arg(pos); + } + + FTB::Modpack pack = m_modpacks.at(pos); + if (role == Qt::DisplayRole) { + return pack.name; + } else if (role == Qt::ToolTipRole) { + return pack.synopsis; + } else if (role == Qt::DecorationRole) { + QIcon placeholder = QIcon::fromTheme("screenshot-placeholder"); + + auto iter = m_logoMap.find(pack.safeName); + if (iter != m_logoMap.end()) { + auto& logo = *iter; + if (!logo.result.isNull()) { + return logo.result; + } + return placeholder; + } + + for (auto art : pack.art) { + if (art.type == "square") { + ((ListModel*)this)->requestLogo(pack.safeName, art.url); + } + } + return placeholder; + } else if (role == Qt::UserRole) { + QVariant v; + v.setValue(pack); + return v; + } + + return QVariant(); +} + +void ListModel::getLogo(const QString& logo, const QString& logoUrl, LogoCallback callback) +{ + if (m_logoMap.contains(logo)) { + callback(APPLICATION->metacache()->resolveEntry("FTBPacks", QString("logos/%1").arg(logo))->getFullPath()); + } else { + requestLogo(logo, logoUrl); + } +} + +void ListModel::request() +{ + m_aborted = false; + + beginResetModel(); + m_modpacks.clear(); + endResetModel(); + + auto netJob = makeShared("Ftb::Request", APPLICATION->network()); + auto url = QString(BuildConfig.FTB_API_BASE_URL + "/modpack/all"); + auto [action, response] = Net::Download::makeByteArray(QUrl(url)); + netJob->addNetAction(action); + m_jobPtr = netJob; + m_jobPtr->start(); + + QObject::connect(netJob.get(), &NetJob::succeeded, this, [this, response] { requestFinished(response); }); + QObject::connect(netJob.get(), &NetJob::failed, this, &ListModel::requestFailed); +} + +void ListModel::abortRequest() +{ + m_aborted = m_jobPtr->abort(); + m_jobPtr.reset(); +} + +void ListModel::requestFinished(QByteArray* responsePtr) +{ + // NOTE(TheKodeToad): moving the response out to avoid it from being destroyed by m_jobPtr.reset() + QByteArray response = std::move(*responsePtr); + m_jobPtr.reset(); + m_remainingPacks.clear(); + + QJsonParseError parse_error{}; + QJsonDocument doc = QJsonDocument::fromJson(response, &parse_error); + if (parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from FTB at " << parse_error.offset << " reason: " << parse_error.errorString(); + qWarning() << response; + return; + } + + auto packs = doc.object().value("packs").toArray(); + for (auto pack : packs) { + auto packId = pack.toInt(); + m_remainingPacks.append(packId); + } + + if (!m_remainingPacks.isEmpty()) { + m_currentPack = m_remainingPacks.at(0); + requestPack(); + } +} + +void ListModel::requestFailed(QString) +{ + m_jobPtr.reset(); + m_remainingPacks.clear(); +} + +void ListModel::requestPack() +{ + auto netJob = makeShared("Ftb::Search", APPLICATION->network()); + auto searchUrl = QString(BuildConfig.FTB_API_BASE_URL + "/modpack/%1").arg(m_currentPack); + auto [action, response] = Net::Download::makeByteArray(QUrl(searchUrl)); + netJob->addNetAction(action); + m_jobPtr = netJob; + m_jobPtr->start(); + + QObject::connect(netJob.get(), &NetJob::succeeded, this, [this, response] { packRequestFinished(response); }); + QObject::connect(netJob.get(), &NetJob::failed, this, &ListModel::packRequestFailed); +} + +void ListModel::packRequestFinished(QByteArray* responsePtr) +{ + if (!m_jobPtr || m_aborted) + return; + + // NOTE(TheKodeToad): moving the response out to avoid it from being destroyed by jobPtr.reset() + QByteArray response = std::move(*responsePtr); + + m_jobPtr.reset(); + m_remainingPacks.removeOne(m_currentPack); + + QJsonParseError parse_error; + QJsonDocument doc = QJsonDocument::fromJson(response, &parse_error); + + if (parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from FTB at " << parse_error.offset << " reason: " << parse_error.errorString(); + qWarning() << response; + return; + } + + auto obj = doc.object(); + + FTB::Modpack pack; + try { + FTB::loadModpack(pack, obj); + } catch (const JSONValidationError& e) { + qDebug() << QString::fromUtf8(response); + qWarning() << "Error while reading pack manifest from FTB: " << e.cause(); + return; + } + + // Since there is no guarantee that packs have a version, this will just + // ignore those "dud" packs. + if (pack.versions.empty()) { + qWarning() << "FTB Pack " << pack.id << " ignored. reason: lacking any versions"; + } else { + beginInsertRows(QModelIndex(), m_modpacks.size(), m_modpacks.size()); + m_modpacks.append(pack); + endInsertRows(); + } + + if (!m_remainingPacks.isEmpty()) { + m_currentPack = m_remainingPacks.at(0); + requestPack(); + } +} + +void ListModel::packRequestFailed(QString) +{ + m_jobPtr.reset(); + m_remainingPacks.removeOne(m_currentPack); +} + +void ListModel::logoLoaded(QString logo) +{ + auto& logoObj = m_logoMap[logo]; + logoObj.downloadJob.reset(); + logoObj.result = QIcon(logoObj.fullpath); + for (int i = 0; i < m_modpacks.size(); i++) { + if (m_modpacks[i].safeName == logo) { + emit dataChanged(createIndex(i, 0), createIndex(i, 0), { Qt::DecorationRole }); + } + } +} + +void ListModel::logoFailed(QString logo) +{ + m_logoMap[logo].failed = true; + m_logoMap[logo].downloadJob.reset(); +} + +void ListModel::requestLogo(QString logo, QString url) +{ + if (m_logoMap.contains(logo)) { + return; + } + + MetaEntryPtr entry = APPLICATION->metacache()->resolveEntry("FTBPacks", QString("logos/%1").arg(logo)); + + auto job = makeShared(QString("FTB Icon Download %1").arg(logo), APPLICATION->network()); + job->setAskRetry(false); + job->addNetAction(Net::Download::makeCached(QUrl(url), entry)); + + auto fullPath = entry->getFullPath(); + QObject::connect(job.get(), &NetJob::finished, this, [this, logo, fullPath] { logoLoaded(logo); }); + + QObject::connect(job.get(), &NetJob::failed, this, [this, logo] { logoFailed(logo); }); + + auto& newLogoEntry = m_logoMap[logo]; + newLogoEntry.downloadJob = job; + newLogoEntry.fullpath = fullPath; + job->start(); +} + +} // namespace Ftb diff --git a/launcher/ui/pages/modplatform/ftb/FtbListModel.h b/launcher/ui/pages/modplatform/ftb/FtbListModel.h new file mode 100644 index 0000000..339693c --- /dev/null +++ b/launcher/ui/pages/modplatform/ftb/FtbListModel.h @@ -0,0 +1,82 @@ +/* + * Copyright 2020-2021 Jamie Mansfield + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +#include +#include +#include "modplatform/ftb/FTBPackManifest.h" +#include "net/NetJob.h" + +namespace Ftb { + +struct Logo { + QString fullpath; + NetJob::Ptr downloadJob; + QIcon result; + bool failed = false; +}; + +using LogoMap = QMap; +using LogoCallback = std::function; + +class ListModel : public QAbstractListModel { + Q_OBJECT + + public: + ListModel(QObject* parent); + virtual ~ListModel(); + + int rowCount(const QModelIndex& parent) const override; + int columnCount(const QModelIndex& parent) const override; + QVariant data(const QModelIndex& index, int role) const override; + + void request(); + void abortRequest(); + + void getLogo(const QString& logo, const QString& logoUrl, LogoCallback callback); + + [[nodiscard]] bool isMakingRequest() const { return m_jobPtr.get(); } + [[nodiscard]] bool wasAborted() const { return m_aborted; } + + private slots: + void requestFinished(QByteArray* responsePtr); + void requestFailed(QString reason); + + void requestPack(); + void packRequestFinished(QByteArray* responsePtr); + void packRequestFailed(QString reason); + + void logoFailed(QString logo); + void logoLoaded(QString logo); + + private: + void requestLogo(QString file, QString url); + + private: + bool m_aborted = false; + + QList m_modpacks; + LogoMap m_logoMap; + + NetJob::Ptr m_jobPtr; + int m_currentPack; + QList m_remainingPacks; +}; + +} // namespace Ftb diff --git a/launcher/ui/pages/modplatform/ftb/FtbPage.cpp b/launcher/ui/pages/modplatform/ftb/FtbPage.cpp new file mode 100644 index 0000000..b208f5c --- /dev/null +++ b/launcher/ui/pages/modplatform/ftb/FtbPage.cpp @@ -0,0 +1,198 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * PolyMC - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2020-2021 Jamie Mansfield + * Copyright 2021 Philip T + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "FtbPage.h" +#include "ui_FtbPage.h" + +#include + +#include "modplatform/ftb/FTBPackInstallTask.h" +#include "ui/dialogs/NewInstanceDialog.h" + +#include "Markdown.h" + +FtbPage::FtbPage(NewInstanceDialog* dialog, QWidget* parent) : QWidget(parent), m_ui(new Ui::FtbPage), m_dialog(dialog) +{ + m_ui->setupUi(this); + + m_filterModel = new Ftb::FilterModel(this); + m_listModel = new Ftb::ListModel(this); + m_filterModel->setSourceModel(m_listModel); + m_ui->packView->setModel(m_filterModel); + m_ui->packView->setSortingEnabled(true); + m_ui->packView->header()->hide(); + m_ui->packView->setIndentation(0); + + m_ui->searchEdit->installEventFilter(this); + + m_ui->versionSelectionBox->view()->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + m_ui->versionSelectionBox->view()->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); + m_ui->versionSelectionBox->view()->parentWidget()->setMaximumHeight(300); + + for (int i = 0; i < m_filterModel->getAvailableSortings().size(); i++) { + m_ui->sortByBox->addItem(m_filterModel->getAvailableSortings().keys().at(i)); + } + m_ui->sortByBox->setCurrentText(m_filterModel->translateCurrentSorting()); + + connect(m_ui->searchEdit, &QLineEdit::textChanged, this, &FtbPage::triggerSearch); + connect(m_ui->sortByBox, &QComboBox::currentTextChanged, this, &FtbPage::onSortingSelectionChanged); + connect(m_ui->packView->selectionModel(), &QItemSelectionModel::currentChanged, this, &FtbPage::onSelectionChanged); + connect(m_ui->versionSelectionBox, &QComboBox::currentTextChanged, this, &FtbPage::onVersionSelectionChanged); + + m_ui->packDescription->setMetaEntry("FTBPacks"); +} + +FtbPage::~FtbPage() +{ + delete m_ui; +} + +bool FtbPage::eventFilter(QObject* watched, QEvent* event) +{ + if (watched == m_ui->searchEdit && event->type() == QEvent::KeyPress) { + QKeyEvent* keyEvent = static_cast(event); + if (keyEvent->key() == Qt::Key_Return) { + triggerSearch(); + keyEvent->accept(); + return true; + } + } + return QWidget::eventFilter(watched, event); +} + +bool FtbPage::shouldDisplay() const +{ + return true; +} + +void FtbPage::retranslate() +{ + m_ui->retranslateUi(this); +} + +void FtbPage::openedImpl() +{ + if (!m_initialised || m_listModel->wasAborted()) { + m_listModel->request(); + m_initialised = true; + } + + suggestCurrent(); +} + +void FtbPage::closedImpl() +{ + if (m_listModel->isMakingRequest()) + m_listModel->abortRequest(); +} + +void FtbPage::suggestCurrent() +{ + if (!isOpened) { + return; + } + + if (m_selectedVersion.isEmpty()) { + m_dialog->setSuggestedPack(); + return; + } + + m_dialog->setSuggestedPack(m_selected.name, m_selectedVersion, new FTB::PackInstallTask(m_selected, m_selectedVersion, this)); + for (auto art : m_selected.art) { + if (art.type == "square") { + auto editedLogoName = "ftb_" + m_selected.safeName; + m_listModel->getLogo(m_selected.safeName, art.url, + [this, editedLogoName](QString logo) { m_dialog->setSuggestedIconFromFile(logo, editedLogoName); }); + } + } +} + +void FtbPage::triggerSearch() +{ + m_filterModel->setSearchTerm(m_ui->searchEdit->text()); +} + +void FtbPage::onSortingSelectionChanged(QString selected) +{ + auto toSet = m_filterModel->getAvailableSortings().value(selected); + m_filterModel->setSorting(toSet); +} + +void FtbPage::onSelectionChanged(QModelIndex first, QModelIndex /*second*/) +{ + m_ui->versionSelectionBox->clear(); + + if (!first.isValid()) { + if (isOpened) { + m_dialog->setSuggestedPack(); + } + return; + } + + m_selected = m_filterModel->data(first, Qt::UserRole).value(); + + QString output = markdownToHTML(m_selected.description.toUtf8()); + m_ui->packDescription->setHtml(output); + + // reverse foreach, so that the newest versions are first + for (auto i = m_selected.versions.size(); i--;) { + m_ui->versionSelectionBox->addItem(m_selected.versions.at(i).name); + } + + suggestCurrent(); +} + +void FtbPage::onVersionSelectionChanged(QString selected) +{ + if (selected.isNull() || selected.isEmpty()) { + m_selectedVersion = ""; + return; + } + + m_selectedVersion = selected; + suggestCurrent(); +} + +QString FtbPage::getSerachTerm() const +{ + return m_ui->searchEdit->text(); +} + +void FtbPage::setSearchTerm(QString term) +{ + m_ui->searchEdit->setText(term); +} \ No newline at end of file diff --git a/launcher/ui/pages/modplatform/ftb/FtbPage.h b/launcher/ui/pages/modplatform/ftb/FtbPage.h new file mode 100644 index 0000000..84e7740 --- /dev/null +++ b/launcher/ui/pages/modplatform/ftb/FtbPage.h @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * PolyMC - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "FtbFilterModel.h" +#include "FtbListModel.h" + +#include + +#include "Application.h" +#include "tasks/Task.h" +#include "ui/pages/BasePage.h" +#include "ui/pages/modplatform/ModpackProviderBasePage.h" + +namespace Ui { +class FtbPage; +} + +class NewInstanceDialog; + +class FtbPage : public QWidget, public ModpackProviderBasePage { + Q_OBJECT + + public: + explicit FtbPage(NewInstanceDialog* dialog, QWidget* parent = 0); + virtual ~FtbPage(); + virtual QString displayName() const override { return "FTB"; } + virtual QIcon icon() const override { return QIcon::fromTheme("ftb_logo"); } + virtual QString id() const override { return "ftb"; } + virtual QString helpPage() const override { return "FTB-platform"; } + virtual bool shouldDisplay() const override; + void retranslate() override; + + void openedImpl() override; + void closedImpl() override; + + bool eventFilter(QObject* watched, QEvent* event) override; + + /** Programatically set the term in the search bar. */ + virtual void setSearchTerm(QString) override; + /** Get the current term in the search bar. */ + [[nodiscard]] virtual QString getSerachTerm() const override; + + private: + void suggestCurrent(); + + private slots: + void triggerSearch(); + + void onSortingSelectionChanged(QString selected); + void onSelectionChanged(QModelIndex first, QModelIndex second); + void onVersionSelectionChanged(QString selected); + + private: + Ui::FtbPage* m_ui = nullptr; + NewInstanceDialog* m_dialog = nullptr; + Ftb::ListModel* m_listModel = nullptr; + Ftb::FilterModel* m_filterModel = nullptr; + + FTB::Modpack m_selected; + QString m_selectedVersion; + + bool m_initialised{ false }; +}; diff --git a/launcher/ui/pages/modplatform/ftb/FtbPage.ui b/launcher/ui/pages/modplatform/ftb/FtbPage.ui new file mode 100644 index 0000000..e7fe6f4 --- /dev/null +++ b/launcher/ui/pages/modplatform/ftb/FtbPage.ui @@ -0,0 +1,96 @@ + + + FtbPage + + + + 0 + 0 + 875 + 745 + + + + + + + + + + + + Version selected: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + + + + + Search and filter... + + + true + + + + + + + + + true + + + + 48 + 48 + + + + QAbstractItemView::ScrollPerPixel + + + + + + + true + + + true + + + + + + + + + Note: Many recent FTB modpacks are also available from CurseForge! + + + + + + + + ProjectDescriptionPage + QTextBrowser +
    ui/widgets/ProjectDescriptionPage.h
    +
    +
    + + searchEdit + versionSelectionBox + + + +
    diff --git a/launcher/ui/pages/modplatform/import_ftb/ImportFTBPage.cpp b/launcher/ui/pages/modplatform/import_ftb/ImportFTBPage.cpp new file mode 100644 index 0000000..9a2b317 --- /dev/null +++ b/launcher/ui/pages/modplatform/import_ftb/ImportFTBPage.cpp @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "ImportFTBPage.h" +#include "ui/widgets/ProjectItem.h" +#include "ui_ImportFTBPage.h" + +#include +#include +#include +#include +#include "FileSystem.h" +#include "ListModel.h" +#include "modplatform/import_ftb/PackInstallTask.h" +#include "ui/dialogs/NewInstanceDialog.h" + +namespace FTBImportAPP { + +ImportFTBPage::ImportFTBPage(NewInstanceDialog* dialog, QWidget* parent) : QWidget(parent), dialog(dialog), ui(new Ui::ImportFTBPage) +{ + ui->setupUi(this); + + { + currentModel = new FilterModel(this); + listModel = new ListModel(this); + currentModel->setSourceModel(listModel); + + ui->modpackList->setModel(currentModel); + ui->modpackList->setSortingEnabled(true); + ui->modpackList->header()->hide(); + ui->modpackList->setIndentation(0); + ui->modpackList->setIconSize(QSize(42, 42)); + + for (int i = 0; i < currentModel->getAvailableSortings().size(); i++) { + ui->sortByBox->addItem(currentModel->getAvailableSortings().keys().at(i)); + } + + ui->sortByBox->setCurrentText(currentModel->translateCurrentSorting()); + } + + connect(ui->modpackList->selectionModel(), &QItemSelectionModel::currentChanged, this, &ImportFTBPage::onPublicPackSelectionChanged); + + connect(ui->sortByBox, &QComboBox::currentTextChanged, this, &ImportFTBPage::onSortingSelectionChanged); + + connect(ui->searchEdit, &QLineEdit::textChanged, this, &ImportFTBPage::triggerSearch); + + connect(ui->browseButton, &QPushButton::clicked, this, [this] { + QString dir = QFileDialog::getExistingDirectory(this, tr("Select FTBApp instances directory"), listModel->getUserPath(), + QFileDialog::ShowDirsOnly); + if (!dir.isEmpty()) + listModel->setPath(dir); + }); + + ui->modpackList->setItemDelegate(new ProjectItemDelegate(this)); + ui->modpackList->selectionModel()->reset(); +} + +ImportFTBPage::~ImportFTBPage() +{ + delete ui; +} + +void ImportFTBPage::openedImpl() +{ + if (!initialized) { + listModel->update(); + initialized = true; + } + suggestCurrent(); +} + +void ImportFTBPage::retranslate() +{ + ui->retranslateUi(this); +} + +QString saveIconToTempFile(const QIcon& icon) +{ + if (icon.isNull()) { + return QString(); + } + + QPixmap pixmap = icon.pixmap(icon.availableSizes().last()); + if (pixmap.isNull()) { + return QString(); + } + + QTemporaryFile tempFile(QDir::tempPath() + "/iconXXXXXX.png"); + tempFile.setAutoRemove(false); + if (!tempFile.open()) { + return QString(); + } + + QString tempPath = tempFile.fileName(); + tempFile.close(); + + if (!pixmap.save(tempPath, "PNG")) { + QFile::remove(tempPath); + return QString(); + } + + return tempPath; // Success +} + +void ImportFTBPage::suggestCurrent() +{ + if (!isOpened) + return; + + if (selected.path.isEmpty()) { + dialog->setSuggestedPack(); + return; + } + + dialog->setSuggestedPack(selected.name, new PackInstallTask(selected)); + QString editedLogoName = QString("ftb_%1_%2.jpg").arg(selected.name, QString::number(selected.id)); + auto iconPath = FS::PathCombine(selected.path, "folder.jpg"); + if (!QFileInfo::exists(iconPath)) { + // need to save the icon as that actual logo is not a image on the disk + iconPath = saveIconToTempFile(selected.icon); + } + if (!iconPath.isEmpty() && QFileInfo::exists(iconPath)) { + dialog->setSuggestedIconFromFile(iconPath, editedLogoName); + } +} + +void ImportFTBPage::onPublicPackSelectionChanged(QModelIndex now, QModelIndex) +{ + if (!now.isValid()) { + onPackSelectionChanged(); + return; + } + + QVariant raw = currentModel->data(now, Qt::UserRole); + Q_ASSERT(raw.canConvert()); + auto selectedPack = raw.value(); + onPackSelectionChanged(&selectedPack); +} + +void ImportFTBPage::onPackSelectionChanged(Modpack* pack) +{ + if (pack) { + selected = *pack; + suggestCurrent(); + return; + } + if (isOpened) + dialog->setSuggestedPack(); +} + +void ImportFTBPage::onSortingSelectionChanged(QString sort) +{ + FilterModel::Sorting toSet = currentModel->getAvailableSortings().value(sort); + currentModel->setSorting(toSet); +} + +void ImportFTBPage::triggerSearch() +{ + currentModel->setSearchTerm(ui->searchEdit->text()); +} + +void ImportFTBPage::setSearchTerm(QString term) +{ + ui->searchEdit->setText(term); +} + +QString ImportFTBPage::getSerachTerm() const +{ + return ui->searchEdit->text(); +} +} // namespace FTBImportAPP diff --git a/launcher/ui/pages/modplatform/import_ftb/ImportFTBPage.h b/launcher/ui/pages/modplatform/import_ftb/ImportFTBPage.h new file mode 100644 index 0000000..25b900f --- /dev/null +++ b/launcher/ui/pages/modplatform/import_ftb/ImportFTBPage.h @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include +#include +#include + +#include "modplatform/import_ftb/PackHelpers.h" +#include "ui/pages/modplatform/ModpackProviderBasePage.h" +#include "ui/pages/modplatform/import_ftb/ListModel.h" + +class NewInstanceDialog; + +namespace FTBImportAPP { +namespace Ui { +class ImportFTBPage; +} + +class ImportFTBPage : public QWidget, public ModpackProviderBasePage { + Q_OBJECT + + public: + explicit ImportFTBPage(NewInstanceDialog* dialog, QWidget* parent = 0); + virtual ~ImportFTBPage(); + QString displayName() const override { return tr("FTB App Import"); } + QIcon icon() const override { return QIcon::fromTheme("ftb_logo"); } + QString id() const override { return "import_ftb"; } + QString helpPage() const override { return "FTB-import"; } + bool shouldDisplay() const override { return true; } + void openedImpl() override; + void retranslate() override; + + /** Programatically set the term in the search bar. */ + virtual void setSearchTerm(QString) override; + /** Get the current term in the search bar. */ + virtual QString getSerachTerm() const override; + + private: + void suggestCurrent(); + void onPackSelectionChanged(Modpack* pack = nullptr); + private slots: + void onSortingSelectionChanged(QString data); + void onPublicPackSelectionChanged(QModelIndex first, QModelIndex second); + void triggerSearch(); + + private: + bool initialized = false; + Modpack selected; + ListModel* listModel = nullptr; + FilterModel* currentModel = nullptr; + + NewInstanceDialog* dialog = nullptr; + Ui::ImportFTBPage* ui = nullptr; +}; + +} // namespace FTBImportAPP diff --git a/launcher/ui/pages/modplatform/import_ftb/ImportFTBPage.ui b/launcher/ui/pages/modplatform/import_ftb/ImportFTBPage.ui new file mode 100644 index 0000000..aa9b5ae --- /dev/null +++ b/launcher/ui/pages/modplatform/import_ftb/ImportFTBPage.ui @@ -0,0 +1,106 @@ + + + FTBImportAPP::ImportFTBPage + + + + 0 + 0 + 1461 + 1011 + + + + + + + + true + + + + Note: Many recent FTB modpacks are also available from CurseForge! Also, if your FTB instances are not in the default location, select it using the button next to search. + + + Qt::AlignmentFlag::AlignCenter + + + true + + + + + + + + + Search and filter... + + + true + + + + + + + Select FTBApp instances directory + + + + + + + + + true + + + + + + + + + QAbstractItemView::ScrollPerPixel + + + + 16777215 + 16777215 + + + + + + + + + + + 265 + 0 + + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + + + diff --git a/launcher/ui/pages/modplatform/import_ftb/ListModel.cpp b/launcher/ui/pages/modplatform/import_ftb/ListModel.cpp new file mode 100644 index 0000000..5c9c2fd --- /dev/null +++ b/launcher/ui/pages/modplatform/import_ftb/ListModel.cpp @@ -0,0 +1,218 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "ListModel.h" +#include +#include +#include +#include +#include +#include "Application.h" +#include "settings/SettingsObject.h" +#include "Exception.h" +#include "FileSystem.h" +#include "Json.h" +#include "StringUtils.h" +#include "modplatform/import_ftb/PackHelpers.h" +#include "ui/widgets/ProjectItem.h" + +namespace FTBImportAPP { + +QString getFTBRoot() +{ + QString partialPath = QDir::homePath(); +#if defined(Q_OS_MACOS) + partialPath = FS::PathCombine(partialPath, "Library/Application Support"); +#endif + return FS::PathCombine(partialPath, ".ftba"); +} + +QString getDynamicPath() +{ + auto settingsPath = FS::PathCombine(getFTBRoot(), "storage", "settings.json"); + if (!QFileInfo::exists(settingsPath)) + settingsPath = FS::PathCombine(getFTBRoot(), "bin", "settings.json"); + if (!QFileInfo::exists(settingsPath)) { + qWarning() << "The ftb app setings doesn't exist."; + return {}; + } + try { + auto doc = Json::requireDocument(FS::read(settingsPath)); + return Json::requireString(Json::requireObject(doc), "instanceLocation"); + } catch (const Exception& e) { + qCritical() << "Could not read ftb settings file:" << e.cause(); + } + return {}; +} + +ListModel::ListModel(QObject* parent) : QAbstractListModel(parent), m_instances_path(getDynamicPath()) {} + +void ListModel::update() +{ + beginResetModel(); + m_modpacks.clear(); + + auto wasPathAdded = [this](QString path) { + for (auto pack : m_modpacks) { + if (pack.path == path) + return true; + } + return false; + }; + + auto scanPath = [this, wasPathAdded](QString path) { + if (path.isEmpty()) + return; + if (auto instancesInfo = QFileInfo(path); !instancesInfo.exists() || !instancesInfo.isDir()) + return; + QDirIterator directoryIterator(path, QDir::Dirs | QDir::NoDotAndDotDot | QDir::Readable | QDir::Hidden, + QDirIterator::FollowSymlinks); + while (directoryIterator.hasNext()) { + auto currentPath = directoryIterator.next(); + if (!wasPathAdded(currentPath)) { + auto modpack = parseDirectory(currentPath); + if (!modpack.path.isEmpty()) + m_modpacks.append(modpack); + } + } + }; + + scanPath(APPLICATION->settings()->get("FTBAppInstancesPath").toString()); + scanPath(m_instances_path); + + endResetModel(); +} + +QVariant ListModel::data(const QModelIndex& index, int role) const +{ + int pos = index.row(); + if (pos >= m_modpacks.size() || pos < 0 || !index.isValid()) { + return QVariant(); + } + + auto pack = m_modpacks.at(pos); + switch (role) { + case Qt::ToolTipRole: + return tr("Minecraft %1").arg(pack.mcVersion); + case Qt::DecorationRole: + return pack.icon; + case Qt::UserRole: { + QVariant v; + v.setValue(pack); + return v; + } + case Qt::DisplayRole: + return pack.name; + case Qt::SizeHintRole: + return QSize(0, 58); + // Custom data + case UserDataTypes::TITLE: + return pack.name; + case UserDataTypes::DESCRIPTION: + return tr("Minecraft %1").arg(pack.mcVersion); + case UserDataTypes::INSTALLED: + return false; + default: + break; + } + + return {}; +} + +FilterModel::FilterModel(QObject* parent) : QSortFilterProxyModel(parent) +{ + m_currentSorting = Sorting::ByGameVersion; + m_sortings.insert(tr("Sort by Name"), Sorting::ByName); + m_sortings.insert(tr("Sort by Game Version"), Sorting::ByGameVersion); +} + +bool FilterModel::lessThan(const QModelIndex& left, const QModelIndex& right) const +{ + QVariant leftRaw = sourceModel()->data(left, Qt::UserRole); + Q_ASSERT(leftRaw.canConvert()); + auto leftPack = leftRaw.value(); + QVariant rightRaw = sourceModel()->data(right, Qt::UserRole); + Q_ASSERT(rightRaw.canConvert()); + auto rightPack = rightRaw.value(); + + if (m_currentSorting == Sorting::ByGameVersion) { + Version lv(leftPack.mcVersion); + Version rv(rightPack.mcVersion); + return lv < rv; + + } else if (m_currentSorting == Sorting::ByName) { + return StringUtils::naturalCompare(leftPack.name, rightPack.name, Qt::CaseSensitive) >= 0; + } + + // UHM, some inavlid value set?! + qWarning() << "Invalid sorting set!"; + return true; +} + +bool FilterModel::filterAcceptsRow([[maybe_unused]] int sourceRow, [[maybe_unused]] const QModelIndex& sourceParent) const +{ + if (m_searchTerm.isEmpty()) { + return true; + } + QModelIndex index = sourceModel()->index(sourceRow, 0, sourceParent); + QVariant raw = sourceModel()->data(index, Qt::UserRole); + Q_ASSERT(raw.canConvert()); + auto pack = raw.value(); + return pack.name.contains(m_searchTerm, Qt::CaseInsensitive); +} + +void FilterModel::setSearchTerm(const QString term) +{ + m_searchTerm = term.trimmed(); + invalidate(); +} + +const QMap FilterModel::getAvailableSortings() +{ + return m_sortings; +} + +QString FilterModel::translateCurrentSorting() +{ + return m_sortings.key(m_currentSorting); +} + +void FilterModel::setSorting(Sorting s) +{ + m_currentSorting = s; + invalidate(); +} + +FilterModel::Sorting FilterModel::getCurrentSorting() +{ + return m_currentSorting; +} +void ListModel::setPath(QString path) +{ + APPLICATION->settings()->set("FTBAppInstancesPath", path); + update(); +} + +QString ListModel::getUserPath() +{ + auto path = APPLICATION->settings()->get("FTBAppInstancesPath").toString(); + if (path.isEmpty()) + path = m_instances_path; + return path; +} +} // namespace FTBImportAPP diff --git a/launcher/ui/pages/modplatform/import_ftb/ListModel.h b/launcher/ui/pages/modplatform/import_ftb/ListModel.h new file mode 100644 index 0000000..7262846 --- /dev/null +++ b/launcher/ui/pages/modplatform/import_ftb/ListModel.h @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include +#include +#include +#include "modplatform/import_ftb/PackHelpers.h" + +namespace FTBImportAPP { + +class FilterModel : public QSortFilterProxyModel { + Q_OBJECT + public: + FilterModel(QObject* parent = Q_NULLPTR); + enum Sorting { ByName, ByGameVersion }; + const QMap getAvailableSortings(); + QString translateCurrentSorting(); + void setSorting(Sorting sorting); + Sorting getCurrentSorting(); + void setSearchTerm(QString term); + + protected: + bool filterAcceptsRow(int sourceRow, const QModelIndex& sourceParent) const override; + bool lessThan(const QModelIndex& left, const QModelIndex& right) const override; + + private: + QMap m_sortings; + Sorting m_currentSorting; + QString m_searchTerm; +}; + +class ListModel : public QAbstractListModel { + Q_OBJECT + + public: + ListModel(QObject* parent); + virtual ~ListModel() = default; + + int rowCount(const QModelIndex& parent) const { return m_modpacks.size(); } + int columnCount(const QModelIndex& parent) const { return 1; } + QVariant data(const QModelIndex& index, int role) const; + + void update(); + + QString getUserPath(); + void setPath(QString path); + + private: + ModpackList m_modpacks; + const QString m_instances_path; +}; +} // namespace FTBImportAPP diff --git a/launcher/ui/pages/modplatform/legacy_ftb/ListModel.cpp b/launcher/ui/pages/modplatform/legacy_ftb/ListModel.cpp new file mode 100644 index 0000000..ab2bc6a --- /dev/null +++ b/launcher/ui/pages/modplatform/legacy_ftb/ListModel.cpp @@ -0,0 +1,309 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ListModel.h" +#include "Application.h" +#include "settings/SettingsObject.h" +#include "net/ApiDownload.h" +#include "net/HttpMetaCache.h" +#include "net/NetJob.h" + +#include +#include "StringUtils.h" +#include "ui/widgets/ProjectItem.h" + +#include +#include + +#include + +#include + +namespace LegacyFTB { + +FilterModel::FilterModel(QObject* parent) : QSortFilterProxyModel(parent) +{ + currentSorting = Sorting::ByGameVersion; + sortings.insert(tr("Sort by Name"), Sorting::ByName); + sortings.insert(tr("Sort by Game Version"), Sorting::ByGameVersion); +} + +bool FilterModel::lessThan(const QModelIndex& left, const QModelIndex& right) const +{ + QVariant leftRaw = sourceModel()->data(left, Qt::UserRole); + Q_ASSERT(leftRaw.canConvert()); + auto leftPack = leftRaw.value(); + QVariant rightRaw = sourceModel()->data(right, Qt::UserRole); + Q_ASSERT(rightRaw.canConvert()); + auto rightPack = rightRaw.value(); + + if (currentSorting == Sorting::ByGameVersion) { + Version lv(leftPack.mcVersion); + Version rv(rightPack.mcVersion); + return lv < rv; + + } else if (currentSorting == Sorting::ByName) { + return StringUtils::naturalCompare(leftPack.name, rightPack.name, Qt::CaseSensitive) >= 0; + } + + // UHM, some inavlid value set?! + qWarning() << "Invalid sorting set!"; + return true; +} + +bool FilterModel::filterAcceptsRow([[maybe_unused]] int sourceRow, [[maybe_unused]] const QModelIndex& sourceParent) const +{ + if (searchTerm.isEmpty()) { + return true; + } + QModelIndex index = sourceModel()->index(sourceRow, 0, sourceParent); + QVariant raw = sourceModel()->data(index, Qt::UserRole); + Q_ASSERT(raw.canConvert()); + auto pack = raw.value(); + if (searchTerm.startsWith("#")) + return pack.packCode == searchTerm.mid(1); + return pack.name.contains(searchTerm, Qt::CaseInsensitive); +} + +void FilterModel::setSearchTerm(const QString term) +{ + searchTerm = term.trimmed(); + invalidate(); +} + +const QMap FilterModel::getAvailableSortings() +{ + return sortings; +} + +QString FilterModel::translateCurrentSorting() +{ + return sortings.key(currentSorting); +} + +void FilterModel::setSorting(Sorting s) +{ + currentSorting = s; + invalidate(); +} + +FilterModel::Sorting FilterModel::getCurrentSorting() +{ + return currentSorting; +} + +ListModel::ListModel(QObject* parent) : QAbstractListModel(parent) {} + +ListModel::~ListModel() {} + +QString ListModel::translatePackType(PackType type) const +{ + switch (type) { + case PackType::Public: + return tr("Public Modpack"); + case PackType::ThirdParty: + return tr("Third Party Modpack"); + case PackType::Private: + return tr("Private Modpack"); + } + qWarning() << "Unknown FTB modpack type:" << int(type); + return QString(); +} + +int ListModel::rowCount(const QModelIndex& parent) const +{ + return parent.isValid() ? 0 : modpacks.size(); +} + +int ListModel::columnCount(const QModelIndex& parent) const +{ + return parent.isValid() ? 0 : 1; +} + +QVariant ListModel::data(const QModelIndex& index, int role) const +{ + int pos = index.row(); + if (pos >= modpacks.size() || pos < 0 || !index.isValid()) { + return QString("INVALID INDEX %1").arg(pos); + } + + Modpack pack = modpacks.at(pos); + switch (role) { + case Qt::ToolTipRole: { + if (pack.description.length() > 100) { + // some magic to prevent to long tooltips and replace html linebreaks + QString edit = pack.description.left(97); + edit = edit.left(edit.lastIndexOf("
    ")).left(edit.lastIndexOf(" ")).append("..."); + return edit; + } + return pack.description; + } + case Qt::DecorationRole: { + if (m_logoMap.contains(pack.logo)) { + return (m_logoMap.value(pack.logo)); + } + QIcon icon = QIcon::fromTheme("screenshot-placeholder"); + ((ListModel*)this)->requestLogo(pack.logo); + return icon; + } + case Qt::UserRole: { + QVariant v; + v.setValue(pack); + return v; + } + case Qt::ForegroundRole: { + if (pack.broken) { + // FIXME: Hardcoded color + return QColor(255, 0, 50); + } else if (pack.bugged) { + // FIXME: Hardcoded color + // bugged pack, currently only indicates bugged xml + return QColor(244, 229, 66); + } + return {}; + } + case Qt::DisplayRole: + return pack.name; + case Qt::SizeHintRole: + return QSize(0, 58); + // Custom data + case UserDataTypes::TITLE: + return pack.name; + case UserDataTypes::DESCRIPTION: + return pack.description; + case UserDataTypes::INSTALLED: + return false; + default: + break; + } + + return {}; +} + +void ListModel::fill(ModpackList modpacks_) +{ + beginResetModel(); + this->modpacks = modpacks_; + endResetModel(); +} + +void ListModel::addPack(const Modpack& modpack) +{ + beginResetModel(); + this->modpacks.append(modpack); + endResetModel(); +} + +void ListModel::clear() +{ + beginResetModel(); + modpacks.clear(); + endResetModel(); +} + +Modpack ListModel::at(int row) +{ + return modpacks.at(row); +} + +void ListModel::remove(int row) +{ + if (row < 0 || row >= modpacks.size()) { + qWarning() << "Attempt to remove FTB modpacks with invalid row" << row; + return; + } + beginRemoveRows(QModelIndex(), row, row); + modpacks.removeAt(row); + endRemoveRows(); +} + +void ListModel::logoLoaded(QString logo, QIcon out) +{ + m_loadingLogos.removeAll(logo); + m_logoMap.insert(logo, out); + emit dataChanged(createIndex(0, 0), createIndex(1, 0)); +} + +void ListModel::logoFailed(QString logo) +{ + m_failedLogos.append(logo); + m_loadingLogos.removeAll(logo); +} + +void ListModel::requestLogo(QString file) +{ + if (m_loadingLogos.contains(file) || m_failedLogos.contains(file)) { + return; + } + + MetaEntryPtr entry = APPLICATION->metacache()->resolveEntry("FTBPacks", QString("logos/%1").arg(file)); + NetJob* job = new NetJob(QString("FTB Icon Download for %1").arg(file), APPLICATION->network()); + job->setAskRetry(false); + job->addNetAction(Net::ApiDownload::makeCached(QUrl(QString(BuildConfig.LEGACY_FTB_CDN_BASE_URL + "static/%1").arg(file)), entry)); + + auto fullPath = entry->getFullPath(); + connect(job, &NetJob::finished, this, [this, file, fullPath, job] { + job->deleteLater(); + emit logoLoaded(file, QIcon(fullPath)); + if (waitingCallbacks.contains(file)) { + waitingCallbacks.value(file)(fullPath); + } + }); + + connect(job, &NetJob::failed, this, [this, file, job] { + job->deleteLater(); + emit logoFailed(file); + }); + + job->start(); + + m_loadingLogos.append(file); +} + +void ListModel::getLogo(const QString& logo, LogoCallback callback) +{ + if (m_logoMap.contains(logo)) { + callback(APPLICATION->metacache()->resolveEntry("FTBPacks", QString("logos/%1").arg(logo))->getFullPath()); + } else { + requestLogo(logo); + } +} + +Qt::ItemFlags ListModel::flags(const QModelIndex& index) const +{ + return QAbstractListModel::flags(index); +} + +} // namespace LegacyFTB diff --git a/launcher/ui/pages/modplatform/legacy_ftb/ListModel.h b/launcher/ui/pages/modplatform/legacy_ftb/ListModel.h new file mode 100644 index 0000000..e4477c9 --- /dev/null +++ b/launcher/ui/pages/modplatform/legacy_ftb/ListModel.h @@ -0,0 +1,73 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include + +#include + +namespace LegacyFTB { + +using FTBLogoMap = QMap; +using LogoCallback = std::function; + +class FilterModel : public QSortFilterProxyModel { + Q_OBJECT + public: + FilterModel(QObject* parent = Q_NULLPTR); + enum Sorting { ByName, ByGameVersion }; + const QMap getAvailableSortings(); + QString translateCurrentSorting(); + void setSorting(Sorting sorting); + Sorting getCurrentSorting(); + void setSearchTerm(QString term); + + protected: + bool filterAcceptsRow(int sourceRow, const QModelIndex& sourceParent) const override; + bool lessThan(const QModelIndex& left, const QModelIndex& right) const override; + + private: + QMap sortings; + Sorting currentSorting; + QString searchTerm; +}; + +class ListModel : public QAbstractListModel { + Q_OBJECT + private: + ModpackList modpacks; + QStringList m_failedLogos; + QStringList m_loadingLogos; + FTBLogoMap m_logoMap; + QMap waitingCallbacks; + + void requestLogo(QString file); + QString translatePackType(PackType type) const; + + private slots: + void logoFailed(QString logo); + void logoLoaded(QString logo, QIcon out); + + public: + ListModel(QObject* parent); + ~ListModel(); + int rowCount(const QModelIndex& parent) const override; + int columnCount(const QModelIndex& parent) const override; + QVariant data(const QModelIndex& index, int role) const override; + Qt::ItemFlags flags(const QModelIndex& index) const override; + + void fill(ModpackList modpacks); + void addPack(const Modpack& modpack); + void clear(); + void remove(int row); + + Modpack at(int row); + void getLogo(const QString& logo, LogoCallback callback); +}; + +} // namespace LegacyFTB diff --git a/launcher/ui/pages/modplatform/legacy_ftb/Page.cpp b/launcher/ui/pages/modplatform/legacy_ftb/Page.cpp new file mode 100644 index 0000000..be4d316 --- /dev/null +++ b/launcher/ui/pages/modplatform/legacy_ftb/Page.cpp @@ -0,0 +1,390 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "Page.h" +#include "StringUtils.h" +#include "ui/widgets/ProjectItem.h" +#include "ui_Page.h" + +#include + +#include "Application.h" + +#include "ui/dialogs/CustomMessageBox.h" +#include "ui/dialogs/NewInstanceDialog.h" + +#include "ListModel.h" +#include "modplatform/legacy_ftb/PackFetchTask.h" +#include "modplatform/legacy_ftb/PackInstallTask.h" +#include "modplatform/legacy_ftb/PrivatePackManager.h" + +namespace LegacyFTB { + +Page::Page(NewInstanceDialog* dialog, QWidget* parent) : QWidget(parent), dialog(dialog), ui(new Ui::Page) +{ + ftbFetchTask.reset(new PackFetchTask(APPLICATION->network())); + ftbPrivatePacks.reset(new PrivatePackManager()); + + ui->setupUi(this); + + { + publicFilterModel = new FilterModel(this); + publicListModel = new ListModel(this); + publicFilterModel->setSourceModel(publicListModel); + + ui->publicPackList->setModel(publicFilterModel); + ui->publicPackList->setSortingEnabled(true); + ui->publicPackList->header()->hide(); + ui->publicPackList->setIndentation(0); + ui->publicPackList->setIconSize(QSize(42, 42)); + + for (int i = 0; i < publicFilterModel->getAvailableSortings().size(); i++) { + ui->sortByBox->addItem(publicFilterModel->getAvailableSortings().keys().at(i)); + } + + ui->sortByBox->setCurrentText(publicFilterModel->translateCurrentSorting()); + } + + { + thirdPartyFilterModel = new FilterModel(this); + thirdPartyModel = new ListModel(this); + thirdPartyFilterModel->setSourceModel(thirdPartyModel); + + ui->thirdPartyPackList->setModel(thirdPartyFilterModel); + ui->thirdPartyPackList->setSortingEnabled(true); + ui->thirdPartyPackList->header()->hide(); + ui->thirdPartyPackList->setIndentation(0); + ui->thirdPartyPackList->setIconSize(QSize(42, 42)); + + thirdPartyFilterModel->setSorting(publicFilterModel->getCurrentSorting()); + } + + { + privateFilterModel = new FilterModel(this); + privateListModel = new ListModel(this); + privateFilterModel->setSourceModel(privateListModel); + + ui->privatePackList->setModel(privateFilterModel); + ui->privatePackList->setSortingEnabled(true); + ui->privatePackList->header()->hide(); + ui->privatePackList->setIndentation(0); + ui->privatePackList->setIconSize(QSize(42, 42)); + + privateFilterModel->setSorting(publicFilterModel->getCurrentSorting()); + } + + ui->versionSelectionBox->view()->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + ui->versionSelectionBox->view()->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); + ui->versionSelectionBox->view()->parentWidget()->setMaximumHeight(300); + + connect(ui->sortByBox, &QComboBox::currentTextChanged, this, &Page::onSortingSelectionChanged); + connect(ui->versionSelectionBox, &QComboBox::currentTextChanged, this, &Page::onVersionSelectionItemChanged); + + connect(ui->searchEdit, &QLineEdit::textChanged, this, &Page::triggerSearch); + + connect(ui->publicPackList->selectionModel(), &QItemSelectionModel::currentChanged, this, &Page::onPublicPackSelectionChanged); + connect(ui->thirdPartyPackList->selectionModel(), &QItemSelectionModel::currentChanged, this, &Page::onThirdPartyPackSelectionChanged); + connect(ui->privatePackList->selectionModel(), &QItemSelectionModel::currentChanged, this, &Page::onPrivatePackSelectionChanged); + + connect(ui->addPackBtn, &QPushButton::clicked, this, &Page::onAddPackClicked); + connect(ui->removePackBtn, &QPushButton::clicked, this, &Page::onRemovePackClicked); + + connect(ui->tabWidget, &QTabWidget::currentChanged, this, &Page::onTabChanged); + + // ui->modpackInfo->setOpenExternalLinks(true); + + ui->publicPackList->selectionModel()->reset(); + ui->thirdPartyPackList->selectionModel()->reset(); + ui->privatePackList->selectionModel()->reset(); + + ui->publicPackList->setItemDelegate(new ProjectItemDelegate(this)); + ui->thirdPartyPackList->setItemDelegate(new ProjectItemDelegate(this)); + ui->privatePackList->setItemDelegate(new ProjectItemDelegate(this)); + onTabChanged(ui->tabWidget->currentIndex()); +} + +Page::~Page() +{ + delete ui; +} + +bool Page::shouldDisplay() const +{ + return true; +} + +void Page::openedImpl() +{ + if (!initialized) { + connect(ftbFetchTask.get(), &PackFetchTask::finished, this, &Page::ftbPackDataDownloadSuccessfully); + connect(ftbFetchTask.get(), &PackFetchTask::failed, this, &Page::ftbPackDataDownloadFailed); + connect(ftbFetchTask.get(), &PackFetchTask::aborted, this, &Page::ftbPackDataDownloadAborted); + + connect(ftbFetchTask.get(), &PackFetchTask::privateFileDownloadFinished, this, &Page::ftbPrivatePackDataDownloadSuccessfully); + connect(ftbFetchTask.get(), &PackFetchTask::privateFileDownloadFailed, this, &Page::ftbPrivatePackDataDownloadFailed); + + ftbFetchTask->fetch(); + ftbPrivatePacks->load(); + ftbFetchTask->fetchPrivate(ftbPrivatePacks->getCurrentPackCodes().values()); + initialized = true; + } + suggestCurrent(); +} + +void Page::retranslate() +{ + ui->retranslateUi(this); +} + +void Page::suggestCurrent() +{ + if (!isOpened) { + return; + } + + if (selected.broken || selectedVersion.isEmpty()) { + dialog->setSuggestedPack(); + return; + } + + dialog->setSuggestedPack(selected.name, selectedVersion, new PackInstallTask(APPLICATION->network(), selected, selectedVersion)); + QString editedLogoName = selected.logo; + if (!selected.logo.toLower().startsWith("ftb")) { + editedLogoName = "ftb_" + editedLogoName; + } + + if (selected.type == PackType::Public) { + publicListModel->getLogo(selected.logo, + [this, editedLogoName](QString logo) { dialog->setSuggestedIconFromFile(logo, editedLogoName); }); + } else if (selected.type == PackType::ThirdParty) { + thirdPartyModel->getLogo(selected.logo, + [this, editedLogoName](QString logo) { dialog->setSuggestedIconFromFile(logo, editedLogoName); }); + } else if (selected.type == PackType::Private) { + privateListModel->getLogo(selected.logo, + [this, editedLogoName](QString logo) { dialog->setSuggestedIconFromFile(logo, editedLogoName); }); + } +} + +void Page::ftbPackDataDownloadSuccessfully(ModpackList publicPacks, ModpackList thirdPartyPacks) +{ + publicListModel->fill(publicPacks); + thirdPartyModel->fill(thirdPartyPacks); +} + +void Page::ftbPackDataDownloadFailed(QString reason) +{ + CustomMessageBox::selectable(this, tr("Error"), reason, QMessageBox::Critical)->show(); +} + +void Page::ftbPackDataDownloadAborted() +{ + CustomMessageBox::selectable(this, tr("Task aborted"), tr("The task has been aborted by the user."), QMessageBox::Information)->show(); +} + +void Page::ftbPrivatePackDataDownloadSuccessfully(const Modpack& pack) +{ + privateListModel->addPack(pack); +} + +void Page::ftbPrivatePackDataDownloadFailed([[maybe_unused]] QString reason, QString packCode) +{ + auto reply = QMessageBox::question(this, tr("FTB private packs"), + tr("Failed to download pack information for code %1.\nShould it be removed now?").arg(packCode)); + if (reply == QMessageBox::Yes) { + ftbPrivatePacks->remove(packCode); + } +} + +void Page::onPublicPackSelectionChanged(QModelIndex now, [[maybe_unused]] QModelIndex prev) +{ + if (!now.isValid()) { + onPackSelectionChanged(); + return; + } + QVariant raw = publicFilterModel->data(now, Qt::UserRole); + Q_ASSERT(raw.canConvert()); + auto selectedPack = raw.value(); + onPackSelectionChanged(&selectedPack); +} + +void Page::onThirdPartyPackSelectionChanged(QModelIndex now, [[maybe_unused]] QModelIndex prev) +{ + if (!now.isValid()) { + onPackSelectionChanged(); + return; + } + QVariant raw = thirdPartyFilterModel->data(now, Qt::UserRole); + Q_ASSERT(raw.canConvert()); + auto selectedPack = raw.value(); + onPackSelectionChanged(&selectedPack); +} + +void Page::onPrivatePackSelectionChanged(QModelIndex now, [[maybe_unused]] QModelIndex prev) +{ + if (!now.isValid()) { + onPackSelectionChanged(); + return; + } + QVariant raw = privateFilterModel->data(now, Qt::UserRole); + Q_ASSERT(raw.canConvert()); + auto selectedPack = raw.value(); + onPackSelectionChanged(&selectedPack); +} + +void Page::onPackSelectionChanged(Modpack* pack) +{ + ui->versionSelectionBox->clear(); + if (pack) { + currentModpackInfo->setHtml(StringUtils::htmlListPatch("Pack by " + pack->author + "" + "
    Minecraft " + pack->mcVersion + + "
    " + "
    " + pack->description + "
    • " + + pack->mods.replace(";", "
    • ") + "
    ")); + bool currentAdded = false; + + for (int i = 0; i < pack->oldVersions.size(); i++) { + if (pack->currentVersion == pack->oldVersions.at(i)) { + currentAdded = true; + } + ui->versionSelectionBox->addItem(pack->oldVersions.at(i)); + } + + if (!currentAdded) { + ui->versionSelectionBox->addItem(pack->currentVersion); + } + selected = *pack; + } else { + currentModpackInfo->setHtml(""); + ui->versionSelectionBox->clear(); + if (isOpened) { + dialog->setSuggestedPack(); + } + return; + } + suggestCurrent(); +} + +void Page::onVersionSelectionItemChanged(QString version) +{ + if (version.isNull() || version.isEmpty()) { + selectedVersion = ""; + return; + } + + selectedVersion = version; + suggestCurrent(); +} + +void Page::onSortingSelectionChanged(QString sort) +{ + FilterModel::Sorting toSet = publicFilterModel->getAvailableSortings().value(sort); + publicFilterModel->setSorting(toSet); + thirdPartyFilterModel->setSorting(toSet); + privateFilterModel->setSorting(toSet); +} + +void Page::onTabChanged(int tab) +{ + if (tab == 1) { + currentModel = thirdPartyFilterModel; + currentList = ui->thirdPartyPackList; + currentModpackInfo = ui->thirdPartyPackDescription; + } else if (tab == 2) { + currentModel = privateFilterModel; + currentList = ui->privatePackList; + currentModpackInfo = ui->privatePackDescription; + } else { + currentModel = publicFilterModel; + currentList = ui->publicPackList; + currentModpackInfo = ui->publicPackDescription; + } + + triggerSearch(); + + currentList->selectionModel()->reset(); + QModelIndex idx = currentList->currentIndex(); + if (idx.isValid()) { + QVariant raw = currentModel->data(idx, Qt::UserRole); + Q_ASSERT(raw.canConvert()); + auto pack = raw.value(); + onPackSelectionChanged(&pack); + } else { + onPackSelectionChanged(); + } +} + +void Page::onAddPackClicked() +{ + bool ok; + QString text = QInputDialog::getText(this, tr("Add FTB pack"), tr("Enter pack code:"), QLineEdit::Normal, QString(), &ok); + if (ok && !text.isEmpty()) { + ftbPrivatePacks->add(text); + ftbFetchTask->fetchPrivate({ text }); + } +} + +void Page::onRemovePackClicked() +{ + auto index = ui->privatePackList->currentIndex(); + if (!index.isValid()) { + return; + } + auto row = index.row(); + Modpack pack = privateListModel->at(row); + auto answer = QMessageBox::question(this, tr("Remove pack"), tr("Are you sure you want to remove pack %1?").arg(pack.name), + QMessageBox::Yes | QMessageBox::No); + if (answer != QMessageBox::Yes) { + return; + } + + ftbPrivatePacks->remove(pack.packCode); + privateListModel->remove(row); + onPackSelectionChanged(); +} + +void Page::triggerSearch() +{ + currentModel->setSearchTerm(ui->searchEdit->text()); +} + +void Page::setSearchTerm(QString term) +{ + ui->searchEdit->setText(term); +} + +QString Page::getSerachTerm() const +{ + return ui->searchEdit->text(); +} +} // namespace LegacyFTB diff --git a/launcher/ui/pages/modplatform/legacy_ftb/Page.h b/launcher/ui/pages/modplatform/legacy_ftb/Page.h new file mode 100644 index 0000000..db70ae7 --- /dev/null +++ b/launcher/ui/pages/modplatform/legacy_ftb/Page.h @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +#include "QObjectPtr.h" +#include "modplatform/legacy_ftb/PackFetchTask.h" +#include "modplatform/legacy_ftb/PackHelpers.h" +#include "ui/pages/modplatform/ModpackProviderBasePage.h" + +class NewInstanceDialog; + +namespace LegacyFTB { + +namespace Ui { +class Page; +} + +class ListModel; +class FilterModel; +class PrivatePackManager; + +class Page : public QWidget, public ModpackProviderBasePage { + Q_OBJECT + + public: + explicit Page(NewInstanceDialog* dialog, QWidget* parent = 0); + virtual ~Page(); + QString displayName() const override { return "FTB Legacy"; } + QIcon icon() const override { return QIcon::fromTheme("ftb_logo"); } + QString id() const override { return "legacy_ftb"; } + QString helpPage() const override { return "FTB-legacy"; } + bool shouldDisplay() const override; + void openedImpl() override; + void retranslate() override; + + /** Programatically set the term in the search bar. */ + virtual void setSearchTerm(QString) override; + /** Get the current term in the search bar. */ + virtual QString getSerachTerm() const override; + + private: + void suggestCurrent(); + void onPackSelectionChanged(Modpack* pack = nullptr); + + private slots: + void ftbPackDataDownloadSuccessfully(ModpackList publicPacks, ModpackList thirdPartyPacks); + void ftbPackDataDownloadFailed(QString reason); + void ftbPackDataDownloadAborted(); + + void ftbPrivatePackDataDownloadSuccessfully(const Modpack& pack); + void ftbPrivatePackDataDownloadFailed(QString reason, QString packCode); + + void onSortingSelectionChanged(QString data); + void onVersionSelectionItemChanged(QString data); + + void onPublicPackSelectionChanged(QModelIndex first, QModelIndex second); + void onThirdPartyPackSelectionChanged(QModelIndex first, QModelIndex second); + void onPrivatePackSelectionChanged(QModelIndex first, QModelIndex second); + + void onTabChanged(int tab); + + void onAddPackClicked(); + void onRemovePackClicked(); + + void triggerSearch(); + + private: + FilterModel* currentModel = nullptr; + QTreeView* currentList = nullptr; + QTextBrowser* currentModpackInfo = nullptr; + + bool initialized = false; + Modpack selected; + QString selectedVersion; + + ListModel* publicListModel = nullptr; + FilterModel* publicFilterModel = nullptr; + + ListModel* thirdPartyModel = nullptr; + FilterModel* thirdPartyFilterModel = nullptr; + + ListModel* privateListModel = nullptr; + FilterModel* privateFilterModel = nullptr; + + unique_qobject_ptr ftbFetchTask; + std::unique_ptr ftbPrivatePacks; + + NewInstanceDialog* dialog = nullptr; + + Ui::Page* ui = nullptr; +}; + +} // namespace LegacyFTB diff --git a/launcher/ui/pages/modplatform/legacy_ftb/Page.ui b/launcher/ui/pages/modplatform/legacy_ftb/Page.ui new file mode 100644 index 0000000..d3d696b --- /dev/null +++ b/launcher/ui/pages/modplatform/legacy_ftb/Page.ui @@ -0,0 +1,170 @@ + + + LegacyFTB::Page + + + + 0 + 0 + 709 + 602 + + + + + + + + + Search and filter... + + + true + + + + + + + + + 0 + + + + Public + + + + + + + 16777215 + 16777215 + + + + true + + + QAbstractItemView::ScrollPerPixel + + + + + + + true + + + + + + + + 3rd Party + + + + + + true + + + + + + + + 16777215 + 16777215 + + + + true + + + QAbstractItemView::ScrollPerPixel + + + + + + + + Private + + + + + + + 16777215 + 16777215 + + + + true + + + QAbstractItemView::ScrollPerPixel + + + + + + + Add pack + + + + + + + Remove selected pack + + + + + + + true + + + + + + + + + + + + + + 265 + 0 + + + + + + + + Version selected: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + + + + + + diff --git a/launcher/ui/pages/modplatform/modrinth/ModrinthModel.cpp b/launcher/ui/pages/modplatform/modrinth/ModrinthModel.cpp new file mode 100644 index 0000000..05cd297 --- /dev/null +++ b/launcher/ui/pages/modplatform/modrinth/ModrinthModel.cpp @@ -0,0 +1,349 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ModrinthModel.h" + +#include "Application.h" +#include "BuildConfig.h" +#include "Json.h" +#include "modplatform/ModIndex.h" +#include "modplatform/modrinth/ModrinthAPI.h" +#include "net/NetJob.h" +#include "ui/widgets/ProjectItem.h" + +#include "net/ApiDownload.h" + +#include +#include + +namespace Modrinth { + +ModpackListModel::ModpackListModel(ModrinthPage* parent) : QAbstractListModel(parent), m_parent(parent) {} + +auto ModpackListModel::debugName() const -> QString +{ + return m_parent->debugName(); +} + +/******** Make data requests ********/ + +void ModpackListModel::fetchMore(const QModelIndex& parent) +{ + if (parent.isValid()) + return; + if (m_nextSearchOffset == 0) { + qWarning() << "fetchMore with 0 offset is wrong..."; + return; + } + performPaginatedSearch(); +} + +auto ModpackListModel::data(const QModelIndex& index, int role) const -> QVariant +{ + int pos = index.row(); + if (pos >= m_modpacks.size() || pos < 0 || !index.isValid()) { + return QString("INVALID INDEX %1").arg(pos); + } + + auto pack = m_modpacks.at(pos); + switch (role) { + case Qt::ToolTipRole: { + if (pack->description.length() > 100) { + // some magic to prevent to long tooltips and replace html linebreaks + QString edit = pack->description.left(97); + edit = edit.left(edit.lastIndexOf("
    ")).left(edit.lastIndexOf(" ")).append("..."); + return edit; + } + return pack->description; + } + case Qt::DecorationRole: { + if (m_logoMap.contains(pack->logoName)) + return m_logoMap.value(pack->logoName); + + QIcon icon = QIcon::fromTheme("screenshot-placeholder"); + ((ModpackListModel*)this)->requestLogo(pack->logoName, pack->logoUrl); + return icon; + } + case Qt::UserRole: { + QVariant v; + v.setValue(pack); + return v; + } + case Qt::SizeHintRole: + return QSize(0, 58); + // Custom data + case UserDataTypes::TITLE: + return pack->name; + case UserDataTypes::DESCRIPTION: + return pack->description; + case UserDataTypes::INSTALLED: + return false; + default: + break; + } + + return {}; +} + +bool ModpackListModel::setData(const QModelIndex& index, const QVariant& value, [[maybe_unused]] int role) +{ + int pos = index.row(); + if (pos >= m_modpacks.size() || pos < 0 || !index.isValid()) + return false; + + m_modpacks[pos] = value.value(); + + return true; +} + +void ModpackListModel::performPaginatedSearch() +{ + if (hasActiveSearchJob()) + return; + static const ModrinthAPI api; + + if (m_currentSearchTerm.startsWith("#")) { + auto projectId = m_currentSearchTerm.mid(1); + if (!projectId.isEmpty()) { + ResourceAPI::Callback callbacks; + + callbacks.on_fail = [this](QString reason, int) { searchRequestFailed(reason); }; + callbacks.on_succeed = [this](auto& pack) { searchRequestForOneSucceeded(pack); }; + callbacks.on_abort = [this] { + qCritical() << "Search task aborted by an unknown reason!"; + searchRequestFailed("Aborted"); + }; + auto project = std::make_shared(); + project->addonId = projectId; + if (auto job = api.getProjectInfo({ project }, std::move(callbacks)); job) { + m_jobPtr = job; + m_jobPtr->start(); + } + return; + } + } // TODO: Move to standalone API + ResourceAPI::SortingMethod sort{}; + sort.name = m_currentSort; + + ResourceAPI::Callback> callbacks{}; + + callbacks.on_succeed = [this](auto& doc) { searchRequestFinished(doc); }; + callbacks.on_fail = [this](QString reason, int) { searchRequestFailed(reason); }; + callbacks.on_abort = [this] { + qCritical() << "Search task aborted by an unknown reason!"; + searchRequestFailed("Aborted"); + }; + + auto netJob = api.searchProjects({ ModPlatform::ResourceType::Modpack, m_nextSearchOffset, m_currentSearchTerm, sort, m_filter->loaders, + m_filter->versions, ModPlatform::Side::NoSide, m_filter->categoryIds, m_filter->openSource }, + std::move(callbacks)); + + m_jobPtr = netJob; + m_jobPtr->start(); +} + +void ModpackListModel::refresh() +{ + if (hasActiveSearchJob()) { + m_jobPtr->abort(); + m_searchState = ResetRequested; + return; + } + + beginResetModel(); + m_modpacks.clear(); + endResetModel(); + m_searchState = None; + + m_nextSearchOffset = 0; + performPaginatedSearch(); +} + +static auto sortFromIndex(int index) -> QString +{ + switch (index) { + default: + case 0: + return "relevance"; + case 1: + return "downloads"; + case 2: + return "follows"; + case 3: + return "newest"; + case 4: + return "updated"; + } +} + +void ModpackListModel::searchWithTerm(const QString& term, + const int sort, + std::shared_ptr filter, + bool filterChanged) +{ + if (sort > 5 || sort < 0) + return; + + auto sort_str = sortFromIndex(sort); + + if (m_currentSearchTerm == term && m_currentSearchTerm.isNull() == term.isNull() && m_currentSort == sort_str && !filterChanged) { + return; + } + + m_currentSearchTerm = term; + m_currentSort = sort_str; + m_filter = filter; + + refresh(); +} + +void ModpackListModel::getLogo(const QString& logo, const QString& logoUrl, LogoCallback callback) +{ + if (m_logoMap.contains(logo)) { + callback(APPLICATION->metacache()->resolveEntry(m_parent->metaEntryBase(), QString("logos/%1").arg(logo))->getFullPath()); + } else { + requestLogo(logo, logoUrl); + } +} + +void ModpackListModel::requestLogo(QString logo, QString url) +{ + if (m_loadingLogos.contains(logo) || m_failedLogos.contains(logo) || url.isEmpty()) { + return; + } + + MetaEntryPtr entry = APPLICATION->metacache()->resolveEntry(m_parent->metaEntryBase(), QString("logos/%1").arg(logo)); + auto job = new NetJob(QString("%1 Icon Download %2").arg(m_parent->debugName()).arg(logo), APPLICATION->network()); + job->setAskRetry(false); + job->addNetAction(Net::ApiDownload::makeCached(QUrl(url), entry)); + + auto fullPath = entry->getFullPath(); + connect(job, &NetJob::succeeded, this, [this, logo, fullPath, job] { + job->deleteLater(); + emit logoLoaded(logo, QIcon(fullPath)); + if (m_waitingCallbacks.contains(logo)) { + m_waitingCallbacks.value(logo)(fullPath); + } + }); + + connect(job, &NetJob::failed, this, [this, logo, job] { + job->deleteLater(); + emit logoFailed(logo); + }); + + job->start(); + m_loadingLogos.append(logo); +} + +/******** Request callbacks ********/ + +void ModpackListModel::logoLoaded(QString logo, QIcon out) +{ + m_loadingLogos.removeAll(logo); + m_logoMap.insert(logo, out); + for (int i = 0; i < m_modpacks.size(); i++) { + if (m_modpacks[i]->logoName == logo) { + emit dataChanged(createIndex(i, 0), createIndex(i, 0), { Qt::DecorationRole }); + } + } +} + +void ModpackListModel::logoFailed(QString logo) +{ + m_failedLogos.append(logo); + m_loadingLogos.removeAll(logo); +} + +void ModpackListModel::searchRequestFinished(QList& newList) +{ + m_jobPtr.reset(); + + if (newList.size() < m_modpacks_per_page) { + m_searchState = Finished; + } else { + m_nextSearchOffset += m_modpacks_per_page; + m_searchState = CanPossiblyFetchMore; + } + + // When you have a Qt build with assertions turned on, proceeding here will abort the application + if (newList.size() == 0) + return; + + beginInsertRows(QModelIndex(), m_modpacks.size(), m_modpacks.size() + newList.size() - 1); + m_modpacks.append(newList); + endInsertRows(); +} + +void ModpackListModel::searchRequestForOneSucceeded(ModPlatform::IndexedPack::Ptr pack) +{ + m_jobPtr.reset(); + + beginInsertRows(QModelIndex(), m_modpacks.size(), m_modpacks.size() + 1); + m_modpacks.append(pack); + endInsertRows(); +} + +void ModpackListModel::searchRequestFailed(QString) +{ + auto failed_action = dynamic_cast(m_jobPtr.get())->getFailedActions().at(0); + if (failed_action->replyStatusCode() == -1) { + // Network error + QMessageBox::critical(nullptr, tr("Error"), tr("A network error occurred. Could not load modpacks.")); + } else if (failed_action->replyStatusCode() == 409) { + // 409 Gone, notify user to update + QMessageBox::critical(nullptr, tr("Error"), + //: %1 refers to the launcher itself + QString("%1 %2") + .arg(m_parent->displayName()) + .arg(tr("API version too old!\nPlease update %1!").arg(BuildConfig.LAUNCHER_DISPLAYNAME))); + } + m_jobPtr.reset(); + + if (m_searchState == ResetRequested) { + beginResetModel(); + m_modpacks.clear(); + endResetModel(); + + m_nextSearchOffset = 0; + performPaginatedSearch(); + } else { + m_searchState = Finished; + } +} + +} // namespace Modrinth + +/******** Helpers ********/ diff --git a/launcher/ui/pages/modplatform/modrinth/ModrinthModel.h b/launcher/ui/pages/modplatform/modrinth/ModrinthModel.h new file mode 100644 index 0000000..96f6fd1 --- /dev/null +++ b/launcher/ui/pages/modplatform/modrinth/ModrinthModel.h @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +#include "modplatform/ModIndex.h" +#include "net/NetJob.h" +#include "ui/pages/modplatform/modrinth/ModrinthPage.h" + +class ModPage; +class Version; + +namespace Modrinth { + +using LogoMap = QMap; +using LogoCallback = std::function; + +class ModpackListModel : public QAbstractListModel { + Q_OBJECT + + public: + ModpackListModel(ModrinthPage* parent); + ~ModpackListModel() override = default; + + inline auto rowCount(const QModelIndex& parent) const -> int override { return parent.isValid() ? 0 : m_modpacks.size(); }; + inline auto columnCount(const QModelIndex& parent) const -> int override { return parent.isValid() ? 0 : 1; }; + inline auto flags(const QModelIndex& index) const -> Qt::ItemFlags override { return QAbstractListModel::flags(index); }; + + auto debugName() const -> QString; + + /* Retrieve information from the model at a given index with the given role */ + auto data(const QModelIndex& index, int role) const -> QVariant override; + bool setData(const QModelIndex& index, const QVariant& value, int role) override; + + inline void setActiveJob(NetJob::Ptr ptr) { m_jobPtr = ptr; } + + /* Ask the API for more information */ + void fetchMore(const QModelIndex& parent) override; + void refresh(); + void searchWithTerm(const QString& term, int sort, std::shared_ptr filter, bool filterChanged); + + bool hasActiveSearchJob() const { return m_jobPtr && m_jobPtr->isRunning(); } + Task::Ptr activeSearchJob() { return hasActiveSearchJob() ? m_jobPtr : nullptr; } + + void getLogo(const QString& logo, const QString& logoUrl, LogoCallback callback); + + inline auto canFetchMore(const QModelIndex& parent) const -> bool override + { + return parent.isValid() ? false : m_searchState == CanPossiblyFetchMore; + }; + + public slots: + void searchRequestFinished(QList& doc_all); + void searchRequestFailed(QString reason); + void searchRequestForOneSucceeded(ModPlatform::IndexedPack::Ptr); + + protected slots: + + void logoFailed(QString logo); + void logoLoaded(QString logo, QIcon out); + + void performPaginatedSearch(); + + protected: + void requestLogo(QString file, QString url); + + inline auto getMineVersions() const -> std::vector; + + protected: + ModrinthPage* m_parent; + + QList m_modpacks; + + LogoMap m_logoMap; + QMap m_waitingCallbacks; + QStringList m_failedLogos; + QStringList m_loadingLogos; + + QString m_currentSearchTerm; + QString m_currentSort; + std::shared_ptr m_filter; + int m_nextSearchOffset = 0; + enum SearchState { None, CanPossiblyFetchMore, ResetRequested, Finished } m_searchState = None; + + Task::Ptr m_jobPtr; + + std::shared_ptr m_allResponse = std::make_shared(); + QByteArray m_specific_response; + + int m_modpacks_per_page = 20; +}; +} // namespace Modrinth diff --git a/launcher/ui/pages/modplatform/modrinth/ModrinthPage.cpp b/launcher/ui/pages/modplatform/modrinth/ModrinthPage.cpp new file mode 100644 index 0000000..4798583 --- /dev/null +++ b/launcher/ui/pages/modplatform/modrinth/ModrinthPage.cpp @@ -0,0 +1,382 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * Copyright 2021-2022 kb1000 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ModrinthPage.h" +#include "Version.h" +#include "modplatform/ModIndex.h" +#include "modplatform/modrinth/ModrinthAPI.h" +#include "ui/dialogs/CustomMessageBox.h" +#include "ui_ModrinthPage.h" + +#include "ModrinthModel.h" + +#include "BuildConfig.h" +#include "InstanceImportTask.h" +#include "Json.h" +#include "Markdown.h" +#include "StringUtils.h" + +#include "ui/widgets/ProjectItem.h" + +#include "net/ApiDownload.h" + +#include +#include +#include + +ModrinthPage::ModrinthPage(NewInstanceDialog* dialog, QWidget* parent) + : QWidget(parent), m_ui(new Ui::ModrinthPage), m_dialog(dialog), m_fetch_progress(this, false) +{ + m_ui->setupUi(this); + createFilterWidget(); + + m_ui->searchEdit->installEventFilter(this); + m_model = new Modrinth::ModpackListModel(this); + m_ui->packView->setModel(m_model); + + m_ui->versionSelectionBox->view()->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + m_ui->versionSelectionBox->view()->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); + m_ui->versionSelectionBox->view()->parentWidget()->setMaximumHeight(300); + + m_search_timer.setTimerType(Qt::TimerType::CoarseTimer); + m_search_timer.setSingleShot(true); + + connect(&m_search_timer, &QTimer::timeout, this, &ModrinthPage::triggerSearch); + + m_fetch_progress.hideIfInactive(true); + m_fetch_progress.setFixedHeight(24); + m_fetch_progress.progressFormat(""); + + m_ui->verticalLayout->insertWidget(1, &m_fetch_progress); + + m_ui->sortByBox->addItem(tr("Sort by Relevance")); + m_ui->sortByBox->addItem(tr("Sort by Total Downloads")); + m_ui->sortByBox->addItem(tr("Sort by Follows")); + m_ui->sortByBox->addItem(tr("Sort by Newest")); + m_ui->sortByBox->addItem(tr("Sort by Last Updated")); + + connect(m_ui->sortByBox, &QComboBox::currentIndexChanged, this, &ModrinthPage::triggerSearch); + connect(m_ui->packView->selectionModel(), &QItemSelectionModel::currentChanged, this, &ModrinthPage::onSelectionChanged); + connect(m_ui->versionSelectionBox, &QComboBox::currentIndexChanged, this, &ModrinthPage::onVersionSelectionChanged); + + m_ui->packView->setItemDelegate(new ProjectItemDelegate(this)); + m_ui->packDescription->setMetaEntry(metaEntryBase()); +} + +ModrinthPage::~ModrinthPage() +{ + delete m_ui; +} + +void ModrinthPage::retranslate() +{ + m_ui->retranslateUi(this); +} + +void ModrinthPage::openedImpl() +{ + BasePage::openedImpl(); + suggestCurrent(); + triggerSearch(); +} + +bool ModrinthPage::eventFilter(QObject* watched, QEvent* event) +{ + if (watched == m_ui->searchEdit && event->type() == QEvent::KeyPress) { + auto* keyEvent = reinterpret_cast(event); + if (keyEvent->key() == Qt::Key_Return) { + this->triggerSearch(); + keyEvent->accept(); + return true; + } else { + if (m_search_timer.isActive()) + m_search_timer.stop(); + + m_search_timer.start(350); + } + } + return QObject::eventFilter(watched, event); +} + +void ModrinthPage::onSelectionChanged(QModelIndex curr, [[maybe_unused]] QModelIndex prev) +{ + m_ui->versionSelectionBox->clear(); + + if (!curr.isValid()) { + if (isOpened) { + m_dialog->setSuggestedPack(); + } + return; + } + + m_current = m_model->data(curr, Qt::UserRole).value(); + auto name = m_current->name; + + if (!m_current->extraDataLoaded) { + qDebug() << "Loading modrinth modpack information"; + ResourceAPI::Callback callbacks; + + auto id = m_current->addonId; + callbacks.on_fail = [this](QString reason, int) { + CustomMessageBox::selectable(this, tr("Error"), reason, QMessageBox::Critical)->exec(); + }; + callbacks.on_succeed = [this, id, curr](auto& pack) { + if (id != m_current->addonId) { + return; // wrong request? + } + + QVariant current_updated; + current_updated.setValue(pack); + + if (!m_model->setData(curr, current_updated, Qt::UserRole)) + qWarning() << "Failed to cache extra info for the current pack!"; + + suggestCurrent(); + updateUI(); + }; + if (auto netJob = m_api.getProjectInfo({ m_current }, std::move(callbacks)); netJob) { + m_job = netJob; + m_job->start(); + } + + } else + updateUI(); + + if (!m_current->versionsLoaded || m_filterWidget->changed()) { + qDebug() << "Loading modrinth modpack versions"; + + ResourceAPI::Callback> callbacks{}; + + auto addonId = m_current->addonId; + // Use default if no callbacks are set + callbacks.on_succeed = [this, curr, addonId](auto& doc) { + if (addonId != m_current->addonId) { + return; // wrong request + } + + m_current->versions = doc; + m_current->versionsLoaded = true; + auto pred = [this](const ModPlatform::IndexedVersion& v) { + if (auto filter = m_filterWidget->getFilter()) + return !filter->checkModpackFilters(v); + return false; + }; +#if QT_VERSION >= QT_VERSION_CHECK(6, 1, 0) + m_current->versions.removeIf(pred); +#else + for (auto it = m_current->versions.begin(); it != m_current->versions.end();) + if (pred(*it)) + it = m_current->versions.erase(it); + else + ++it; +#endif + for (const auto& version : m_current->versions) { + m_ui->versionSelectionBox->addItem(version.getVersionDisplayString(), QVariant(version.fileId)); + } + + QVariant current_updated; + current_updated.setValue(m_current); + + if (!m_model->setData(curr, current_updated, Qt::UserRole)) + qWarning() << "Failed to cache versions for the current pack!"; + + suggestCurrent(); + }; + callbacks.on_fail = [this](QString reason, int) { + CustomMessageBox::selectable(this, tr("Error"), reason, QMessageBox::Critical)->exec(); + }; + + auto netJob = m_api.getProjectVersions({ m_current, {}, {}, ModPlatform::ResourceType::Modpack }, std::move(callbacks)); + + m_job2 = netJob; + m_job2->start(); + + } else { + for (auto version : m_current->versions) { + if (!version.version.contains(version.version)) + m_ui->versionSelectionBox->addItem(QString("%1 - %2").arg(version.version, version.version_number), + QVariant(version.fileId)); + else + m_ui->versionSelectionBox->addItem(version.version, QVariant(version.fileId)); + } + + suggestCurrent(); + } +} + +void ModrinthPage::updateUI() +{ + QString text = ""; + + if (m_current->websiteUrl.isEmpty()) + text = m_current->name; + else + text = "websiteUrl + "\">" + m_current->name + ""; + + if (!m_current->authors.empty()) { + auto authorToStr = [](ModPlatform::ModpackAuthor& author) { + if (author.url.isEmpty()) { + return author.name; + } + return QString("%2").arg(author.url, author.name); + }; + QStringList authorStrs; + for (auto& author : m_current->authors) { + authorStrs.push_back(authorToStr(author)); + } + text += "
    " + tr(" by ") + authorStrs.join(", "); + } + + if (m_current->extraDataLoaded) { + if (m_current->extraData.status == "archived") { + text += "

    " + tr("This project has been archived. It will not receive any further updates unless the author decides " + "to unarchive the project."); + } + + if (!m_current->extraData.donate.isEmpty()) { + text += "

    " + tr("Donate information: "); + auto donateToStr = [](ModPlatform::DonationData& donate) -> QString { + return QString("%2").arg(donate.url, donate.platform); + }; + QStringList donates; + for (auto& donate : m_current->extraData.donate) { + donates.append(donateToStr(donate)); + } + text += donates.join(", "); + } + + if (!m_current->extraData.issuesUrl.isEmpty() || !m_current->extraData.sourceUrl.isEmpty() || + !m_current->extraData.wikiUrl.isEmpty() || !m_current->extraData.discordUrl.isEmpty()) { + text += "

    " + tr("External links:") + "
    "; + } + + if (!m_current->extraData.issuesUrl.isEmpty()) + text += "- " + tr("Issues: %1").arg(m_current->extraData.issuesUrl) + "
    "; + if (!m_current->extraData.wikiUrl.isEmpty()) + text += "- " + tr("Wiki: %1").arg(m_current->extraData.wikiUrl) + "
    "; + if (!m_current->extraData.sourceUrl.isEmpty()) + text += "- " + tr("Source code: %1").arg(m_current->extraData.sourceUrl) + "
    "; + if (!m_current->extraData.discordUrl.isEmpty()) + text += "- " + tr("Discord: %1").arg(m_current->extraData.discordUrl) + "
    "; + } + + text += "
    "; + + text += markdownToHTML(m_current->extraData.body.toUtf8()); + + m_ui->packDescription->setHtml(StringUtils::htmlListPatch(text + m_current->description)); + m_ui->packDescription->flush(); +} + +void ModrinthPage::suggestCurrent() +{ + if (!isOpened) { + return; + } + + if (m_selectedVersion.isEmpty()) { + m_dialog->setSuggestedPack(); + return; + } + + for (auto& ver : m_current->versions) { + if (ver.fileId == m_selectedVersion) { + QMap extra_info; + extra_info.insert("pack_id", m_current->addonId.toString()); + extra_info.insert("pack_version_id", ver.fileId.toString()); + + m_dialog->setSuggestedPack(m_current->name, ver.version, new InstanceImportTask(ver.downloadUrl, this, std::move(extra_info))); + QString editedLogoName = "modrinth_" + m_current->logoName; + m_model->getLogo(m_current->logoName, m_current->logoUrl, + [this, editedLogoName](QString logo) { m_dialog->setSuggestedIconFromFile(logo, editedLogoName); }); + + break; + } + } +} + +void ModrinthPage::triggerSearch() +{ + m_ui->packView->selectionModel()->setCurrentIndex({}, QItemSelectionModel::SelectionFlag::ClearAndSelect); + m_ui->packView->clearSelection(); + m_ui->packDescription->clear(); + m_ui->versionSelectionBox->clear(); + bool filterChanged = m_filterWidget->changed(); + m_model->searchWithTerm(m_ui->searchEdit->text(), m_ui->sortByBox->currentIndex(), m_filterWidget->getFilter(), filterChanged); + m_fetch_progress.watch(m_model->activeSearchJob().get()); +} + +void ModrinthPage::onVersionSelectionChanged(int index) +{ + if (index == -1) { + m_selectedVersion = ""; + return; + } + m_selectedVersion = m_ui->versionSelectionBox->itemData(index).toString(); + suggestCurrent(); +} + +void ModrinthPage::setSearchTerm(QString term) +{ + m_ui->searchEdit->setText(term); +} + +QString ModrinthPage::getSerachTerm() const +{ + return m_ui->searchEdit->text(); +} + +void ModrinthPage::createFilterWidget() +{ + auto widget = ModFilterWidget::create(nullptr, true); + m_filterWidget.swap(widget); + auto old = m_ui->splitter->replaceWidget(0, m_filterWidget.get()); + // because we replaced the widget we also need to delete it + if (old) { + delete old; + } + + connect(m_ui->filterButton, &QPushButton::clicked, this, [this] { m_filterWidget->setHidden(!m_filterWidget->isHidden()); }); + + connect(m_filterWidget.get(), &ModFilterWidget::filterChanged, this, &ModrinthPage::triggerSearch); + auto [categoriesTask, response] = ModrinthAPI::getModCategories(); + m_categoriesTask = categoriesTask; + connect(m_categoriesTask.get(), &Task::succeeded, [this, response]() { + auto categories = ModrinthAPI::loadCategories(*response, "modpack"); + m_filterWidget->setCategories(categories); + }); + m_categoriesTask->start(); +} diff --git a/launcher/ui/pages/modplatform/modrinth/ModrinthPage.h b/launcher/ui/pages/modplatform/modrinth/ModrinthPage.h new file mode 100644 index 0000000..4ca41a3 --- /dev/null +++ b/launcher/ui/pages/modplatform/modrinth/ModrinthPage.h @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * Copyright 2021-2022 kb1000 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "modplatform/ModIndex.h" +#include "modplatform/modrinth/ModrinthAPI.h" +#include "ui/dialogs/NewInstanceDialog.h" + +#include "ui/pages/modplatform/ModpackProviderBasePage.h" +#include "ui/widgets/ModFilterWidget.h" +#include "ui/widgets/ProgressWidget.h" + +#include +#include + +namespace Ui { +class ModrinthPage; +} + +namespace Modrinth { +class ModpackListModel; +} + +class ModrinthPage : public QWidget, public ModpackProviderBasePage { + Q_OBJECT + + public: + explicit ModrinthPage(NewInstanceDialog* dialog, QWidget* parent = nullptr); + ~ModrinthPage() override; + + QString displayName() const override { return tr("Modrinth"); } + QIcon icon() const override { return QIcon::fromTheme("modrinth"); } + QString id() const override { return "modrinth"; } + QString helpPage() const override { return "Modrinth-platform"; } + + inline QString debugName() const { return "Modrinth"; } + inline QString metaEntryBase() const { return "ModrinthModpacks"; }; + + ModPlatform::IndexedPack::Ptr getCurrent() { return m_current; } + void suggestCurrent(); + + void updateUI(); + + void retranslate() override; + void openedImpl() override; + bool eventFilter(QObject* watched, QEvent* event) override; + + /** Programatically set the term in the search bar. */ + virtual void setSearchTerm(QString) override; + /** Get the current term in the search bar. */ + virtual QString getSerachTerm() const override; + + private slots: + void onSelectionChanged(QModelIndex first, QModelIndex second); + void onVersionSelectionChanged(int index); + void triggerSearch(); + void createFilterWidget(); + + private: + Ui::ModrinthPage* m_ui; + NewInstanceDialog* m_dialog; + Modrinth::ModpackListModel* m_model; + + ModPlatform::IndexedPack::Ptr m_current; + QString m_selectedVersion; + + ProgressWidget m_fetch_progress; + + // Used to do instant searching with a delay to cache quick changes + QTimer m_search_timer; + + std::unique_ptr m_filterWidget; + Task::Ptr m_categoriesTask; + + ModrinthAPI m_api; + Task::Ptr m_job; + Task::Ptr m_job2; +}; diff --git a/launcher/ui/pages/modplatform/modrinth/ModrinthPage.ui b/launcher/ui/pages/modplatform/modrinth/ModrinthPage.ui new file mode 100644 index 0000000..c68d01d --- /dev/null +++ b/launcher/ui/pages/modplatform/modrinth/ModrinthPage.ui @@ -0,0 +1,108 @@ + + + ModrinthPage + + + + 0 + 0 + 800 + 600 + + + + + + + + + Filter options + + + + + + + Search and filter... + + + + + + + + + + 0 + 0 + + + + Qt::Horizontal + + + + + Qt::ScrollBarAlwaysOff + + + true + + + + 48 + 48 + + + + QAbstractItemView::ScrollPerPixel + + + + + true + + + true + + + + + + + + + + + + + Version selected: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + + + + + + ProjectDescriptionPage + QTextBrowser +
    ui/widgets/ProjectDescriptionPage.h
    +
    +
    + + packView + packDescription + sortByBox + versionSelectionBox + + + +
    diff --git a/launcher/ui/pages/modplatform/modrinth/ModrinthResourcePages.cpp b/launcher/ui/pages/modplatform/modrinth/ModrinthResourcePages.cpp new file mode 100644 index 0000000..c290b67 --- /dev/null +++ b/launcher/ui/pages/modplatform/modrinth/ModrinthResourcePages.cpp @@ -0,0 +1,176 @@ +// SPDX-FileCopyrightText: 2023 flowln +// +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ModrinthResourcePages.h" +#include "ui/pages/modplatform/DataPackModel.h" +#include "ui_ResourcePage.h" + +#include "modplatform/modrinth/ModrinthAPI.h" + +#include "ui/dialogs/ResourceDownloadDialog.h" + +namespace ResourceDownload { + +ModrinthModPage::ModrinthModPage(ModDownloadDialog* dialog, BaseInstance& instance) : ModPage(dialog, instance) +{ + m_model = new ModModel(instance, new ModrinthAPI(), Modrinth::debugName(), Modrinth::metaEntryBase()); + m_ui->packView->setModel(m_model); + + addSortings(); + + // sometimes Qt just ignores virtual slots and doesn't work as intended it seems, + // so it's best not to connect them in the parent's constructor... + connect(m_ui->sortByBox, &QComboBox::currentIndexChanged, this, &ModrinthModPage::triggerSearch); + connect(m_ui->packView->selectionModel(), &QItemSelectionModel::currentChanged, this, &ModrinthModPage::onSelectionChanged); + connect(m_ui->versionSelectionBox, &QComboBox::currentIndexChanged, this, &ModrinthModPage::onVersionSelectionChanged); + connect(m_ui->resourceSelectionButton, &QPushButton::clicked, this, &ModrinthModPage::onResourceSelected); + + m_ui->packDescription->setMetaEntry(metaEntryBase()); +} + +ModrinthResourcePackPage::ModrinthResourcePackPage(ResourcePackDownloadDialog* dialog, BaseInstance& instance) + : ResourcePackResourcePage(dialog, instance) +{ + m_model = new ResourcePackResourceModel(instance, new ModrinthAPI(), Modrinth::debugName(), Modrinth::metaEntryBase()); + m_ui->packView->setModel(m_model); + + addSortings(); + + // sometimes Qt just ignores virtual slots and doesn't work as intended it seems, + // so it's best not to connect them in the parent's constructor... + connect(m_ui->sortByBox, &QComboBox::currentIndexChanged, this, &ModrinthResourcePackPage::triggerSearch); + connect(m_ui->packView->selectionModel(), &QItemSelectionModel::currentChanged, this, &ModrinthResourcePackPage::onSelectionChanged); + connect(m_ui->versionSelectionBox, &QComboBox::currentIndexChanged, this, &ModrinthResourcePackPage::onVersionSelectionChanged); + connect(m_ui->resourceSelectionButton, &QPushButton::clicked, this, &ModrinthResourcePackPage::onResourceSelected); + + m_ui->packDescription->setMetaEntry(metaEntryBase()); +} + +ModrinthTexturePackPage::ModrinthTexturePackPage(TexturePackDownloadDialog* dialog, BaseInstance& instance) + : TexturePackResourcePage(dialog, instance) +{ + m_model = new TexturePackResourceModel(instance, new ModrinthAPI(), Modrinth::debugName(), Modrinth::metaEntryBase()); + m_ui->packView->setModel(m_model); + + addSortings(); + + // sometimes Qt just ignores virtual slots and doesn't work as intended it seems, + // so it's best not to connect them in the parent's constructor... + connect(m_ui->sortByBox, &QComboBox::currentIndexChanged, this, &ModrinthTexturePackPage::triggerSearch); + connect(m_ui->packView->selectionModel(), &QItemSelectionModel::currentChanged, this, &ModrinthTexturePackPage::onSelectionChanged); + connect(m_ui->versionSelectionBox, &QComboBox::currentIndexChanged, this, &ModrinthTexturePackPage::onVersionSelectionChanged); + connect(m_ui->resourceSelectionButton, &QPushButton::clicked, this, &ModrinthTexturePackPage::onResourceSelected); + + m_ui->packDescription->setMetaEntry(metaEntryBase()); +} + +ModrinthShaderPackPage::ModrinthShaderPackPage(ShaderPackDownloadDialog* dialog, BaseInstance& instance) + : ShaderPackResourcePage(dialog, instance) +{ + m_model = new ShaderPackResourceModel(instance, new ModrinthAPI(), Modrinth::debugName(), Modrinth::metaEntryBase()); + m_ui->packView->setModel(m_model); + + addSortings(); + + // sometimes Qt just ignores virtual slots and doesn't work as intended it seems, + // so it's best not to connect them in the parent's constructor... + connect(m_ui->sortByBox, &QComboBox::currentIndexChanged, this, &ModrinthShaderPackPage::triggerSearch); + connect(m_ui->packView->selectionModel(), &QItemSelectionModel::currentChanged, this, &ModrinthShaderPackPage::onSelectionChanged); + connect(m_ui->versionSelectionBox, &QComboBox::currentIndexChanged, this, &ModrinthShaderPackPage::onVersionSelectionChanged); + connect(m_ui->resourceSelectionButton, &QPushButton::clicked, this, &ModrinthShaderPackPage::onResourceSelected); + + m_ui->packDescription->setMetaEntry(metaEntryBase()); +} + +ModrinthDataPackPage::ModrinthDataPackPage(DataPackDownloadDialog* dialog, BaseInstance& instance) : DataPackResourcePage(dialog, instance) +{ + m_model = new DataPackResourceModel(instance, new ModrinthAPI(), Modrinth::debugName(), Modrinth::metaEntryBase()); + m_ui->packView->setModel(m_model); + + addSortings(); + + // sometimes Qt just ignores virtual slots and doesn't work as intended it seems, + // so it's best not to connect them in the parent's constructor... + connect(m_ui->sortByBox, &QComboBox::currentIndexChanged, this, &ModrinthDataPackPage::triggerSearch); + connect(m_ui->packView->selectionModel(), &QItemSelectionModel::currentChanged, this, &ModrinthDataPackPage::onSelectionChanged); + connect(m_ui->versionSelectionBox, &QComboBox::currentIndexChanged, this, &ModrinthDataPackPage::onVersionSelectionChanged); + connect(m_ui->resourceSelectionButton, &QPushButton::clicked, this, &ModrinthDataPackPage::onResourceSelected); + + m_ui->packDescription->setMetaEntry(metaEntryBase()); +} + +// I don't know why, but doing this on the parent class makes it so that +// other mod providers start loading before being selected, at least with +// my Qt, so we need to implement this in every derived class... +auto ModrinthModPage::shouldDisplay() const -> bool +{ + return true; +} +auto ModrinthResourcePackPage::shouldDisplay() const -> bool +{ + return true; +} +auto ModrinthTexturePackPage::shouldDisplay() const -> bool +{ + return true; +} +auto ModrinthShaderPackPage::shouldDisplay() const -> bool +{ + return true; +} +auto ModrinthDataPackPage::shouldDisplay() const -> bool +{ + return true; +} + +std::unique_ptr ModrinthModPage::createFilterWidget() +{ + return ModFilterWidget::create(&static_cast(m_baseInstance), true); +} + +void ModrinthModPage::prepareProviderCategories() +{ + auto [categoriesTask, response] = ModrinthAPI::getModCategories(); + m_categoriesTask = categoriesTask; + connect(m_categoriesTask.get(), &Task::succeeded, [this, response]() { + auto categories = ModrinthAPI::loadModCategories(*response); + m_filter_widget->setCategories(categories); + }); + m_categoriesTask->start(); +}; +} // namespace ResourceDownload diff --git a/launcher/ui/pages/modplatform/modrinth/ModrinthResourcePages.h b/launcher/ui/pages/modplatform/modrinth/ModrinthResourcePages.h new file mode 100644 index 0000000..3f41a3d --- /dev/null +++ b/launcher/ui/pages/modplatform/modrinth/ModrinthResourcePages.h @@ -0,0 +1,195 @@ +// SPDX-FileCopyrightText: 2023 flowln +// +// SPDX-License-Identifier: GPL-3.0-only AND Apache-2.0 +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "modplatform/ResourceAPI.h" + +#include "ui/pages/modplatform/DataPackPage.h" +#include "ui/pages/modplatform/ModPage.h" +#include "ui/pages/modplatform/ResourcePackPage.h" +#include "ui/pages/modplatform/ShaderPackPage.h" +#include "ui/pages/modplatform/TexturePackPage.h" + +namespace ResourceDownload { + +namespace Modrinth { +static inline QString displayName() +{ + return "Modrinth"; +} +static inline QIcon icon() +{ + return QIcon::fromTheme("modrinth"); +} +static inline QString id() +{ + return "modrinth"; +} +static inline QString debugName() +{ + return "Modrinth"; +} +static inline QString metaEntryBase() +{ + return "ModrinthPacks"; +} +} // namespace Modrinth + +class ModrinthModPage : public ModPage { + Q_OBJECT + + public: + static ModrinthModPage* create(ModDownloadDialog* dialog, BaseInstance& instance) + { + return ModPage::create(dialog, instance); + } + + ModrinthModPage(ModDownloadDialog* dialog, BaseInstance& instance); + ~ModrinthModPage() override = default; + + bool shouldDisplay() const override; + + inline auto displayName() const -> QString override { return Modrinth::displayName(); } + inline auto icon() const -> QIcon override { return Modrinth::icon(); } + inline auto id() const -> QString override { return Modrinth::id(); } + inline auto debugName() const -> QString override { return Modrinth::debugName(); } + inline auto metaEntryBase() const -> QString override { return Modrinth::metaEntryBase(); } + + inline auto helpPage() const -> QString override { return "Mod-platform"; } + + std::unique_ptr createFilterWidget() override; + + protected: + virtual void prepareProviderCategories() override; + Task::Ptr m_categoriesTask; +}; + +class ModrinthResourcePackPage : public ResourcePackResourcePage { + Q_OBJECT + + public: + static ModrinthResourcePackPage* create(ResourcePackDownloadDialog* dialog, BaseInstance& instance) + { + return ResourcePackResourcePage::create(dialog, instance); + } + + ModrinthResourcePackPage(ResourcePackDownloadDialog* dialog, BaseInstance& instance); + ~ModrinthResourcePackPage() override = default; + + bool shouldDisplay() const override; + + inline auto displayName() const -> QString override { return Modrinth::displayName(); } + inline auto icon() const -> QIcon override { return Modrinth::icon(); } + inline auto id() const -> QString override { return Modrinth::id(); } + inline auto debugName() const -> QString override { return Modrinth::debugName(); } + inline auto metaEntryBase() const -> QString override { return Modrinth::metaEntryBase(); } + + inline auto helpPage() const -> QString override { return ""; } +}; + +class ModrinthTexturePackPage : public TexturePackResourcePage { + Q_OBJECT + + public: + static ModrinthTexturePackPage* create(TexturePackDownloadDialog* dialog, BaseInstance& instance) + { + return TexturePackResourcePage::create(dialog, instance); + } + + ModrinthTexturePackPage(TexturePackDownloadDialog* dialog, BaseInstance& instance); + ~ModrinthTexturePackPage() override = default; + + bool shouldDisplay() const override; + + inline auto displayName() const -> QString override { return Modrinth::displayName(); } + inline auto icon() const -> QIcon override { return Modrinth::icon(); } + inline auto id() const -> QString override { return Modrinth::id(); } + inline auto debugName() const -> QString override { return Modrinth::debugName(); } + inline auto metaEntryBase() const -> QString override { return Modrinth::metaEntryBase(); } + + inline auto helpPage() const -> QString override { return ""; } +}; + +class ModrinthShaderPackPage : public ShaderPackResourcePage { + Q_OBJECT + + public: + static ModrinthShaderPackPage* create(ShaderPackDownloadDialog* dialog, BaseInstance& instance) + { + return ShaderPackResourcePage::create(dialog, instance); + } + + ModrinthShaderPackPage(ShaderPackDownloadDialog* dialog, BaseInstance& instance); + ~ModrinthShaderPackPage() override = default; + + bool shouldDisplay() const override; + + inline auto displayName() const -> QString override { return Modrinth::displayName(); } + inline auto icon() const -> QIcon override { return Modrinth::icon(); } + inline auto id() const -> QString override { return Modrinth::id(); } + inline auto debugName() const -> QString override { return Modrinth::debugName(); } + inline auto metaEntryBase() const -> QString override { return Modrinth::metaEntryBase(); } + + inline auto helpPage() const -> QString override { return ""; } +}; + +class ModrinthDataPackPage : public DataPackResourcePage { + Q_OBJECT + + public: + static ModrinthDataPackPage* create(DataPackDownloadDialog* dialog, BaseInstance& instance) + { + return DataPackResourcePage::create(dialog, instance); + } + + ModrinthDataPackPage(DataPackDownloadDialog* dialog, BaseInstance& instance); + ~ModrinthDataPackPage() override = default; + + bool shouldDisplay() const override; + + inline auto displayName() const -> QString override { return Modrinth::displayName(); } + inline auto icon() const -> QIcon override { return Modrinth::icon(); } + inline auto id() const -> QString override { return Modrinth::id(); } + inline auto debugName() const -> QString override { return Modrinth::debugName(); } + inline auto metaEntryBase() const -> QString override { return Modrinth::metaEntryBase(); } + + inline auto helpPage() const -> QString override { return ""; } +}; + +} // namespace ResourceDownload diff --git a/launcher/ui/pages/modplatform/technic/TechnicData.h b/launcher/ui/pages/modplatform/technic/TechnicData.h new file mode 100644 index 0000000..1049d1f --- /dev/null +++ b/launcher/ui/pages/modplatform/technic/TechnicData.h @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2021-2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2020-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +namespace Technic { +struct Modpack { + QString slug; + + QString name; + QString logoUrl; + QString logoName; + + bool broken = true; + + QString url; + bool isSolder = false; + QString minecraftVersion; + + bool metadataLoaded = false; + QString websiteUrl; + QString author; + QString description; + QString currentVersion; + + bool versionsLoaded = false; + QString recommended; + QList versions; +}; +} // namespace Technic + +Q_DECLARE_METATYPE(Technic::Modpack) diff --git a/launcher/ui/pages/modplatform/technic/TechnicModel.cpp b/launcher/ui/pages/modplatform/technic/TechnicModel.cpp new file mode 100644 index 0000000..af2aed6 --- /dev/null +++ b/launcher/ui/pages/modplatform/technic/TechnicModel.cpp @@ -0,0 +1,318 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2021 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2020-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "TechnicModel.h" +#include "Application.h" +#include "BuildConfig.h" +#include "Json.h" +#include "settings/SettingsObject.h" + +#include "net/ApiDownload.h" +#include "ui/widgets/ProjectItem.h" + +#include +#include +#include + +Technic::ListModel::ListModel(QObject* parent) : QAbstractListModel(parent) {} + +Technic::ListModel::~ListModel() {} + +QVariant Technic::ListModel::data(const QModelIndex& index, int role) const +{ + int pos = index.row(); + if (pos >= modpacks.size() || pos < 0 || !index.isValid()) { + return QString("INVALID INDEX %1").arg(pos); + } + + Modpack pack = modpacks.at(pos); + switch (role) { + case Qt::ToolTipRole: { + if (pack.description.length() > 100) { + // some magic to prevent to long tooltips and replace html linebreaks + QString edit = pack.description.left(97); + edit = edit.left(edit.lastIndexOf("
    ")).left(edit.lastIndexOf(" ")).append("..."); + return edit; + } + return pack.description; + } + case Qt::DecorationRole: { + if (m_logoMap.contains(pack.logoName)) { + return (m_logoMap.value(pack.logoName)); + } + QIcon icon = QIcon::fromTheme("screenshot-placeholder"); + ((ListModel*)this)->requestLogo(pack.logoName, pack.logoUrl); + return icon; + } + case Qt::UserRole: { + QVariant v; + v.setValue(pack); + return v; + } + case Qt::DisplayRole: + return pack.name; + case Qt::SizeHintRole: + return QSize(0, 58); + // Custom data + case UserDataTypes::TITLE: + return pack.name; + case UserDataTypes::DESCRIPTION: + return pack.description; + case UserDataTypes::INSTALLED: + return false; + default: + break; + } + + return {}; +} + +int Technic::ListModel::columnCount(const QModelIndex& parent) const +{ + return parent.isValid() ? 0 : 1; +} + +int Technic::ListModel::rowCount(const QModelIndex& parent) const +{ + return parent.isValid() ? 0 : modpacks.size(); +} + +void Technic::ListModel::searchWithTerm(const QString& term) +{ + if (currentSearchTerm == term && currentSearchTerm.isNull() == term.isNull()) { + return; + } + currentSearchTerm = term; + if (hasActiveSearchJob()) { + jobPtr->abort(); + searchState = ResetRequested; + return; + } + + beginResetModel(); + modpacks.clear(); + endResetModel(); + searchState = None; + + performSearch(); +} + +void Technic::ListModel::performSearch() +{ + if (hasActiveSearchJob()) + return; + + auto netJob = makeShared("Technic::Search", APPLICATION->network()); + QString searchUrl = ""; + if (currentSearchTerm.isEmpty()) { + searchUrl = QString("%1trending?build=%2").arg(BuildConfig.TECHNIC_API_BASE_URL, BuildConfig.TECHNIC_API_BUILD); + searchMode = List; + } else if (currentSearchTerm.startsWith("http://api.technicpack.net/modpack/")) { + searchUrl = QString("https://%1?build=%2").arg(currentSearchTerm.mid(7), BuildConfig.TECHNIC_API_BUILD); + searchMode = Single; + } else if (currentSearchTerm.startsWith("https://api.technicpack.net/modpack/")) { + searchUrl = QString("%1?build=%2").arg(currentSearchTerm, BuildConfig.TECHNIC_API_BUILD); + searchMode = Single; + } else if (currentSearchTerm.startsWith("#")) { + searchUrl = QString("https://api.technicpack.net/modpack/%1?build=%2").arg(currentSearchTerm.mid(1), BuildConfig.TECHNIC_API_BUILD); + searchMode = Single; + } else { + searchUrl = + QString("%1search?build=%2&q=%3").arg(BuildConfig.TECHNIC_API_BASE_URL, BuildConfig.TECHNIC_API_BUILD, currentSearchTerm); + searchMode = List; + } + auto clientId = APPLICATION->settings()->get("TechnicClientID").toString(); + if (!clientId.isEmpty()) { + searchUrl += "?cid=" + clientId; + } + auto [action, response] = Net::ApiDownload::makeByteArray(QUrl(searchUrl)); + netJob->addNetAction(action); + jobPtr = netJob; + jobPtr->start(); + connect(netJob.get(), &NetJob::succeeded, this, [this, response] { searchRequestFinished(response); }); + connect(netJob.get(), &NetJob::failed, this, &ListModel::searchRequestFailed); +} + +void Technic::ListModel::searchRequestFinished(QByteArray* responsePtr) +{ + // NOTE(TheKodeToad): moving the response out to avoid it from being destroyed by jobPtr.reset() + QByteArray response = std::move(*responsePtr); + jobPtr.reset(); + + QJsonParseError parse_error; + QJsonDocument doc = QJsonDocument::fromJson(response, &parse_error); + if (parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from Technic at" << parse_error.offset << "reason:" << parse_error.errorString(); + qWarning() << response; + return; + } + + QList newList; + try { + auto root = Json::requireObject(doc); + + switch (searchMode) { + case List: { + auto objs = Json::requireArray(root, "modpacks"); + for (auto technicPack : objs) { + Modpack pack; + auto technicPackObject = Json::requireObject(technicPack); + pack.name = Json::requireString(technicPackObject, "name"); + pack.slug = Json::requireString(technicPackObject, "slug"); + if (pack.slug == "vanilla") + continue; + + auto rawURL = technicPackObject["iconUrl"].toString("null"); + if (rawURL == "null") { + pack.logoUrl = "null"; + pack.logoName = "null"; + } else { + pack.logoUrl = rawURL; + pack.logoName = pack.slug + "." + QFileInfo(QUrl(rawURL).fileName()).suffix(); + } + pack.broken = false; + newList.append(pack); + } + break; + } + case Single: { + if (root.contains("error")) { + // Invalid API url + break; + } + + Modpack pack; + pack.name = Json::requireString(root, "displayName"); + pack.slug = Json::requireString(root, "name"); + + if (root.contains("icon")) { + auto iconObj = Json::requireObject(root, "icon"); + auto iconUrl = Json::requireString(iconObj, "url"); + + pack.logoUrl = iconUrl; + pack.logoName = pack.slug + "." + QFileInfo(QUrl(iconUrl).fileName()).suffix(); + } else { + pack.logoUrl = "null"; + pack.logoName = "null"; + } + + pack.broken = false; + newList.append(pack); + break; + } + } + } catch (const JSONValidationError& err) { + qCritical() << "Couldn't parse technic search results:" << err.cause(); + return; + } + searchState = Finished; + + // When you have a Qt build with assertions turned on, proceeding here will abort the application + if (newList.size() == 0) + return; + + beginInsertRows(QModelIndex(), modpacks.size(), modpacks.size() + newList.size() - 1); + modpacks.append(newList); + endInsertRows(); +} + +void Technic::ListModel::getLogo(const QString& logo, const QString& logoUrl, Technic::LogoCallback callback) +{ + if (m_logoMap.contains(logo)) { + callback(APPLICATION->metacache()->resolveEntry("TechnicPacks", QString("logos/%1").arg(logo))->getFullPath()); + } else { + requestLogo(logo, logoUrl); + } +} + +void Technic::ListModel::searchRequestFailed() +{ + jobPtr.reset(); + + if (searchState == ResetRequested) { + beginResetModel(); + modpacks.clear(); + endResetModel(); + + performSearch(); + } else { + searchState = Finished; + } +} + +void Technic::ListModel::logoLoaded(QString logo, QString out) +{ + m_loadingLogos.removeAll(logo); + m_logoMap.insert(logo, QIcon(out)); + for (int i = 0; i < modpacks.size(); i++) { + if (modpacks[i].logoName == logo) { + emit dataChanged(createIndex(i, 0), createIndex(i, 0), { Qt::DecorationRole }); + } + } +} + +void Technic::ListModel::logoFailed(QString logo) +{ + m_failedLogos.append(logo); + m_loadingLogos.removeAll(logo); +} + +void Technic::ListModel::requestLogo(QString logo, QString url) +{ + if (m_loadingLogos.contains(logo) || m_failedLogos.contains(logo) || logo == "null") { + return; + } + + MetaEntryPtr entry = APPLICATION->metacache()->resolveEntry("TechnicPacks", QString("logos/%1").arg(logo)); + auto job = new NetJob(QString("Technic Icon Download %1").arg(logo), APPLICATION->network()); + job->setAskRetry(false); + job->addNetAction(Net::ApiDownload::makeCached(QUrl(url), entry)); + + auto fullPath = entry->getFullPath(); + + connect(job, &NetJob::succeeded, this, [this, logo, fullPath, job] { + job->deleteLater(); + logoLoaded(logo, fullPath); + }); + + connect(job, &NetJob::failed, this, [this, logo, job] { + job->deleteLater(); + logoFailed(logo); + }); + + job->start(); + + m_loadingLogos.append(logo); +} diff --git a/launcher/ui/pages/modplatform/technic/TechnicModel.h b/launcher/ui/pages/modplatform/technic/TechnicModel.h new file mode 100644 index 0000000..872f8b5 --- /dev/null +++ b/launcher/ui/pages/modplatform/technic/TechnicModel.h @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2021 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2020-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +#include "TechnicData.h" +#include "net/NetJob.h" + +namespace Technic { + +using LogoCallback = std::function; + +class ListModel : public QAbstractListModel { + Q_OBJECT + + public: + ListModel(QObject* parent); + virtual ~ListModel(); + + virtual QVariant data(const QModelIndex& index, int role) const; + virtual int columnCount(const QModelIndex& parent) const; + virtual int rowCount(const QModelIndex& parent) const; + + void getLogo(const QString& logo, const QString& logoUrl, LogoCallback callback); + void searchWithTerm(const QString& term); + + bool hasActiveSearchJob() const { return jobPtr && jobPtr->isRunning(); } + Task::Ptr activeSearchJob() { return hasActiveSearchJob() ? jobPtr : nullptr; } + + private slots: + void searchRequestFinished(QByteArray* responsePtr); + void searchRequestFailed(); + + void logoFailed(QString logo); + void logoLoaded(QString logo, QString out); + + private: + void performSearch(); + void requestLogo(QString logo, QString url); + + private: + QList modpacks; + QStringList m_failedLogos; + QStringList m_loadingLogos; + QMap m_logoMap; + QMap waitingCallbacks; + + QString currentSearchTerm; + enum SearchState { None, ResetRequested, Finished } searchState = None; + enum SearchMode { + List, + Single, + } searchMode = List; + NetJob::Ptr jobPtr; +}; + +} // namespace Technic diff --git a/launcher/ui/pages/modplatform/technic/TechnicPage.cpp b/launcher/ui/pages/modplatform/technic/TechnicPage.cpp new file mode 100644 index 0000000..0858d63 --- /dev/null +++ b/launcher/ui/pages/modplatform/technic/TechnicPage.cpp @@ -0,0 +1,365 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2021-2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "TechnicPage.h" +#include "ui/dialogs/CustomMessageBox.h" +#include "ui/widgets/ProjectItem.h" +#include "ui_TechnicPage.h" + +#include + +#include "ui/dialogs/NewInstanceDialog.h" + +#include "BuildConfig.h" +#include "Json.h" +#include "StringUtils.h" +#include "TechnicModel.h" +#include "modplatform/technic/SingleZipPackInstallTask.h" +#include "modplatform/technic/SolderPackInstallTask.h" + +#include "Application.h" +#include "modplatform/technic/SolderPackManifest.h" + +#include "net/ApiDownload.h" + +TechnicPage::TechnicPage(NewInstanceDialog* dialog, QWidget* parent) + : QWidget(parent), ui(new Ui::TechnicPage), dialog(dialog), m_fetch_progress(this, false) +{ + ui->setupUi(this); + ui->searchEdit->installEventFilter(this); + model = new Technic::ListModel(this); + ui->packView->setModel(model); + ui->versionSelectionBox->view()->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + ui->versionSelectionBox->view()->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); + ui->versionSelectionBox->view()->parentWidget()->setMaximumHeight(300); + + m_search_timer.setTimerType(Qt::TimerType::CoarseTimer); + m_search_timer.setSingleShot(true); + + connect(&m_search_timer, &QTimer::timeout, this, &TechnicPage::triggerSearch); + + m_fetch_progress.hideIfInactive(true); + m_fetch_progress.setFixedHeight(24); + m_fetch_progress.progressFormat(""); + + ui->verticalLayout->insertWidget(1, &m_fetch_progress); + + connect(ui->packView->selectionModel(), &QItemSelectionModel::currentChanged, this, &TechnicPage::onSelectionChanged); + connect(ui->versionSelectionBox, &QComboBox::currentTextChanged, this, &TechnicPage::onVersionSelectionChanged); + + ui->packView->setItemDelegate(new ProjectItemDelegate(this)); +} + +bool TechnicPage::eventFilter(QObject* watched, QEvent* event) +{ + if (watched == ui->searchEdit && event->type() == QEvent::KeyPress) { + QKeyEvent* keyEvent = static_cast(event); + if (keyEvent->key() == Qt::Key_Return) { + triggerSearch(); + keyEvent->accept(); + return true; + } else { + if (m_search_timer.isActive()) + m_search_timer.stop(); + + m_search_timer.start(350); + } + } + return QWidget::eventFilter(watched, event); +} + +TechnicPage::~TechnicPage() +{ + delete ui; +} + +bool TechnicPage::shouldDisplay() const +{ + return true; +} + +void TechnicPage::retranslate() +{ + ui->retranslateUi(this); +} + +void TechnicPage::openedImpl() +{ + suggestCurrent(); + triggerSearch(); +} + +void TechnicPage::triggerSearch() +{ + model->searchWithTerm(ui->searchEdit->text()); + m_fetch_progress.watch(model->activeSearchJob().get()); +} + +void TechnicPage::onSelectionChanged(QModelIndex first, [[maybe_unused]] QModelIndex second) +{ + ui->versionSelectionBox->clear(); + + if (!first.isValid()) { + if (isOpened) { + dialog->setSuggestedPack(); + } + return; + } + + QVariant raw = model->data(first, Qt::UserRole); + Q_ASSERT(raw.canConvert()); + current = raw.value(); + suggestCurrent(); +} + +void TechnicPage::suggestCurrent() +{ + if (!isOpened) { + return; + } + if (current.broken) { + dialog->setSuggestedPack(); + return; + } + + QString editedLogoName = "technic_" + current.logoName; + model->getLogo(current.logoName, current.logoUrl, + [this, editedLogoName](QString logo) { dialog->setSuggestedIconFromFile(logo, editedLogoName); }); + + if (current.metadataLoaded) { + metadataLoaded(); + return; + } + + auto netJob = makeShared(QString("Technic::PackMeta(%1)").arg(current.name), APPLICATION->network()); + QString slug = current.slug; + auto [action, responsePtr] = Net::ApiDownload::makeByteArray( + QString("%1modpack/%2?build=%3").arg(BuildConfig.TECHNIC_API_BASE_URL, slug, BuildConfig.TECHNIC_API_BUILD)); + netJob->addNetAction(action); + connect(netJob.get(), &NetJob::succeeded, this, [this, responsePtr, slug] { + // NOTE(TheKodeToad): moving the response out to avoid it from being destroyed by jobPtr.reset() + QByteArray response = std::move(*responsePtr); + jobPtr.reset(); + + if (current.slug != slug) { + return; + } + + QJsonParseError parse_error{}; + QJsonDocument doc = QJsonDocument::fromJson(response, &parse_error); + QJsonObject obj = doc.object(); + if (parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from Technic at" << parse_error.offset + << "reason:" << parse_error.errorString(); + qWarning() << response; + return; + } + if (!obj.contains("url")) { + qWarning() << "Json doesn't contain an url key"; + return; + } + QJsonValueRef url = obj["url"]; + if (url.isString()) { + current.url = url.toString(); + } else { + if (!obj.contains("solder")) { + qWarning() << "Json doesn't contain a valid url or solder key"; + return; + } + QJsonValueRef solderUrl = obj["solder"]; + if (solderUrl.isString()) { + current.url = solderUrl.toString(); + current.isSolder = true; + } else { + qWarning() << "Json doesn't contain a valid url or solder key"; + return; + } + } + + current.minecraftVersion = obj["minecraft"].toString(); + current.websiteUrl = obj["platformUrl"].toString(); + current.author = obj["user"].toString(); + current.description = obj["description"].toString(); + current.currentVersion = obj["version"].toString(); + current.metadataLoaded = true; + + metadataLoaded(); + }); + connect(jobPtr.get(), &NetJob::failed, + [this](QString reason) { CustomMessageBox::selectable(this, tr("Error"), reason, QMessageBox::Critical)->exec(); }); + + jobPtr = netJob; + jobPtr->start(); +} + +// expects current.metadataLoaded to be true +void TechnicPage::metadataLoaded() +{ + QString text = ""; + QString name = current.name; + + if (current.websiteUrl.isEmpty()) + text = name.toHtmlEscaped(); + else + text = "" + name.toHtmlEscaped() + ""; + + if (!current.author.isEmpty()) { + text += "
    " + tr(" by ") + current.author.toHtmlEscaped(); + } + + text += "

    "; + + ui->packDescription->setHtml(StringUtils::htmlListPatch(text + current.description)); + + // Strip trailing forward-slashes from Solder URL's + if (current.isSolder) { + while (current.url.endsWith('/')) + current.url.chop(1); + } + + // Display versions from Solder + if (!current.isSolder) { + // If the pack isn't a Solder pack, it only has the single version + ui->versionSelectionBox->addItem(current.currentVersion); + } else if (current.versionsLoaded) { + // reverse foreach, so that the newest versions are first + for (auto i = current.versions.size(); i--;) { + ui->versionSelectionBox->addItem(current.versions.at(i)); + } + ui->versionSelectionBox->setCurrentText(current.recommended); + } else { + // For now, until the versions are pulled from the Solder instance, display the current + // version so we can display something quicker + ui->versionSelectionBox->addItem(current.currentVersion); + + auto netJob = makeShared(QString("Technic::SolderMeta(%1)").arg(current.name), APPLICATION->network()); + auto url = QString("%1/modpack/%2").arg(current.url, current.slug); + auto [action, response] = Net::ApiDownload::makeByteArray(QUrl(url)); + netJob->addNetAction(action); + + connect(netJob.get(), &NetJob::succeeded, this, [this, response] { onSolderLoaded(response); }); + connect(jobPtr.get(), &NetJob::failed, + [this](QString reason) { CustomMessageBox::selectable(this, tr("Error"), reason, QMessageBox::Critical)->exec(); }); + + jobPtr = netJob; + jobPtr->start(); + } + + selectVersion(); +} + +void TechnicPage::selectVersion() +{ + if (!isOpened) { + return; + } + if (current.broken) { + dialog->setSuggestedPack(); + return; + } + + if (!current.isSolder) { + dialog->setSuggestedPack(current.name, selectedVersion, + new Technic::SingleZipPackInstallTask(current.url, current.minecraftVersion)); + } else { + dialog->setSuggestedPack(current.name, selectedVersion, + new Technic::SolderPackInstallTask(APPLICATION->network(), current.url, current.slug, selectedVersion, + current.minecraftVersion)); + } +} + +void TechnicPage::onSolderLoaded(QByteArray* responsePtr) +{ + // NOTE(TheKodeToad): moving the response out to avoid it from being destroyed by jobPtr.reset() + QByteArray response = std::move(*responsePtr); + jobPtr.reset(); + + auto fallback = [this]() { + current.versionsLoaded = true; + + current.versions.clear(); + current.versions.append(current.currentVersion); + }; + + current.versions.clear(); + + QJsonParseError parse_error{}; + auto doc = QJsonDocument::fromJson(response, &parse_error); + if (parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from Solder at" << parse_error.offset << "reason:" << parse_error.errorString(); + qWarning() << response; + fallback(); + return; + } + auto obj = doc.object(); + + TechnicSolder::Pack pack; + try { + TechnicSolder::loadPack(pack, obj); + } catch (const JSONValidationError& err) { + qCritical() << "Couldn't parse Solder pack metadata:" << err.cause(); + fallback(); + return; + } + + current.versionsLoaded = true; + current.recommended = pack.recommended; + current.versions.append(pack.builds); + + // Finally, let's reload :) + ui->versionSelectionBox->clear(); + metadataLoaded(); +} + +void TechnicPage::onVersionSelectionChanged(QString version) +{ + if (version.isNull() || version.isEmpty()) { + selectedVersion = ""; + return; + } + + selectedVersion = version; + selectVersion(); +} + +void TechnicPage::setSearchTerm(QString term) +{ + ui->searchEdit->setText(term); +} + +QString TechnicPage::getSerachTerm() const +{ + return ui->searchEdit->text(); +} diff --git a/launcher/ui/pages/modplatform/technic/TechnicPage.h b/launcher/ui/pages/modplatform/technic/TechnicPage.h new file mode 100644 index 0000000..466be81 --- /dev/null +++ b/launcher/ui/pages/modplatform/technic/TechnicPage.h @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2021-2022 Jamie Mansfield + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +#include "TechnicData.h" +#include "net/NetJob.h" +#include "ui/pages/modplatform/ModpackProviderBasePage.h" +#include "ui/widgets/ProgressWidget.h" + +namespace Ui { +class TechnicPage; +} + +class NewInstanceDialog; + +namespace Technic { +class ListModel; +} + +class TechnicPage : public QWidget, public ModpackProviderBasePage { + Q_OBJECT + + public: + explicit TechnicPage(NewInstanceDialog* dialog, QWidget* parent = 0); + virtual ~TechnicPage(); + virtual QString displayName() const override { return "Technic"; } + virtual QIcon icon() const override { return QIcon::fromTheme("technic"); } + virtual QString id() const override { return "technic"; } + virtual QString helpPage() const override { return "Technic-platform"; } + virtual bool shouldDisplay() const override; + void retranslate() override; + + void openedImpl() override; + + bool eventFilter(QObject* watched, QEvent* event) override; + + /** Programatically set the term in the search bar. */ + virtual void setSearchTerm(QString) override; + /** Get the current term in the search bar. */ + virtual QString getSerachTerm() const override; + + private: + void suggestCurrent(); + void metadataLoaded(); + void selectVersion(); + + private slots: + void triggerSearch(); + void onSelectionChanged(QModelIndex first, QModelIndex second); + void onSolderLoaded(QByteArray* responsePtr); + void onVersionSelectionChanged(QString data); + + private: + Ui::TechnicPage* ui = nullptr; + NewInstanceDialog* dialog = nullptr; + Technic::ListModel* model = nullptr; + + Technic::Modpack current; + QString selectedVersion; + + NetJob::Ptr jobPtr; + + ProgressWidget m_fetch_progress; + + // Used to do instant searching with a delay to cache quick changes + QTimer m_search_timer; +}; diff --git a/launcher/ui/pages/modplatform/technic/TechnicPage.ui b/launcher/ui/pages/modplatform/technic/TechnicPage.ui new file mode 100644 index 0000000..3193677 --- /dev/null +++ b/launcher/ui/pages/modplatform/technic/TechnicPage.ui @@ -0,0 +1,85 @@ + + + TechnicPage + + + + 0 + 0 + 546 + 405 + + + + + + + Search and filter... + + + + + + + + + true + + + + 48 + 48 + + + + QAbstractItemView::ScrollPerPixel + + + + + + + true + + + + + + + + + + + Qt::Horizontal + + + QSizePolicy::Preferred + + + + 1 + 1 + + + + + + + + Version selected: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + + + + + + diff --git a/launcher/ui/setupwizard/AutoJavaWizardPage.cpp b/launcher/ui/setupwizard/AutoJavaWizardPage.cpp new file mode 100644 index 0000000..06fc907 --- /dev/null +++ b/launcher/ui/setupwizard/AutoJavaWizardPage.cpp @@ -0,0 +1,34 @@ +#include "AutoJavaWizardPage.h" +#include "ui_AutoJavaWizardPage.h" + +#include "Application.h" +#include "settings/SettingsObject.h" + +AutoJavaWizardPage::AutoJavaWizardPage(QWidget* parent) : BaseWizardPage(parent), ui(new Ui::AutoJavaWizardPage) +{ + ui->setupUi(this); +} + +AutoJavaWizardPage::~AutoJavaWizardPage() +{ + delete ui; +} + +void AutoJavaWizardPage::initializePage() {} + +bool AutoJavaWizardPage::validatePage() +{ + auto s = APPLICATION->settings(); + + if (!ui->previousSettingsRadioButton->isChecked()) { + s->set("AutomaticJavaSwitch", true); + s->set("AutomaticJavaDownload", true); + } + s->set("UserAskedAboutAutomaticJavaDownload", true); + return true; +} + +void AutoJavaWizardPage::retranslate() +{ + ui->retranslateUi(this); +} diff --git a/launcher/ui/setupwizard/AutoJavaWizardPage.h b/launcher/ui/setupwizard/AutoJavaWizardPage.h new file mode 100644 index 0000000..fcdf5bd --- /dev/null +++ b/launcher/ui/setupwizard/AutoJavaWizardPage.h @@ -0,0 +1,22 @@ +#pragma once +#include +#include "BaseWizardPage.h" + +namespace Ui { +class AutoJavaWizardPage; +} + +class AutoJavaWizardPage : public BaseWizardPage { + Q_OBJECT + + public: + explicit AutoJavaWizardPage(QWidget* parent = nullptr); + ~AutoJavaWizardPage(); + + void initializePage() override; + bool validatePage() override; + void retranslate() override; + + private: + Ui::AutoJavaWizardPage* ui; +}; diff --git a/launcher/ui/setupwizard/AutoJavaWizardPage.ui b/launcher/ui/setupwizard/AutoJavaWizardPage.ui new file mode 100644 index 0000000..a862524 --- /dev/null +++ b/launcher/ui/setupwizard/AutoJavaWizardPage.ui @@ -0,0 +1,93 @@ + + + AutoJavaWizardPage + + + + 0 + 0 + 400 + 300 + + + + Form + + + + + + <html><head/><body><p><span style=" font-size:14pt; font-weight:600;">New Feature Alert!</span></p></body></html> + + + Qt::RichText + + + true + + + + + + + We've added a feature to automatically download the correct Java version for each version of Minecraft (this can be changed in the Java Settings). Would you like to enable or disable this feature? + + + true + + + + + + + Qt::Horizontal + + + + + + + Enable Auto-Download + + + true + + + buttonGroup + + + + + + + Disable Auto-Download + + + false + + + buttonGroup + + + + + + + Qt::Vertical + + + + 20 + 156 + + + + + + + + + + + + diff --git a/launcher/ui/setupwizard/BaseWizardPage.h b/launcher/ui/setupwizard/BaseWizardPage.h new file mode 100644 index 0000000..b5ea062 --- /dev/null +++ b/launcher/ui/setupwizard/BaseWizardPage.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include + +class BaseWizardPage : public QWizardPage { + public: + explicit BaseWizardPage(QWidget* parent = Q_NULLPTR) : QWizardPage(parent) {} + virtual ~BaseWizardPage() {}; + + virtual bool wantsRefreshButton() { return false; } + virtual void refresh() {} + + protected: + virtual void retranslate() = 0; + void changeEvent(QEvent* event) override + { + if (event->type() == QEvent::LanguageChange) { + retranslate(); + } + QWizardPage::changeEvent(event); + } +}; diff --git a/launcher/ui/setupwizard/JavaWizardPage.cpp b/launcher/ui/setupwizard/JavaWizardPage.cpp new file mode 100644 index 0000000..baeab2d --- /dev/null +++ b/launcher/ui/setupwizard/JavaWizardPage.cpp @@ -0,0 +1,87 @@ +#include "JavaWizardPage.h" +#include "Application.h" +#include "settings/SettingsObject.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "JavaCommon.h" + +#include "ui/widgets/JavaWizardWidget.h" +#include "ui/widgets/VersionSelectWidget.h" + +JavaWizardPage::JavaWizardPage(QWidget* parent) : BaseWizardPage(parent) +{ + setupUi(); +} + +void JavaWizardPage::setupUi() +{ + setObjectName(QStringLiteral("javaPage")); + QVBoxLayout* layout = new QVBoxLayout(this); + + m_java_widget = new JavaWizardWidget(this); + layout->addWidget(m_java_widget); + setLayout(layout); + + retranslate(); +} + +void JavaWizardPage::refresh() +{ + m_java_widget->refresh(); +} + +void JavaWizardPage::initializePage() +{ + m_java_widget->initialize(); +} + +bool JavaWizardPage::wantsRefreshButton() +{ + return true; +} + +bool JavaWizardPage::validatePage() +{ + auto settings = APPLICATION->settings(); + auto result = m_java_widget->validate(); + settings->set("AutomaticJavaSwitch", m_java_widget->autoDetectJava()); + settings->set("AutomaticJavaDownload", m_java_widget->autoDownloadJava()); + settings->set("UserAskedAboutAutomaticJavaDownload", true); + switch (result) { + default: + case JavaWizardWidget::ValidationStatus::Bad: { + return false; + } + case JavaWizardWidget::ValidationStatus::AllOK: { + settings->set("JavaPath", m_java_widget->javaPath()); + } /* fallthrough */ + case JavaWizardWidget::ValidationStatus::JavaBad: { + // Memory + auto s = APPLICATION->settings(); + s->set("MinMemAlloc", m_java_widget->minHeapSize()); + s->set("MaxMemAlloc", m_java_widget->maxHeapSize()); + if (m_java_widget->permGenEnabled()) { + s->set("PermGen", m_java_widget->permGenSize()); + } else { + s->reset("PermGen"); + } + return true; + } + } +} + +void JavaWizardPage::retranslate() +{ + setTitle(tr("Java")); + setSubTitle( + tr("Please select how much memory to allocate to instances and if Prism Launcher should manage Java automatically or manually.")); + m_java_widget->retranslate(); +} diff --git a/launcher/ui/setupwizard/JavaWizardPage.h b/launcher/ui/setupwizard/JavaWizardPage.h new file mode 100644 index 0000000..914630d --- /dev/null +++ b/launcher/ui/setupwizard/JavaWizardPage.h @@ -0,0 +1,25 @@ +#pragma once + +#include "BaseWizardPage.h" + +class JavaWizardWidget; + +class JavaWizardPage : public BaseWizardPage { + Q_OBJECT + public: + explicit JavaWizardPage(QWidget* parent = Q_NULLPTR); + + virtual ~JavaWizardPage() = default; + + bool wantsRefreshButton() override; + void refresh() override; + void initializePage() override; + bool validatePage() override; + + protected: /* methods */ + void setupUi(); + void retranslate() override; + + private: /* data */ + JavaWizardWidget* m_java_widget = nullptr; +}; diff --git a/launcher/ui/setupwizard/LanguageWizardPage.cpp b/launcher/ui/setupwizard/LanguageWizardPage.cpp new file mode 100644 index 0000000..e9ba362 --- /dev/null +++ b/launcher/ui/setupwizard/LanguageWizardPage.cpp @@ -0,0 +1,47 @@ +#include "LanguageWizardPage.h" +#include +#include "settings/SettingsObject.h" +#include + +#include +#include +#include "ui/widgets/LanguageSelectionWidget.h" + +LanguageWizardPage::LanguageWizardPage(QWidget* parent) : BaseWizardPage(parent) +{ + setObjectName(QStringLiteral("languagePage")); + auto layout = new QVBoxLayout(this); + mainWidget = new LanguageSelectionWidget(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->addWidget(mainWidget); + + retranslate(); +} + +LanguageWizardPage::~LanguageWizardPage() {} + +bool LanguageWizardPage::wantsRefreshButton() +{ + return true; +} + +void LanguageWizardPage::refresh() +{ + auto translations = APPLICATION->translations(); + translations->downloadIndex(); +} + +bool LanguageWizardPage::validatePage() +{ + auto settings = APPLICATION->settings(); + QString key = mainWidget->getSelectedLanguageKey(); + settings->set("Language", key); + return true; +} + +void LanguageWizardPage::retranslate() +{ + setTitle(tr("Language")); + setSubTitle(tr("Select the language to use in %1").arg(BuildConfig.LAUNCHER_DISPLAYNAME)); + mainWidget->retranslate(); +} diff --git a/launcher/ui/setupwizard/LanguageWizardPage.h b/launcher/ui/setupwizard/LanguageWizardPage.h new file mode 100644 index 0000000..44a0623 --- /dev/null +++ b/launcher/ui/setupwizard/LanguageWizardPage.h @@ -0,0 +1,25 @@ +#pragma once + +#include "BaseWizardPage.h" + +class LanguageSelectionWidget; + +class LanguageWizardPage : public BaseWizardPage { + Q_OBJECT + public: + explicit LanguageWizardPage(QWidget* parent = Q_NULLPTR); + + virtual ~LanguageWizardPage(); + + bool wantsRefreshButton() override; + + void refresh() override; + + bool validatePage() override; + + protected: + void retranslate() override; + + private: + LanguageSelectionWidget* mainWidget = nullptr; +}; diff --git a/launcher/ui/setupwizard/LoginWizardPage.cpp b/launcher/ui/setupwizard/LoginWizardPage.cpp new file mode 100644 index 0000000..f53e319 --- /dev/null +++ b/launcher/ui/setupwizard/LoginWizardPage.cpp @@ -0,0 +1,44 @@ +#include "LoginWizardPage.h" +#include "minecraft/auth/AccountList.h" +#include "ui/dialogs/MSALoginDialog.h" +#include "ui_LoginWizardPage.h" + +#include "Application.h" + +LoginWizardPage::LoginWizardPage(QWidget* parent) : BaseWizardPage(parent), ui(new Ui::LoginWizardPage) +{ + ui->setupUi(this); +} + +LoginWizardPage::~LoginWizardPage() +{ + delete ui; +} + +void LoginWizardPage::initializePage() {} + +bool LoginWizardPage::validatePage() +{ + return true; +} + +void LoginWizardPage::retranslate() +{ + ui->retranslateUi(this); +} + +void LoginWizardPage::on_pushButton_clicked() +{ + wizard()->hide(); + auto account = MSALoginDialog::newAccount(nullptr); + wizard()->show(); + if (account) { + APPLICATION->accounts()->addAccount(account); + APPLICATION->accounts()->setDefaultAccount(account); + if (wizard()->currentId() == wizard()->pageIds().last()) { + wizard()->accept(); + } else { + wizard()->next(); + } + } +} diff --git a/launcher/ui/setupwizard/LoginWizardPage.h b/launcher/ui/setupwizard/LoginWizardPage.h new file mode 100644 index 0000000..af71fc1 --- /dev/null +++ b/launcher/ui/setupwizard/LoginWizardPage.h @@ -0,0 +1,24 @@ +#pragma once +#include +#include "BaseWizardPage.h" + +namespace Ui { +class LoginWizardPage; +} + +class LoginWizardPage : public BaseWizardPage { + Q_OBJECT + + public: + explicit LoginWizardPage(QWidget* parent = nullptr); + ~LoginWizardPage(); + + void initializePage() override; + bool validatePage() override; + void retranslate() override; + private slots: + void on_pushButton_clicked(); + + private: + Ui::LoginWizardPage* ui; +}; diff --git a/launcher/ui/setupwizard/LoginWizardPage.ui b/launcher/ui/setupwizard/LoginWizardPage.ui new file mode 100644 index 0000000..191316c --- /dev/null +++ b/launcher/ui/setupwizard/LoginWizardPage.ui @@ -0,0 +1,74 @@ + + + LoginWizardPage + + + + 0 + 0 + 400 + 300 + + + + Form + + + + + + <html><head/><body><p><span style=" font-size:14pt; font-weight:600;">Add Microsoft account</span></p></body></html> + + + Qt::RichText + + + true + + + + + + + In order to play Minecraft, you must have at least one Microsoft account logged in. Do you want to log in now? + + + true + + + + + + + Qt::Horizontal + + + + + + + Add Microsoft account + + + + + + + Qt::Vertical + + + + 20 + 156 + + + + + + + + + + + + diff --git a/launcher/ui/setupwizard/PasteWizardPage.cpp b/launcher/ui/setupwizard/PasteWizardPage.cpp new file mode 100644 index 0000000..979ec50 --- /dev/null +++ b/launcher/ui/setupwizard/PasteWizardPage.cpp @@ -0,0 +1,38 @@ +#include "PasteWizardPage.h" +#include "ui_PasteWizardPage.h" + +#include "Application.h" +#include "settings/SettingsObject.h" +#include "net/PasteUpload.h" + +PasteWizardPage::PasteWizardPage(QWidget* parent) : BaseWizardPage(parent), ui(new Ui::PasteWizardPage) +{ + ui->setupUi(this); +} + +PasteWizardPage::~PasteWizardPage() +{ + delete ui; +} + +void PasteWizardPage::initializePage() {} + +bool PasteWizardPage::validatePage() +{ + auto s = APPLICATION->settings(); + QString prevPasteURL = s->get("PastebinURL").toString(); + s->reset("PastebinURL"); + if (ui->previousSettingsRadioButton->isChecked()) { + bool usingDefaultBase = prevPasteURL == PasteUpload::PasteTypes.at(PasteUpload::PasteType::NullPointer).defaultBase; + s->set("PastebinType", PasteUpload::PasteType::NullPointer); + if (!usingDefaultBase) + s->set("PastebinCustomAPIBase", prevPasteURL); + } + + return true; +} + +void PasteWizardPage::retranslate() +{ + ui->retranslateUi(this); +} diff --git a/launcher/ui/setupwizard/PasteWizardPage.h b/launcher/ui/setupwizard/PasteWizardPage.h new file mode 100644 index 0000000..dece81c --- /dev/null +++ b/launcher/ui/setupwizard/PasteWizardPage.h @@ -0,0 +1,26 @@ +#ifndef PASTEDEFAULTSCONFIRMATIONWIZARD_H +#define PASTEDEFAULTSCONFIRMATIONWIZARD_H + +#include +#include "BaseWizardPage.h" + +namespace Ui { +class PasteWizardPage; +} + +class PasteWizardPage : public BaseWizardPage { + Q_OBJECT + + public: + explicit PasteWizardPage(QWidget* parent = nullptr); + ~PasteWizardPage(); + + void initializePage() override; + bool validatePage() override; + void retranslate() override; + + private: + Ui::PasteWizardPage* ui; +}; + +#endif // PASTEDEFAULTSCONFIRMATIONWIZARD_H diff --git a/launcher/ui/setupwizard/PasteWizardPage.ui b/launcher/ui/setupwizard/PasteWizardPage.ui new file mode 100644 index 0000000..247d3a7 --- /dev/null +++ b/launcher/ui/setupwizard/PasteWizardPage.ui @@ -0,0 +1,80 @@ + + + PasteWizardPage + + + + 0 + 0 + 400 + 300 + + + + Form + + + + + + The default paste service has changed to mclo.gs, please choose what you want to do with your settings. + + + true + + + + + + + Qt::Horizontal + + + + + + + Use new default service + + + true + + + buttonGroup + + + + + + + Keep previous settings + + + false + + + buttonGroup + + + + + + + Qt::Vertical + + + + 20 + 156 + + + + + + + + + + + + diff --git a/launcher/ui/setupwizard/SetupWizard.cpp b/launcher/ui/setupwizard/SetupWizard.cpp new file mode 100644 index 0000000..f2e51ee --- /dev/null +++ b/launcher/ui/setupwizard/SetupWizard.cpp @@ -0,0 +1,79 @@ +#include "SetupWizard.h" + +#include "JavaWizardPage.h" +#include "LanguageWizardPage.h" + +#include +#include +#include "translations/TranslationsModel.h" + +#include +#include + +SetupWizard::SetupWizard(QWidget* parent) : QWizard(parent) +{ + setObjectName(QStringLiteral("SetupWizard")); + resize(620, 660); + setMinimumSize(300, 400); + // make it ugly everywhere to avoid variability in theming + setWizardStyle(QWizard::ClassicStyle); + setOptions(QWizard::NoCancelButton | QWizard::IndependentPages | QWizard::HaveCustomButton1); + + retranslate(); + + connect(this, &QWizard::currentIdChanged, this, &SetupWizard::pageChanged); +} + +void SetupWizard::retranslate() +{ + setButtonText(QWizard::NextButton, tr("&Next >")); + setButtonText(QWizard::BackButton, tr("< &Back")); + setButtonText(QWizard::FinishButton, tr("&Finish")); + setButtonText(QWizard::CustomButton1, tr("&Refresh")); + setWindowTitle(tr("%1 Quick Setup").arg(BuildConfig.LAUNCHER_DISPLAYNAME)); +} + +BaseWizardPage* SetupWizard::getBasePage(int id) +{ + if (id == -1) + return nullptr; + auto pagePtr = page(id); + if (!pagePtr) + return nullptr; + return dynamic_cast(pagePtr); +} + +BaseWizardPage* SetupWizard::getCurrentBasePage() +{ + return getBasePage(currentId()); +} + +void SetupWizard::pageChanged(int id) +{ + auto basePagePtr = getBasePage(id); + if (!basePagePtr) { + return; + } + if (basePagePtr->wantsRefreshButton()) { + setButtonLayout({ QWizard::CustomButton1, QWizard::Stretch, QWizard::BackButton, QWizard::NextButton, QWizard::FinishButton }); + auto customButton = button(QWizard::CustomButton1); + connect(customButton, &QAbstractButton::clicked, [this]() { + auto basePagePtr = getCurrentBasePage(); + if (basePagePtr) { + basePagePtr->refresh(); + } + }); + } else { + setButtonLayout({ QWizard::Stretch, QWizard::BackButton, QWizard::NextButton, QWizard::FinishButton }); + } +} + +void SetupWizard::changeEvent(QEvent* event) +{ + if (event->type() == QEvent::LanguageChange) { + retranslate(); + } + QWizard::changeEvent(event); +} + +SetupWizard::~SetupWizard() {} diff --git a/launcher/ui/setupwizard/SetupWizard.h b/launcher/ui/setupwizard/SetupWizard.h new file mode 100644 index 0000000..c26c59f --- /dev/null +++ b/launcher/ui/setupwizard/SetupWizard.h @@ -0,0 +1,42 @@ +/* Copyright 2017-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +namespace Ui { +class SetupWizard; +} + +class BaseWizardPage; + +class SetupWizard : public QWizard { + Q_OBJECT + + public: /* con/destructors */ + explicit SetupWizard(QWidget* parent = 0); + virtual ~SetupWizard(); + + void changeEvent(QEvent* event) override; + BaseWizardPage* getBasePage(int id); + BaseWizardPage* getCurrentBasePage(); + + private slots: + void pageChanged(int id); + + private: /* methods */ + void retranslate(); +}; diff --git a/launcher/ui/setupwizard/ThemeWizardPage.h b/launcher/ui/setupwizard/ThemeWizardPage.h new file mode 100644 index 0000000..ef4613e --- /dev/null +++ b/launcher/ui/setupwizard/ThemeWizardPage.h @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Tayou + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +#pragma once + +#include +#include +#include +#include "BaseWizardPage.h" + +class ThemeWizardPage : public BaseWizardPage { + Q_OBJECT + + public: + ThemeWizardPage(QWidget* parent = nullptr) : BaseWizardPage(parent) + { + auto layout = new QVBoxLayout(this); + layout->addWidget(&widget); + layout->addSpacerItem(new QSpacerItem(0, 0, QSizePolicy::Minimum, QSizePolicy::Expanding)); + layout->setContentsMargins(0, 0, 0, 0); + setLayout(layout); + + setTitle(tr("Appearance")); + setSubTitle(tr("Select theme and icons to use")); + } + + bool validatePage() override { return true; }; + void retranslate() override { widget.retranslateUi(); } + + private: + AppearanceWidget widget{ true }; +}; diff --git a/launcher/ui/themes/BrightTheme.cpp b/launcher/ui/themes/BrightTheme.cpp new file mode 100644 index 0000000..81bdd77 --- /dev/null +++ b/launcher/ui/themes/BrightTheme.cpp @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2024 Tayou + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +#include "BrightTheme.h" + +#include + +QString BrightTheme::id() +{ + return "bright"; +} + +QString BrightTheme::name() +{ + return QObject::tr("Bright"); +} + +QPalette BrightTheme::colorScheme() +{ + QPalette brightPalette; + brightPalette.setColor(QPalette::Window, QColor(255, 255, 255)); + brightPalette.setColor(QPalette::WindowText, QColor(17, 17, 17)); + brightPalette.setColor(QPalette::Base, QColor(250, 250, 250)); + brightPalette.setColor(QPalette::AlternateBase, QColor(240, 240, 240)); + brightPalette.setColor(QPalette::ToolTipBase, QColor(17, 17, 17)); + brightPalette.setColor(QPalette::ToolTipText, QColor(255, 255, 255)); + brightPalette.setColor(QPalette::Text, Qt::black); + brightPalette.setColor(QPalette::Button, QColor(249, 249, 249)); + brightPalette.setColor(QPalette::ButtonText, Qt::black); + brightPalette.setColor(QPalette::BrightText, Qt::red); + brightPalette.setColor(QPalette::Link, QColor(37, 137, 164)); + brightPalette.setColor(QPalette::Highlight, QColor(137, 207, 84)); + brightPalette.setColor(QPalette::HighlightedText, Qt::black); + return fadeInactive(brightPalette, fadeAmount(), fadeColor()); +} + +double BrightTheme::fadeAmount() +{ + return 0.5; +} + +QColor BrightTheme::fadeColor() +{ + return QColor(255, 255, 255); +} + +bool BrightTheme::hasStyleSheet() +{ + return false; +} + +QString BrightTheme::appStyleSheet() +{ + return QString(); +} +QString BrightTheme::tooltip() +{ + return QString(); +} diff --git a/launcher/ui/themes/BrightTheme.h b/launcher/ui/themes/BrightTheme.h new file mode 100644 index 0000000..070eef1 --- /dev/null +++ b/launcher/ui/themes/BrightTheme.h @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2024 Tayou + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +#pragma once + +#include "FusionTheme.h" + +class BrightTheme : public FusionTheme { + public: + virtual ~BrightTheme() {} + + QString id() override; + QString name() override; + QString tooltip() override; + bool hasStyleSheet() override; + QString appStyleSheet() override; + QPalette colorScheme() override; + double fadeAmount() override; + QColor fadeColor() override; +}; diff --git a/launcher/ui/themes/CatPack.cpp b/launcher/ui/themes/CatPack.cpp new file mode 100644 index 0000000..7d39c8c --- /dev/null +++ b/launcher/ui/themes/CatPack.cpp @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ui/themes/CatPack.h" +#include +#include +#include +#include +#include +#include +#include "FileSystem.h" +#include "Json.h" + +QString BasicCatPack::path() const +{ + const auto now = QDate::currentDate(); + const auto birthday = QDate(now.year(), 11, 1); + const auto xmas = QDate(now.year(), 12, 25); + const auto halloween = QDate(now.year(), 10, 31); + + QString cat = QString(":/backgrounds/%1").arg(m_id); + if (std::abs(now.daysTo(xmas)) <= 4) { + cat += "-xmas"; + } else if (std::abs(now.daysTo(halloween)) <= 4) { + cat += "-spooky"; + } else if (std::abs(now.daysTo(birthday)) <= 12) { + cat += "-bday"; + } + return cat; +} + +JsonCatPack::PartialDate partialDate(QJsonObject date) +{ + auto month = date["month"].toInt(1); + if (month > 12) + month = 12; + else if (month <= 0) + month = 1; + auto day = date["day"].toInt(1); + if (day > 31) + day = 31; + else if (day <= 0) + day = 1; + return { month, day }; +}; + +JsonCatPack::JsonCatPack(QFileInfo& manifestInfo) : BasicCatPack(manifestInfo.dir().dirName()) +{ + QString path = manifestInfo.path(); + auto doc = Json::requireDocument(manifestInfo.absoluteFilePath(), "CatPack JSON file"); + const auto root = doc.object(); + m_name = Json::requireString(root, "name", "Catpack name"); + m_default_path = FS::PathCombine(path, Json::requireString(root, "default", "Default Cat")); + auto variants = root["variants"].toArray(); + for (auto v : variants) { + auto variant = v.toObject(); + m_variants << Variant{ FS::PathCombine(path, Json::requireString(variant, "path", "Variant path")), + partialDate(Json::requireObject(variant, "startTime", "Variant startTime")), + partialDate(Json::requireObject(variant, "endTime", "Variant endTime")) }; + } +} + +QDate ensureDay(int year, int month, int day) +{ + QDate date(year, month, 1); + if (day > date.daysInMonth()) + day = date.daysInMonth(); + return QDate(year, month, day); +} + +QString JsonCatPack::path() const +{ + return path(QDate::currentDate()); +} + +QString JsonCatPack::path(QDate now) const +{ + for (auto var : m_variants) { + QDate startDate = ensureDay(now.year(), var.startTime.month, var.startTime.day); + QDate endDate = ensureDay(now.year(), var.endTime.month, var.endTime.day); + if (startDate > endDate) { // it's spans over multiple years + if (endDate < now) // end date is in the past so jump one year into the future for endDate + endDate = endDate.addYears(1); + else // end date is in the future so jump one year into the past for startDate + startDate = startDate.addYears(-1); + } + + if (startDate <= now && now <= endDate) + return var.path; + } + auto dInfo = QFileInfo(m_default_path); + if (!dInfo.isDir()) + return m_default_path; + + QStringList supportedImageFormats; + for (auto format : QImageReader::supportedImageFormats()) { + supportedImageFormats.append("*." + format); + } + + auto files = QDir(m_default_path).entryInfoList(supportedImageFormats, QDir::Files, QDir::Name); + if (files.length() == 0) + return ""; + auto idx = (now.dayOfYear() - 1) % files.length(); + auto isRandom = dInfo.fileName().compare("random", Qt::CaseInsensitive) == 0; + if (isRandom) + idx = QRandomGenerator::global()->bounded(0, files.length()); + return files[idx].absoluteFilePath(); +} diff --git a/launcher/ui/themes/CatPack.h b/launcher/ui/themes/CatPack.h new file mode 100644 index 0000000..e0e34f8 --- /dev/null +++ b/launcher/ui/themes/CatPack.h @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include + +class CatPack { + public: + virtual ~CatPack() {} + virtual QString id() const = 0; + virtual QString name() const = 0; + virtual QString path() const = 0; +}; + +class BasicCatPack : public CatPack { + public: + BasicCatPack(QString id, QString name) : m_id(id), m_name(name) {} + BasicCatPack(QString id) : BasicCatPack(id, id) {} + virtual QString id() const override { return m_id; } + virtual QString name() const override { return m_name; } + virtual QString path() const override; + + protected: + QString m_id; + QString m_name; +}; + +class FileCatPack : public BasicCatPack { + public: + FileCatPack(QString id, QFileInfo& fileInfo) : BasicCatPack(id), m_path(fileInfo.absoluteFilePath()) {} + FileCatPack(QFileInfo& fileInfo) : FileCatPack(fileInfo.baseName(), fileInfo) {} + virtual QString path() const { return m_path; } + + private: + QString m_path; +}; + +class JsonCatPack : public BasicCatPack { + public: + struct PartialDate { + int month; + int day; + }; + struct Variant { + QString path; + PartialDate startTime; + PartialDate endTime; + }; + JsonCatPack(QFileInfo& manifestInfo); + virtual QString path() const override; + QString path(QDate now) const; + + private: + QString m_default_path; + QList m_variants; +}; diff --git a/launcher/ui/themes/CatPainter.cpp b/launcher/ui/themes/CatPainter.cpp new file mode 100644 index 0000000..a4bda02 --- /dev/null +++ b/launcher/ui/themes/CatPainter.cpp @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2025 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "ui/themes/CatPainter.h" +#include +#include "Application.h" +#include "settings/SettingsObject.h" + +CatPainter::CatPainter(const QString& path, QObject* parent) : QObject(parent) +{ + // Attempt to load as a movie + m_movie = new QMovie(path, QByteArray(), this); + if (m_movie->isValid()) { + // Start the animation if it's a valid movie file + connect(m_movie, &QMovie::frameChanged, this, &CatPainter::updateFrame); + m_movie->start(); + } else { + // Otherwise, load it as a static image + delete m_movie; + m_movie = nullptr; + + m_image = QPixmap(path); + } +} + +void CatPainter::paint(QPainter* painter, const QRect& viewport) +{ + QPixmap frame = m_image; + if (m_movie && m_movie->isValid()) { + frame = m_movie->currentPixmap(); + } + + auto fit = APPLICATION->settings()->get("CatFit").toString(); + painter->setOpacity(APPLICATION->settings()->get("CatOpacity").toFloat() / 100); + int widWidth = viewport.width(); + int widHeight = viewport.height(); + auto aspectMode = Qt::IgnoreAspectRatio; + if (fit == "fill") { + aspectMode = Qt::KeepAspectRatio; + } else if (fit == "fit") { + aspectMode = Qt::KeepAspectRatio; + if (frame.width() < widWidth) + widWidth = frame.width(); + if (frame.height() < widHeight) + widHeight = frame.height(); + } + auto pixmap = frame.scaled(widWidth, widHeight, aspectMode, Qt::SmoothTransformation); + QRect rectOfPixmap = pixmap.rect(); + rectOfPixmap.moveBottomRight(viewport.bottomRight()); + painter->drawPixmap(rectOfPixmap.topLeft(), pixmap); + painter->setOpacity(1.0); +}; diff --git a/launcher/ui/themes/CatPainter.h b/launcher/ui/themes/CatPainter.h new file mode 100644 index 0000000..c36cb76 --- /dev/null +++ b/launcher/ui/themes/CatPainter.h @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2025 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include +#include +#include + +class CatPainter : public QObject { + Q_OBJECT + public: + CatPainter(const QString& path, QObject* parent = nullptr); + virtual ~CatPainter() = default; + void paint(QPainter*, const QRect&); + + signals: + void updateFrame(); + + private: + QMovie* m_movie = nullptr; + QPixmap m_image; +}; diff --git a/launcher/ui/themes/CustomTheme.cpp b/launcher/ui/themes/CustomTheme.cpp new file mode 100644 index 0000000..c20366a --- /dev/null +++ b/launcher/ui/themes/CustomTheme.cpp @@ -0,0 +1,267 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2024 Tayou + * Copyright (C) 2024 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +#include "CustomTheme.h" +#include +#include +#include "ThemeManager.h" + +const char* themeFile = "theme.json"; + +/// @param baseTheme Base Theme +/// @param fileInfo FileInfo object for file to load +/// @param isManifest whether to load a theme manifest or a qss file +CustomTheme::CustomTheme(ITheme* baseTheme, QFileInfo& fileInfo, bool isManifest) +{ + if (isManifest) { + m_id = fileInfo.dir().dirName(); + + QString path = FS::PathCombine("themes", m_id); + QString pathResources = FS::PathCombine("themes", m_id, "resources"); + + if (!FS::ensureFolderPathExists(path)) { + themeWarningLog() << "Theme directory for" << m_id << "could not be created. This theme might be invalid"; + return; + } + + if (!FS::ensureFolderPathExists(pathResources)) { + themeWarningLog() << "Resources directory for" << m_id << "could not be created"; + } + + auto themeFilePath = FS::PathCombine(path, themeFile); + + m_palette = baseTheme->colorScheme(); + + bool hasCustomLogColors = false; + + if (read(themeFilePath, hasCustomLogColors)) { + // If theme data was found, fade "Disabled" color of each role according to FadeAmount + m_palette = fadeInactive(m_palette, m_fadeAmount, m_fadeColor); + + if (!hasCustomLogColors) + m_logColors = defaultLogColors(m_palette); + } else { + themeDebugLog() << "Did not read theme json file correctly, not changing theme, keeping previous."; + m_logColors = defaultLogColors(m_palette); + return; + } + + auto qssFilePath = FS::PathCombine(path, m_qssFilePath); + QFileInfo info(qssFilePath); + if (info.isFile()) { + try { + // TODO: validate qss? + m_styleSheet = QString::fromUtf8(FS::read(qssFilePath)); + } catch (const Exception& e) { + themeWarningLog() << "Couldn't load qss:" << e.cause() << "from" << qssFilePath; + return; + } + } else { + themeDebugLog() << "No theme qss present."; + } + } else { + m_id = fileInfo.fileName(); + m_name = fileInfo.baseName(); + QString path = fileInfo.filePath(); + // themeDebugLog << "Theme ID: " << m_id; + // themeDebugLog << "Theme Name: " << m_name; + // themeDebugLog << "Theme Path: " << path; + + if (!FS::ensureFilePathExists(path)) { + themeWarningLog().nospace() << m_name << ": Theme file path doesn't exist!"; + m_palette = baseTheme->colorScheme(); + m_styleSheet = baseTheme->appStyleSheet(); + return; + } + + m_palette = baseTheme->colorScheme(); + try { + // TODO: validate qss? + m_styleSheet = QString::fromUtf8(FS::read(path)); + } catch (const Exception& e) { + themeWarningLog() << "Couldn't load qss:" << e.cause() << "from" << path; + m_styleSheet = baseTheme->appStyleSheet(); + } + } +} + +QStringList CustomTheme::searchPaths() +{ + QString pathResources = FS::PathCombine("themes", m_id, "resources"); + if (QFileInfo::exists(pathResources)) + return { pathResources }; + + return {}; +} + +QString CustomTheme::id() +{ + return m_id; +} + +QString CustomTheme::name() +{ + return m_name; +} + +QPalette CustomTheme::colorScheme() +{ + return m_palette; +} + +bool CustomTheme::hasStyleSheet() +{ + return true; +} + +QString CustomTheme::appStyleSheet() +{ + return m_styleSheet; +} + +double CustomTheme::fadeAmount() +{ + return m_fadeAmount; +} + +QColor CustomTheme::fadeColor() +{ + return m_fadeColor; +} + +QString CustomTheme::qtTheme() +{ + return m_widgets; +} +QString CustomTheme::tooltip() +{ + return m_tooltip; +} + +bool CustomTheme::read(const QString& path, bool& hasCustomLogColors) +{ + QFileInfo pathInfo(path); + if (pathInfo.exists() && pathInfo.isFile()) { + try { + auto doc = Json::requireDocument(path, "Theme JSON file"); + const QJsonObject root = doc.object(); + m_name = Json::requireString(root, "name", "Theme name"); + m_widgets = Json::requireString(root, "widgets", "Qt widget theme"); + m_qssFilePath = root["qssFilePath"].toString("themeStyle.css"); + + auto readColor = [](const QJsonObject& colors, const QString& colorName) -> QColor { + auto colorValue = colors[colorName].toString(); + if (!colorValue.isEmpty()) { + QColor color(colorValue); + if (!color.isValid()) { + themeWarningLog() << "Color value" << colorValue << "for" << colorName << "was not recognized."; + return {}; + } + return color; + } + return {}; + }; + + if (root.contains("colors")) { + auto colorsRoot = Json::requireObject(root, "colors"); + auto readAndSetPaletteColor = [this, readColor, colorsRoot](QPalette::ColorRole role, const QString& colorName) { + auto color = readColor(colorsRoot, colorName); + if (color.isValid()) { + m_palette.setColor(role, color); + } else { + themeDebugLog() << "Color value for" << colorName << "was not present."; + } + }; + + // palette + readAndSetPaletteColor(QPalette::Window, "Window"); + readAndSetPaletteColor(QPalette::WindowText, "WindowText"); + readAndSetPaletteColor(QPalette::Base, "Base"); + readAndSetPaletteColor(QPalette::AlternateBase, "AlternateBase"); + readAndSetPaletteColor(QPalette::ToolTipBase, "ToolTipBase"); + readAndSetPaletteColor(QPalette::ToolTipText, "ToolTipText"); + readAndSetPaletteColor(QPalette::Text, "Text"); + readAndSetPaletteColor(QPalette::Button, "Button"); + readAndSetPaletteColor(QPalette::ButtonText, "ButtonText"); + readAndSetPaletteColor(QPalette::BrightText, "BrightText"); + readAndSetPaletteColor(QPalette::Link, "Link"); + readAndSetPaletteColor(QPalette::Highlight, "Highlight"); + readAndSetPaletteColor(QPalette::HighlightedText, "HighlightedText"); + + // fade + m_fadeColor = readColor(colorsRoot, "fadeColor"); + m_fadeAmount = colorsRoot["fadeAmount"].toDouble(0.5); + } + + if (root.contains("logColors")) { + hasCustomLogColors = true; + + auto logColorsRoot = Json::requireObject(root, "logColors"); + auto readAndSetLogColor = [this, readColor, logColorsRoot](MessageLevel level, bool fg, const QString& colorName) { + auto color = readColor(logColorsRoot, colorName); + if (color.isValid()) { + if (fg) + m_logColors.foreground[level] = color; + else + m_logColors.background[level] = color; + } else { + themeDebugLog() << "Color value for" << colorName << "was not present."; + } + }; + + readAndSetLogColor(MessageLevel::Message, false, "MessageHighlight"); + readAndSetLogColor(MessageLevel::Launcher, false, "LauncherHighlight"); + readAndSetLogColor(MessageLevel::Debug, false, "DebugHighlight"); + readAndSetLogColor(MessageLevel::Warning, false, "WarningHighlight"); + readAndSetLogColor(MessageLevel::Error, false, "ErrorHighlight"); + readAndSetLogColor(MessageLevel::Fatal, false, "FatalHighlight"); + + readAndSetLogColor(MessageLevel::Message, true, "Message"); + readAndSetLogColor(MessageLevel::Launcher, true, "Launcher"); + readAndSetLogColor(MessageLevel::Debug, true, "Debug"); + readAndSetLogColor(MessageLevel::Warning, true, "Warning"); + readAndSetLogColor(MessageLevel::Error, true, "Error"); + readAndSetLogColor(MessageLevel::Fatal, true, "Fatal"); + } + } catch (const Exception& e) { + themeWarningLog() << "Couldn't load theme json:" << e.cause(); + return false; + } + } else { + themeDebugLog() << "No theme json present."; + return false; + } + return true; +} diff --git a/launcher/ui/themes/CustomTheme.h b/launcher/ui/themes/CustomTheme.h new file mode 100644 index 0000000..b8d0739 --- /dev/null +++ b/launcher/ui/themes/CustomTheme.h @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2024 Tayou + * Copyright (C) 2024 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +#pragma once + +#include +#include "ITheme.h" + +class CustomTheme : public ITheme { + public: + CustomTheme(ITheme* baseTheme, QFileInfo& file, bool isManifest); + virtual ~CustomTheme() {} + + QString id() override; + QString name() override; + QString tooltip() override; + bool hasStyleSheet() override; + QString appStyleSheet() override; + QPalette colorScheme() override; + double fadeAmount() override; + QColor fadeColor() override; + QString qtTheme() override; + LogColors logColorScheme() override { return m_logColors; } + QStringList searchPaths() override; + + private: + bool read(const QString& path, bool& hasCustomLogColors); + + QPalette m_palette; + QColor m_fadeColor; + double m_fadeAmount; + QString m_styleSheet; + QString m_name; + QString m_id; + QString m_widgets; + QString m_qssFilePath; + LogColors m_logColors; + /** + * The tooltip could be defined in the theme json, + * or composed of other fields that could be in there. + * like author, license, etc. + */ + QString m_tooltip = ""; +}; diff --git a/launcher/ui/themes/DarkTheme.cpp b/launcher/ui/themes/DarkTheme.cpp new file mode 100644 index 0000000..8041265 --- /dev/null +++ b/launcher/ui/themes/DarkTheme.cpp @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2024 Tayou + * Copyright (C) 2024 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +#include "DarkTheme.h" + +#include + +QString DarkTheme::id() +{ + return "dark"; +} + +QString DarkTheme::name() +{ + return QObject::tr("Dark"); +} + +QPalette DarkTheme::colorScheme() +{ + QPalette darkPalette; + darkPalette.setColor(QPalette::Window, QColor(49, 49, 49)); + darkPalette.setColor(QPalette::WindowText, Qt::white); + darkPalette.setColor(QPalette::Base, QColor(34, 34, 34)); + darkPalette.setColor(QPalette::AlternateBase, QColor(42, 42, 42)); + darkPalette.setColor(QPalette::ToolTipBase, Qt::white); + darkPalette.setColor(QPalette::ToolTipText, Qt::white); + darkPalette.setColor(QPalette::Text, Qt::white); + darkPalette.setColor(QPalette::Button, QColor(48, 48, 48)); + darkPalette.setColor(QPalette::ButtonText, Qt::white); + darkPalette.setColor(QPalette::BrightText, Qt::red); + darkPalette.setColor(QPalette::Link, QColor(47, 163, 198)); + darkPalette.setColor(QPalette::Highlight, QColor(150, 219, 89)); + darkPalette.setColor(QPalette::HighlightedText, Qt::black); + darkPalette.setColor(QPalette::PlaceholderText, Qt::darkGray); + return fadeInactive(darkPalette, fadeAmount(), fadeColor()); +} + +double DarkTheme::fadeAmount() +{ + return 0.5; +} + +QColor DarkTheme::fadeColor() +{ + return QColor(49, 49, 49); +} + +bool DarkTheme::hasStyleSheet() +{ + return true; +} + +QString DarkTheme::appStyleSheet() +{ + return "QToolTip { color: #ffffff; background-color: #2a82da; border: 1px solid white; }"; +} + +QString DarkTheme::tooltip() +{ + return ""; +} diff --git a/launcher/ui/themes/DarkTheme.h b/launcher/ui/themes/DarkTheme.h new file mode 100644 index 0000000..c97edbc --- /dev/null +++ b/launcher/ui/themes/DarkTheme.h @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2024 Tayou + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +#pragma once + +#include "FusionTheme.h" + +class DarkTheme : public FusionTheme { + public: + virtual ~DarkTheme() {} + + QString id() override; + QString name() override; + QString tooltip() override; + bool hasStyleSheet() override; + QString appStyleSheet() override; + QPalette colorScheme() override; + double fadeAmount() override; + QColor fadeColor() override; +}; diff --git a/launcher/ui/themes/FusionTheme.cpp b/launcher/ui/themes/FusionTheme.cpp new file mode 100644 index 0000000..cf3286b --- /dev/null +++ b/launcher/ui/themes/FusionTheme.cpp @@ -0,0 +1,6 @@ +#include "FusionTheme.h" + +QString FusionTheme::qtTheme() +{ + return "Fusion"; +} diff --git a/launcher/ui/themes/FusionTheme.h b/launcher/ui/themes/FusionTheme.h new file mode 100644 index 0000000..826fae1 --- /dev/null +++ b/launcher/ui/themes/FusionTheme.h @@ -0,0 +1,10 @@ +#pragma once + +#include "ITheme.h" + +class FusionTheme : public ITheme { + public: + virtual ~FusionTheme() {} + + QString qtTheme() override; +}; diff --git a/launcher/ui/themes/HintOverrideProxyStyle.cpp b/launcher/ui/themes/HintOverrideProxyStyle.cpp new file mode 100644 index 0000000..6d95cc7 --- /dev/null +++ b/launcher/ui/themes/HintOverrideProxyStyle.cpp @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2024 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "HintOverrideProxyStyle.h" + +HintOverrideProxyStyle::HintOverrideProxyStyle(QStyle* style) : QProxyStyle(style) +{ + setObjectName(baseStyle()->objectName()); +} + +int HintOverrideProxyStyle::styleHint(QStyle::StyleHint hint, + const QStyleOption* option, + const QWidget* widget, + QStyleHintReturn* returnData) const +{ + if (hint == QStyle::SH_ItemView_ActivateItemOnSingleClick) + return 0; + + if (hint == QStyle::SH_Slider_AbsoluteSetButtons) + return Qt::LeftButton | Qt::MiddleButton; + + if (hint == QStyle::SH_Slider_PageSetButtons) + return Qt::RightButton; + + return QProxyStyle::styleHint(hint, option, widget, returnData); +} diff --git a/launcher/ui/themes/HintOverrideProxyStyle.h b/launcher/ui/themes/HintOverrideProxyStyle.h new file mode 100644 index 0000000..e9c489d --- /dev/null +++ b/launcher/ui/themes/HintOverrideProxyStyle.h @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2024 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include + +/// Used to override platform-specific behaviours which the launcher does work well with. +class HintOverrideProxyStyle : public QProxyStyle { + Q_OBJECT + public: + explicit HintOverrideProxyStyle(QStyle* style); + + int styleHint(QStyle::StyleHint hint, + const QStyleOption* option = nullptr, + const QWidget* widget = nullptr, + QStyleHintReturn* returnData = nullptr) const override; +}; diff --git a/launcher/ui/themes/ITheme.cpp b/launcher/ui/themes/ITheme.cpp new file mode 100644 index 0000000..cae6e90 --- /dev/null +++ b/launcher/ui/themes/ITheme.cpp @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Tayou + * Copyright (C) 2024 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +#include "ITheme.h" +#include +#include +#include "Application.h" +#include "HintOverrideProxyStyle.h" +#include "rainbow.h" + +void ITheme::apply(bool) +{ + APPLICATION->setStyleSheet(QString()); + QApplication::setStyle(new HintOverrideProxyStyle(QStyleFactory::create(qtTheme()))); + QApplication::setPalette(colorScheme()); + APPLICATION->setStyleSheet(appStyleSheet()); + QDir::setSearchPaths("theme", searchPaths()); +} + +QPalette ITheme::fadeInactive(QPalette in, qreal bias, QColor color) +{ + auto blend = [&in, bias, color](QPalette::ColorRole role) { + QColor from = in.color(QPalette::Active, role); + QColor blended = Rainbow::mix(from, color, bias); + in.setColor(QPalette::Disabled, role, blended); + }; + blend(QPalette::Window); + blend(QPalette::WindowText); + blend(QPalette::Base); + blend(QPalette::AlternateBase); + blend(QPalette::ToolTipBase); + blend(QPalette::ToolTipText); + blend(QPalette::Text); + blend(QPalette::Button); + blend(QPalette::ButtonText); + blend(QPalette::BrightText); + blend(QPalette::Link); + blend(QPalette::Highlight); + blend(QPalette::HighlightedText); + return in; +} + +LogColors ITheme::defaultLogColors(const QPalette& palette) +{ + LogColors result; + + const QColor& bg = palette.color(QPalette::Base); + const QColor& fg = palette.color(QPalette::Text); + + auto blend = [bg, fg](QColor color) { + if (Rainbow::luma(fg) > Rainbow::luma(bg)) { + // for dark color schemes, produce a fitting color first + color = Rainbow::tint(fg, color, 0.5); + } + // adapt contrast + return Rainbow::mix(fg, color, 1); + }; + + result.background[MessageLevel::Fatal] = Qt::black; + + result.foreground[MessageLevel::Launcher] = blend(QColor("purple")); + result.foreground[MessageLevel::Debug] = blend(QColor("green")); + result.foreground[MessageLevel::Warning] = blend(QColor("orange")); + result.foreground[MessageLevel::Error] = blend(QColor("red")); + result.foreground[MessageLevel::Fatal] = blend(QColor("red")); + + return result; +} diff --git a/launcher/ui/themes/ITheme.h b/launcher/ui/themes/ITheme.h new file mode 100644 index 0000000..6e0e613 --- /dev/null +++ b/launcher/ui/themes/ITheme.h @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Tayou + * Copyright (C) 2024 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +#pragma once +#include +#include +#include +#include + +class QStyle; + +struct LogColors { + QMap background; + QMap foreground; +}; + +// TODO: rename to Theme; this is not an interface as it contains method implementations +// TODO: make methods const +class ITheme { + public: + virtual ~ITheme() {} + virtual void apply(bool initial); + virtual QString id() = 0; + virtual QString name() = 0; + virtual QString tooltip() = 0; + virtual bool hasStyleSheet() = 0; + virtual QString appStyleSheet() = 0; + virtual QString qtTheme() = 0; + virtual QPalette colorScheme() = 0; + virtual QColor fadeColor() = 0; + virtual double fadeAmount() = 0; + virtual LogColors logColorScheme() { return defaultLogColors(colorScheme()); } + virtual QStringList searchPaths() { return {}; } + + static QPalette fadeInactive(QPalette in, qreal bias, QColor color); + static LogColors defaultLogColors(const QPalette& palette); +}; diff --git a/launcher/ui/themes/IconTheme.cpp b/launcher/ui/themes/IconTheme.cpp new file mode 100644 index 0000000..6415c51 --- /dev/null +++ b/launcher/ui/themes/IconTheme.cpp @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "IconTheme.h" + +#include +#include + +bool IconTheme::load() +{ + const QString path = m_path + "/index.theme"; + + if (!QFile::exists(path)) + return false; + + QSettings settings(path, QSettings::IniFormat); + settings.beginGroup("Icon Theme"); + m_name = settings.value("Name").toString(); + settings.endGroup(); + return !m_name.isNull(); +} diff --git a/launcher/ui/themes/IconTheme.h b/launcher/ui/themes/IconTheme.h new file mode 100644 index 0000000..f49e392 --- /dev/null +++ b/launcher/ui/themes/IconTheme.h @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include + +class IconTheme { + public: + IconTheme(const QString& id, const QString& path) : m_id(id), m_path(path) {} + IconTheme() = default; + + bool load(); + QString id() const { return m_id; } + QString path() const { return m_path; } + QString name() const { return m_name; } + + private: + QString m_id; + QString m_path; + QString m_name; +}; diff --git a/launcher/ui/themes/SystemTheme.cpp b/launcher/ui/themes/SystemTheme.cpp new file mode 100644 index 0000000..c9b2e5c --- /dev/null +++ b/launcher/ui/themes/SystemTheme.cpp @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2024 Tayou + * Copyright (C) 2024 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +#include "SystemTheme.h" +#include +#include +#include +#include "HintOverrideProxyStyle.h" +#include "ThemeManager.h" + +// See https://github.com/MultiMC/Launcher/issues/1790 +// or https://github.com/PrismLauncher/PrismLauncher/issues/490 +static const QStringList S_NATIVE_STYLES{ "windows11", "windowsvista", "macos", "system", "windows" }; + +SystemTheme::SystemTheme(const QString& styleName, const QPalette& defaultPalette, bool isDefaultTheme) +{ + m_themeName = isDefaultTheme ? "system" : styleName; + m_widgetTheme = styleName; + // NOTE: SystemTheme is reconstructed on page refresh. We can't accurately determine the system palette here + // See also S_NATIVE_STYLES comment + if (S_NATIVE_STYLES.contains(m_themeName)) { + m_colorPalette = defaultPalette; + } else { + auto style = QStyleFactory::create(styleName); + m_colorPalette = style != nullptr ? style->standardPalette() : defaultPalette; + delete style; + } +} + +void SystemTheme::apply(bool initial) +{ + // See S_NATIVE_STYLES comment + if (initial && S_NATIVE_STYLES.contains(m_themeName)) { + QApplication::setStyle(new HintOverrideProxyStyle(QStyleFactory::create(qtTheme()))); + return; + } + + ITheme::apply(initial); +} + +QString SystemTheme::id() +{ + return m_themeName; +} + +QString SystemTheme::name() +{ + if (m_themeName.toLower() == "windowsvista") { + return QObject::tr("Windows Vista"); + } else if (m_themeName.toLower() == "windows") { + return QObject::tr("Windows 9x"); + } else if (m_themeName.toLower() == "windows11") { + return QObject::tr("Windows 11"); + } else if (m_themeName.toLower() == "system") { + return QObject::tr("System"); + } else { + return m_themeName; + } +} + +QString SystemTheme::tooltip() +{ + if (m_themeName.toLower() == "windowsvista") { + return QObject::tr("Widget style trying to look like your win32 theme"); + } else if (m_themeName.toLower() == "windows") { + return QObject::tr("Windows 9x inspired widget style"); + } else if (m_themeName.toLower() == "windows11") { + return QObject::tr("WinUI 3 inspired Qt widget style"); + } else if (m_themeName.toLower() == "fusion") { + return QObject::tr("The default Qt widget style"); + } else if (m_themeName.toLower() == "system") { + return QObject::tr("Your current system theme"); + } else { + return ""; + } +} + +QString SystemTheme::qtTheme() +{ + return m_widgetTheme; +} + +QPalette SystemTheme::colorScheme() +{ + return m_colorPalette; +} + +QString SystemTheme::appStyleSheet() +{ + return QString(); +} + +double SystemTheme::fadeAmount() +{ + return 0.5; +} + +QColor SystemTheme::fadeColor() +{ + return QColor(128, 128, 128); +} + +bool SystemTheme::hasStyleSheet() +{ + return false; +} diff --git a/launcher/ui/themes/SystemTheme.h b/launcher/ui/themes/SystemTheme.h new file mode 100644 index 0000000..7ae24c3 --- /dev/null +++ b/launcher/ui/themes/SystemTheme.h @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2024 Tayou + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +#pragma once + +#include "ITheme.h" + +class SystemTheme : public ITheme { + public: + SystemTheme(const QString& styleName, const QPalette& defaultPalette, bool isDefaultTheme); + virtual ~SystemTheme() {} + void apply(bool initial) override; + + QString id() override; + QString name() override; + QString tooltip() override; + QString qtTheme() override; + bool hasStyleSheet() override; + QString appStyleSheet() override; + QPalette colorScheme() override; + double fadeAmount() override; + QColor fadeColor() override; + + private: + QPalette m_colorPalette; + QString m_widgetTheme; + QString m_themeName; +}; diff --git a/launcher/ui/themes/ThemeManager.cpp b/launcher/ui/themes/ThemeManager.cpp new file mode 100644 index 0000000..8947896 --- /dev/null +++ b/launcher/ui/themes/ThemeManager.cpp @@ -0,0 +1,363 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2024 Tayou + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +#include "ThemeManager.h" + +#include +#include +#include +#include +#include +#include +#include +#include "Exception.h" +#include "ui/themes/BrightTheme.h" +#include "ui/themes/CatPack.h" +#include "ui/themes/CustomTheme.h" +#include "ui/themes/DarkTheme.h" +#include "ui/themes/SystemTheme.h" + +#include "Application.h" +#include "settings/SettingsObject.h" + +ThemeManager::ThemeManager() +{ + QIcon::setFallbackThemeName(QIcon::themeName()); + QIcon::setThemeSearchPaths(QIcon::themeSearchPaths() << m_iconThemeFolder.path()); + + themeDebugLog() << "Determining System Widget Theme..."; + const auto& style = QApplication::style(); + m_defaultStyle = style->objectName(); + themeDebugLog() << "System theme seems to be:" << m_defaultStyle; + + m_defaultPalette = QApplication::palette(); + + initializeThemes(); + initializeCatPacks(); +} + +ThemeManager::~ThemeManager() +{ + stopSettingNewWindowColorsOnMac(); +} + +/// @brief Adds the Theme to the list of themes +/// @param theme The Theme to add +/// @return Theme ID +QString ThemeManager::addTheme(std::unique_ptr theme) +{ + QString id = theme->id(); + if (m_themes.find(id) == m_themes.end()) + m_themes.emplace(id, std::move(theme)); + else + themeWarningLog() << "Theme(" << id << ") not added to prevent id duplication"; + return id; +} + +/// @brief Gets the Theme from the List via ID +/// @param themeId Theme ID of theme to fetch +/// @return Theme at themeId +ITheme* ThemeManager::getTheme(QString themeId) +{ + return m_themes[themeId].get(); +} + +QString ThemeManager::addIconTheme(IconTheme theme) +{ + QString id = theme.id(); + if (m_icons.find(id) == m_icons.end()) + m_icons.emplace(id, std::move(theme)); + else + themeWarningLog() << "IconTheme(" << id << ") not added to prevent id duplication"; + return id; +} + +void ThemeManager::initializeThemes() +{ + // Icon themes + initializeIcons(); + + // Initialize widget themes + initializeWidgets(); +} + +void ThemeManager::initializeIcons() +{ + // TODO: icon themes and instance icons do not mesh well together. Rearrange and fix discrepancies! + // set icon theme search path! + themeDebugLog() << "<> Initializing Icon Themes"; + + for (const QString& id : builtinIcons) { + IconTheme theme(id, QString(":/icons/%1").arg(id)); + if (!theme.load()) { + themeWarningLog() << "Couldn't load built-in icon theme" << id; + continue; + } + + addIconTheme(std::move(theme)); + themeDebugLog() << "Loaded Built-In Icon Theme" << id; + } + + if (!m_iconThemeFolder.mkpath(".")) + themeWarningLog() << "Couldn't create icon theme folder"; + themeDebugLog() << "Icon Theme Folder Path:" << m_iconThemeFolder.absolutePath(); + + QDirIterator directoryIterator(m_iconThemeFolder.path(), QDir::Dirs | QDir::NoDotAndDotDot); + while (directoryIterator.hasNext()) { + QDir dir(directoryIterator.next()); + IconTheme theme(dir.dirName(), dir.path()); + if (!theme.load()) + continue; + + addIconTheme(std::move(theme)); + themeDebugLog() << "Loaded Custom Icon Theme from" << dir.path(); + } + + themeDebugLog() << "<> Icon themes initialized."; +} + +void ThemeManager::initializeWidgets() +{ + themeDebugLog() << "<> Initializing Widget Themes"; + themeDebugLog() << "Loading Built-in Theme:" << addTheme(std::make_unique(m_defaultStyle, m_defaultPalette, true)); + auto darkThemeId = addTheme(std::make_unique()); + themeDebugLog() << "Loading Built-in Theme:" << darkThemeId; + themeDebugLog() << "Loading Built-in Theme:" << addTheme(std::make_unique()); + + themeDebugLog() << "<> Initializing System Widget Themes"; + QStringList styles = QStyleFactory::keys(); + for (auto& st : styles) { +#ifdef Q_OS_WINDOWS + if (QSysInfo::productVersion() != "11" && st == "windows11") { + continue; + } +#endif + themeDebugLog() << "Loading System Theme:" << addTheme(std::make_unique(st, m_defaultPalette, false)); + } + + // TODO: need some way to differentiate same name themes in different subdirectories + // (maybe smaller grey text next to theme name in dropdown?) + + if (!m_applicationThemeFolder.mkpath(".")) + themeWarningLog() << "Couldn't create theme folder"; + themeDebugLog() << "Theme Folder Path:" << m_applicationThemeFolder.absolutePath(); + + QDirIterator directoryIterator(m_applicationThemeFolder.path(), QDir::Dirs | QDir::NoDotAndDotDot); + while (directoryIterator.hasNext()) { + QDir dir(directoryIterator.next()); + QFileInfo themeJson(dir.absoluteFilePath("theme.json")); + if (themeJson.exists()) { + // Load "theme.json" based themes + themeDebugLog() << "Loading JSON Theme from:" << themeJson.absoluteFilePath(); + addTheme(std::make_unique(getTheme(darkThemeId), themeJson, true)); + } else { + // Load pure QSS Themes + QDirIterator stylesheetFileIterator(dir.absoluteFilePath(""), { "*.qss", "*.css" }, QDir::Files); + while (stylesheetFileIterator.hasNext()) { + QFile customThemeFile(stylesheetFileIterator.next()); + QFileInfo customThemeFileInfo(customThemeFile); + themeDebugLog() << "Loading QSS Theme from:" << customThemeFileInfo.absoluteFilePath(); + addTheme(std::make_unique(getTheme(darkThemeId), customThemeFileInfo, false)); + } + } + } + + themeDebugLog() << "<> Widget themes initialized."; +} + +#ifndef Q_OS_MACOS +void ThemeManager::setTitlebarColorOnMac(WId windowId, QColor color) {} +void ThemeManager::setTitlebarColorOfAllWindowsOnMac(QColor color) {} +void ThemeManager::stopSettingNewWindowColorsOnMac() {} +#endif + +QList ThemeManager::getValidIconThemes() +{ + QList ret; + ret.reserve(m_icons.size()); + for (auto&& [id, theme] : m_icons) { + ret.append(&theme); + } + return ret; +} + +QList ThemeManager::getValidApplicationThemes() +{ + QList ret; + ret.reserve(m_themes.size()); + for (auto&& [id, theme] : m_themes) { + ret.append(theme.get()); + } + return ret; +} + +QList ThemeManager::getValidCatPacks() +{ + QList ret; + ret.reserve(m_catPacks.size()); + for (auto&& [id, theme] : m_catPacks) { + ret.append(theme.get()); + } + return ret; +} + +bool ThemeManager::isValidIconTheme(const QString& id) +{ + return !id.isEmpty() && m_icons.find(id) != m_icons.end(); +} + +bool ThemeManager::isValidApplicationTheme(const QString& id) +{ + return !id.isEmpty() && m_themes.find(id) != m_themes.end(); +} + +QDir ThemeManager::getIconThemesFolder() +{ + return m_iconThemeFolder; +} + +QDir ThemeManager::getApplicationThemesFolder() +{ + return m_applicationThemeFolder; +} + +QDir ThemeManager::getCatPacksFolder() +{ + return m_catPacksFolder; +} + +void ThemeManager::setIconTheme(const QString& name) +{ + if (m_icons.find(name) == m_icons.end()) { + themeWarningLog() << "Tried to set invalid icon theme:" << name; + return; + } + + QIcon::setThemeName(name); +} + +void ThemeManager::setApplicationTheme(const QString& name, bool initial) +{ + auto systemPalette = qApp->palette(); + auto themeIter = m_themes.find(name); + if (themeIter != m_themes.end()) { + auto& theme = themeIter->second; + themeDebugLog() << "applying theme" << theme->name(); + theme->apply(initial); + setTitlebarColorOfAllWindowsOnMac(qApp->palette().window().color()); + + m_logColors = theme->logColorScheme(); + } else { + themeWarningLog() << "Tried to set invalid theme:" << name; + } +} + +void ThemeManager::applyCurrentlySelectedTheme(bool initial) +{ + auto settings = APPLICATION->settings(); + setIconTheme(settings->get("IconTheme").toString()); + themeDebugLog() << "<> Icon theme set."; + auto applicationTheme = settings->get("ApplicationTheme").toString(); + if (applicationTheme == "") { + applicationTheme = m_defaultStyle; + } + setApplicationTheme(applicationTheme, initial); + themeDebugLog() << "<> Application theme set."; +} + +QString ThemeManager::getCatPack(QString catName) +{ + auto catIter = m_catPacks.find(!catName.isEmpty() ? catName : APPLICATION->settings()->get("BackgroundCat").toString()); + if (catIter != m_catPacks.end()) { + auto& catPack = catIter->second; + themeDebugLog() << "applying catpack" << catPack->id(); + return catPack->path(); + } else { + themeWarningLog() << "Tried to get invalid catPack:" << catName; + } + + return m_catPacks.begin()->second->path(); +} + +QString ThemeManager::addCatPack(std::unique_ptr catPack) +{ + QString id = catPack->id(); + if (m_catPacks.find(id) == m_catPacks.end()) + m_catPacks.emplace(id, std::move(catPack)); + else + themeWarningLog() << "CatPack(" << id << ") not added to prevent id duplication"; + return id; +} + +void ThemeManager::initializeCatPacks() +{ + QList> defaultCats{ { "kitteh", QObject::tr("Background Cat (from MultiMC)") }, + { "rory", QObject::tr("Rory ID 11 (drawn by Ashtaka)") }, + { "rory-flat", QObject::tr("Rory ID 11 (flat edition, drawn by Ashtaka)") }, + { "teawie", QObject::tr("Teawie (drawn by SympathyTea)") } }; + for (auto [id, name] : defaultCats) { + addCatPack(std::unique_ptr(new BasicCatPack(id, name))); + } + if (!m_catPacksFolder.mkpath(".")) + themeWarningLog() << "Couldn't create catpacks folder"; + themeDebugLog() << "CatPacks Folder Path:" << m_catPacksFolder.absolutePath(); + + QStringList supportedImageFormats; + for (auto format : QImageReader::supportedImageFormats()) { + supportedImageFormats.append("*." + format); + } + auto loadFiles = [this, supportedImageFormats](QDir dir) { + // Load image files directly + QDirIterator ImageFileIterator(dir.absoluteFilePath(""), supportedImageFormats, QDir::Files); + while (ImageFileIterator.hasNext()) { + QFile customCatFile(ImageFileIterator.next()); + QFileInfo customCatFileInfo(customCatFile); + themeDebugLog() << "Loading CatPack from:" << customCatFileInfo.absoluteFilePath(); + addCatPack(std::unique_ptr(new FileCatPack(customCatFileInfo))); + } + }; + + loadFiles(m_catPacksFolder); + + QDirIterator directoryIterator(m_catPacksFolder.path(), QDir::Dirs | QDir::NoDotAndDotDot); + while (directoryIterator.hasNext()) { + QDir dir(directoryIterator.next()); + QFileInfo manifest(dir.absoluteFilePath("catpack.json")); + if (manifest.isFile()) { + try { + // Load background manifest + themeDebugLog() << "Loading background manifest from:" << manifest.absoluteFilePath(); + addCatPack(std::unique_ptr(new JsonCatPack(manifest))); + } catch (const Exception& e) { + themeWarningLog() << "Couldn't load catpack json:" << e.cause(); + } + } else { + loadFiles(dir); + } + } +} + +void ThemeManager::refresh() +{ + m_themes.clear(); + m_icons.clear(); + m_catPacks.clear(); + + initializeThemes(); + initializeCatPacks(); +} diff --git a/launcher/ui/themes/ThemeManager.h b/launcher/ui/themes/ThemeManager.h new file mode 100644 index 0000000..d28365a --- /dev/null +++ b/launcher/ui/themes/ThemeManager.h @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2024 Tayou + * Copyright (C) 2024 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +#pragma once + +#include +#include +#include +#include + +#include "IconTheme.h" +#include "ui/themes/CatPack.h" +#include "ui/themes/ITheme.h" + +inline auto themeDebugLog() { + return qDebug() << "[Theme]"; +} +inline auto themeWarningLog() { + return qWarning() << "[Theme]"; +} + +class ThemeManager { + public: + ThemeManager(); + ~ThemeManager(); + + QList getValidIconThemes(); + QList getValidApplicationThemes(); + bool isValidIconTheme(const QString& id); + bool isValidApplicationTheme(const QString& id); + QDir getIconThemesFolder(); + QDir getApplicationThemesFolder(); + QDir getCatPacksFolder(); + void applyCurrentlySelectedTheme(bool initial = false); + void setIconTheme(const QString& name); + void setApplicationTheme(const QString& name, bool initial = false); + + /// @brief Returns the background based on selected and with events (Birthday, XMas, etc.) + /// @param catName Optional, if you need a specific background. + /// @return + QString getCatPack(QString catName = ""); + QList getValidCatPacks(); + + const LogColors& getLogColors() { return m_logColors; } + + void refresh(); + + private: + std::map> m_themes; + std::map m_icons; + QDir m_iconThemeFolder{"iconthemes"}; + QDir m_applicationThemeFolder{"themes"}; + QDir m_catPacksFolder{"catpacks"}; + std::map> m_catPacks; + QPalette m_defaultPalette; + QString m_defaultStyle; + LogColors m_logColors; + + void initializeThemes(); + void initializeCatPacks(); + QString addTheme(std::unique_ptr theme); + ITheme* getTheme(QString themeId); + QString addIconTheme(IconTheme theme); + QString addCatPack(std::unique_ptr catPack); + void initializeIcons(); + void initializeWidgets(); + + // On non-Mac systems, this is a no-op. + void setTitlebarColorOnMac(WId windowId, QColor color); + // This also will set the titlebar color of newly opened windows after this method is called. + // On non-Mac systems, this is a no-op. + void setTitlebarColorOfAllWindowsOnMac(QColor color); + // On non-Mac systems, this is a no-op. + void stopSettingNewWindowColorsOnMac(); +#ifdef Q_OS_MACOS + NSObject* m_windowTitlebarObserver = nullptr; +#endif + + const QStringList builtinIcons{"flat_white", "racked_ru"}; +}; diff --git a/launcher/ui/themes/ThemeManager.mm b/launcher/ui/themes/ThemeManager.mm new file mode 100644 index 0000000..d9fc291 --- /dev/null +++ b/launcher/ui/themes/ThemeManager.mm @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2025 Kenneth Chew <79120643+kthchew@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "ThemeManager.h" + +#include + +void ThemeManager::setTitlebarColorOnMac(WId windowId, QColor color) +{ + if (windowId == 0) { + return; + } + + NSView* view = (NSView*)windowId; + NSWindow* window = [view window]; + window.titlebarAppearsTransparent = YES; + window.backgroundColor = [NSColor colorWithRed:color.redF() green:color.greenF() blue:color.blueF() alpha:color.alphaF()]; + + // Unfortunately there seems to be no easy way to set the titlebar text color. + // The closest we can do without dubious hacks is set the dark/light mode state based on the brightness of the + // background color, which should at least make the text readable even if we can't use the theme's text color. + // It's a good idea to set this anyway since it also affects some other UI elements like text shadows (PrismLauncher#3825). + if (color.lightnessF() < 0.5) { + window.appearance = [NSAppearance appearanceNamed:NSAppearanceNameDarkAqua]; + } else { + window.appearance = [NSAppearance appearanceNamed:NSAppearanceNameAqua]; + } +} + +void ThemeManager::setTitlebarColorOfAllWindowsOnMac(QColor color) +{ + NSArray* windows = [NSApp windows]; + for (NSWindow* window : windows) { + setTitlebarColorOnMac((WId)window.contentView, color); + } + + // We want to change the titlebar color of newly opened windows as well. + // There's no notification for when a new window is opened, but we can set the color when a window switches + // from occluded to visible, which also fires on open. + NSNotificationCenter* center = [NSNotificationCenter defaultCenter]; + stopSettingNewWindowColorsOnMac(); + m_windowTitlebarObserver = [center addObserverForName:NSWindowDidChangeOcclusionStateNotification + object:nil + queue:[NSOperationQueue mainQueue] + usingBlock:^(NSNotification* notification) { + NSWindow* window = notification.object; + setTitlebarColorOnMac((WId)window.contentView, color); + }]; +} + +void ThemeManager::stopSettingNewWindowColorsOnMac() +{ + if (m_windowTitlebarObserver) { + NSNotificationCenter* center = [NSNotificationCenter defaultCenter]; + [center removeObserver:m_windowTitlebarObserver]; + m_windowTitlebarObserver = nil; + } +} diff --git a/launcher/ui/widgets/AppearanceWidget.cpp b/launcher/ui/widgets/AppearanceWidget.cpp new file mode 100644 index 0000000..41a80dc --- /dev/null +++ b/launcher/ui/widgets/AppearanceWidget.cpp @@ -0,0 +1,280 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2025 TheKodeToad + * Copyright (C) 2022 Tayou + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "AppearanceWidget.h" +#include "ui_AppearanceWidget.h" + +#include +#include +#include "BuildConfig.h" +#include "ui/themes/ITheme.h" +#include "ui/themes/ThemeManager.h" + +#include +#include "settings/SettingsObject.h" + +AppearanceWidget::AppearanceWidget(bool themesOnly, QWidget* parent) + : QWidget(parent), m_ui(new Ui::AppearanceWidget), m_themesOnly(themesOnly) +{ + m_ui->setupUi(this); + + m_ui->catPreview->setGraphicsEffect(new QGraphicsOpacityEffect(this)); + + m_defaultFormat = QTextCharFormat(m_ui->consolePreview->currentCharFormat()); + + if (themesOnly) { + m_ui->catPackLabel->hide(); + m_ui->catPackComboBox->hide(); + m_ui->catPackFolder->hide(); + m_ui->settingsBox->hide(); + m_ui->consolePreview->hide(); + m_ui->catPreview->hide(); + loadThemeSettings(); + } else { + loadSettings(); + loadThemeSettings(); + + updateConsolePreview(); + updateCatPreview(); + } + + connect(m_ui->fontSizeBox, &QSpinBox::valueChanged, this, &AppearanceWidget::updateConsolePreview); + connect(m_ui->consoleFont, &QFontComboBox::currentFontChanged, this, &AppearanceWidget::updateConsolePreview); + + connect(m_ui->iconsComboBox, &QComboBox::currentIndexChanged, this, &AppearanceWidget::applyIconTheme); + connect(m_ui->widgetStyleComboBox, &QComboBox::currentIndexChanged, this, &AppearanceWidget::applyWidgetTheme); + connect(m_ui->catPackComboBox, &QComboBox::currentIndexChanged, this, &AppearanceWidget::applyCatTheme); + connect(m_ui->catOpacitySlider, &QAbstractSlider::valueChanged, this, &AppearanceWidget::updateCatPreview); + + connect(m_ui->iconsFolder, &QPushButton::clicked, this, + [] { DesktopServices::openPath(APPLICATION->themeManager()->getIconThemesFolder().path()); }); + connect(m_ui->widgetStyleFolder, &QPushButton::clicked, this, + [] { DesktopServices::openPath(APPLICATION->themeManager()->getApplicationThemesFolder().path()); }); + connect(m_ui->catPackFolder, &QPushButton::clicked, this, + [] { DesktopServices::openPath(APPLICATION->themeManager()->getCatPacksFolder().path()); }); + connect(m_ui->reloadThemesButton, &QPushButton::pressed, this, &AppearanceWidget::loadThemeSettings); +} + +AppearanceWidget::~AppearanceWidget() +{ + delete m_ui; +} + +void AppearanceWidget::applySettings() +{ + SettingsObject* settings = APPLICATION->settings(); + QString consoleFontFamily = m_ui->consoleFont->currentFont().family(); + settings->set("ConsoleFont", consoleFontFamily); + settings->set("ConsoleFontSize", m_ui->fontSizeBox->value()); + settings->set("CatOpacity", m_ui->catOpacitySlider->value()); + auto catFit = m_ui->catFitComboBox->currentIndex(); + settings->set("CatFit", catFit == 0 ? "fit" : catFit == 1 ? "fill" : "strech"); +} + +void AppearanceWidget::loadSettings() +{ + SettingsObject* settings = APPLICATION->settings(); + QString fontFamily = settings->get("ConsoleFont").toString(); + QFont consoleFont(fontFamily); + m_ui->consoleFont->setCurrentFont(consoleFont); + + bool conversionOk = true; + int fontSize = settings->get("ConsoleFontSize").toInt(&conversionOk); + if (!conversionOk) { + fontSize = 11; + } + m_ui->fontSizeBox->setValue(fontSize); + + m_ui->catOpacitySlider->setValue(settings->get("CatOpacity").toInt()); + + auto catFit = settings->get("CatFit").toString(); + m_ui->catFitComboBox->setCurrentIndex(catFit == "fit" ? 0 : catFit == "fill" ? 1 : 2); +} + +void AppearanceWidget::retranslateUi() +{ + m_ui->retranslateUi(this); +} + +void AppearanceWidget::applyIconTheme(int index) +{ + auto settings = APPLICATION->settings(); + auto originalIconTheme = settings->get("IconTheme").toString(); + auto newIconTheme = m_ui->iconsComboBox->itemData(index).toString(); + if (originalIconTheme != newIconTheme) { + settings->set("IconTheme", newIconTheme); + APPLICATION->themeManager()->applyCurrentlySelectedTheme(); + } +} + +void AppearanceWidget::applyWidgetTheme(int index) +{ + auto settings = APPLICATION->settings(); + auto originalAppTheme = settings->get("ApplicationTheme").toString(); + auto newAppTheme = m_ui->widgetStyleComboBox->itemData(index).toString(); + if (originalAppTheme != newAppTheme) { + settings->set("ApplicationTheme", newAppTheme); + APPLICATION->themeManager()->applyCurrentlySelectedTheme(); + } + + updateConsolePreview(); +} + +void AppearanceWidget::applyCatTheme(int index) +{ + auto settings = APPLICATION->settings(); + auto originalCat = settings->get("BackgroundCat").toString(); + auto newCat = m_ui->catPackComboBox->itemData(index).toString(); + if (originalCat != newCat) { + settings->set("BackgroundCat", newCat); + } + + APPLICATION->currentCatChanged(index); + updateCatPreview(); +} + +void AppearanceWidget::loadThemeSettings() +{ + APPLICATION->themeManager()->refresh(); + + m_ui->iconsComboBox->blockSignals(true); + m_ui->widgetStyleComboBox->blockSignals(true); + m_ui->catPackComboBox->blockSignals(true); + + m_ui->iconsComboBox->clear(); + m_ui->widgetStyleComboBox->clear(); + m_ui->catPackComboBox->clear(); + + SettingsObject* settings = APPLICATION->settings(); + + const QString currentIconTheme = settings->get("IconTheme").toString(); + const auto iconThemes = APPLICATION->themeManager()->getValidIconThemes(); + + for (int i = 0; i < iconThemes.count(); ++i) { + const IconTheme* theme = iconThemes[i]; + + QIcon iconForComboBox = QIcon(theme->path() + "/scalable/settings"); + m_ui->iconsComboBox->addItem(iconForComboBox, theme->name(), theme->id()); + + if (currentIconTheme == theme->id()) + m_ui->iconsComboBox->setCurrentIndex(i); + } + + const QString currentTheme = settings->get("ApplicationTheme").toString(); + auto themes = APPLICATION->themeManager()->getValidApplicationThemes(); + for (int i = 0; i < themes.count(); ++i) { + ITheme* theme = themes[i]; + + m_ui->widgetStyleComboBox->addItem(theme->name(), theme->id()); + + if (!theme->tooltip().isEmpty()) + m_ui->widgetStyleComboBox->setItemData(i, theme->tooltip(), Qt::ToolTipRole); + + if (currentTheme == theme->id()) + m_ui->widgetStyleComboBox->setCurrentIndex(i); + } + + if (!m_themesOnly) { + const QString currentCat = settings->get("BackgroundCat").toString(); + const auto cats = APPLICATION->themeManager()->getValidCatPacks(); + for (int i = 0; i < cats.count(); ++i) { + const CatPack* cat = cats[i]; + + QIcon catIcon = QIcon(QString("%1").arg(cat->path())); + m_ui->catPackComboBox->addItem(catIcon, cat->name(), cat->id()); + + if (currentCat == cat->id()) + m_ui->catPackComboBox->setCurrentIndex(i); + } + } + + m_ui->iconsComboBox->blockSignals(false); + m_ui->widgetStyleComboBox->blockSignals(false); + m_ui->catPackComboBox->blockSignals(false); +} + +void AppearanceWidget::updateConsolePreview() +{ + const LogColors& colors = APPLICATION->themeManager()->getLogColors(); + + int fontSize = m_ui->fontSizeBox->value(); + QString fontFamily = m_ui->consoleFont->currentFont().family(); + m_ui->consolePreview->clear(); + m_defaultFormat.setFont(QFont(fontFamily, fontSize)); + + auto print = [this, colors](const QString& message, MessageLevel level) { + QTextCharFormat format(m_defaultFormat); + + QColor bg = colors.background.value(level); + QColor fg = colors.foreground.value(level); + + if (bg.isValid()) + format.setBackground(bg); + + if (fg.isValid()) + format.setForeground(fg); + + // append a paragraph/line + auto workCursor = m_ui->consolePreview->textCursor(); + workCursor.movePosition(QTextCursor::End); + workCursor.insertText(message, format); + workCursor.insertBlock(); + }; + + print(QString("%1 version: %2\n").arg(BuildConfig.LAUNCHER_DISPLAYNAME, BuildConfig.printableVersionString()), MessageLevel::Launcher); + + QDate today = QDate::currentDate(); + + if (today.month() == 10 && today.day() == 31) + print(tr("[ERROR] OOoooOOOoooo! A spooky error!"), MessageLevel::Error); + else + print(tr("[ERROR] A spooky error!"), MessageLevel::Error); + + print(tr("[INFO] A harmless message..."), MessageLevel::Info); + print(tr("[WARN] A not so spooky warning."), MessageLevel::Warning); + print(tr("[DEBUG] A secret debugging message..."), MessageLevel::Debug); + print(tr("[FATAL] A terrifying fatal error!"), MessageLevel::Fatal); +} + +void AppearanceWidget::updateCatPreview() +{ + QIcon catPackIcon(APPLICATION->themeManager()->getCatPack()); + m_ui->catPreview->setIcon(catPackIcon); + + auto effect = dynamic_cast(m_ui->catPreview->graphicsEffect()); + if (effect) + effect->setOpacity(m_ui->catOpacitySlider->value() / 100.0); +} diff --git a/launcher/ui/widgets/AppearanceWidget.h b/launcher/ui/widgets/AppearanceWidget.h new file mode 100644 index 0000000..a63c531 --- /dev/null +++ b/launcher/ui/widgets/AppearanceWidget.h @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2025 TheKodeToad + * Copyright (C) 2022 Tayou + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include + +#include +#include + +class QTextCharFormat; +class SettingsObject; + +namespace Ui { +class AppearanceWidget; +} + +class AppearanceWidget : public QWidget { + Q_OBJECT + + public: + explicit AppearanceWidget(bool simple, QWidget* parent = 0); + virtual ~AppearanceWidget(); + + public: + void applySettings(); + void loadSettings(); + void retranslateUi(); + + private: + void applyIconTheme(int index); + void applyWidgetTheme(int index); + void applyCatTheme(int index); + void loadThemeSettings(); + + void updateConsolePreview(); + void updateCatPreview(); + + Ui::AppearanceWidget* m_ui; + QTextCharFormat m_defaultFormat; + bool m_themesOnly; +}; diff --git a/launcher/ui/widgets/AppearanceWidget.ui b/launcher/ui/widgets/AppearanceWidget.ui new file mode 100644 index 0000000..f74ab64 --- /dev/null +++ b/launcher/ui/widgets/AppearanceWidget.ui @@ -0,0 +1,632 @@ + + + AppearanceWidget + + + + 0 + 0 + 600 + 583 + + + + + 300 + 0 + + + + + + + + + + false + + + + + + + + View cat packs folder. + + + Open Folder + + + + + + + View widget themes folder. + + + Open Folder + + + + + + + View icon themes folder. + + + Open Folder + + + + + + + &Cat Pack: + + + catPackComboBox + + + + + + + + + + + 0 + 0 + + + + Qt::FocusPolicy::StrongFocus + + + + + + + + 0 + 0 + + + + Qt::FocusPolicy::StrongFocus + + + + + + + + 0 + 0 + + + + Reload All + + + + + + + Theme: + + + widgetStyleComboBox + + + + + + + &Icons: + + + iconsComboBox + + + + + + + + + + + + + + + + + + + + Console Font: + + + + + + + + + + + 0 + 0 + + + + 5 + + + 16 + + + 11 + + + + + + + Qt::Orientation::Horizontal + + + + 0 + 0 + + + + + + + + + + Qt::Orientation::Vertical + + + QSizePolicy::Policy::Fixed + + + + 0 + 6 + + + + + + + + Cat Opacity + + + + + + + + 300 + 16777215 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + 300 + 16777215 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + false + + + Opaque + + + + + + + Qt::Orientation::Horizontal + + + + 0 + 0 + + + + + + + + false + + + Transparent + + + + + + + + 0 + 0 + + + + 100 + + + Qt::Orientation::Horizontal + + + + + + + + + + Qt::Orientation::Vertical + + + QSizePolicy::Policy::Fixed + + + + 0 + 6 + + + + + + + + + 0 + 0 + + + + Cat Scaling + + + + + + + + 0 + 0 + + + + + 81 + 32 + + + + 0 + + + + Fit + + + + + Fill + + + + + Stretch + + + + + + + + + + + Preview + + + + + + + + + 0 + 0 + + + + Qt::FocusPolicy::NoFocus + + + + + + + 64 + 128 + + + + true + + + + + + + + + + + Qt::FocusPolicy::NoFocus + + + + + + + + + true + + + + + + + Qt::FocusPolicy::NoFocus + + + + + + + + + true + + + + + + + Qt::FocusPolicy::NoFocus + + + + + + + + + true + + + + + + + Qt::FocusPolicy::NoFocus + + + + + + + + + true + + + + + + + Qt::FocusPolicy::NoFocus + + + + + + + + + true + + + + + + + Qt::FocusPolicy::NoFocus + + + + + + + + + true + + + + + + + Qt::FocusPolicy::NoFocus + + + + + + + + + true + + + + + + + Qt::FocusPolicy::NoFocus + + + + + + + + + true + + + + + + + Qt::FocusPolicy::NoFocus + + + + + + + + + true + + + + + + + Qt::FocusPolicy::NoFocus + + + + + + + + + true + + + + + + + Qt::Orientation::Horizontal + + + + 0 + 0 + + + + + + + + + + + 0 + 0 + + + + Qt::ScrollBarPolicy::ScrollBarAsNeeded + + + false + + + Qt::TextInteractionFlag::TextSelectableByKeyboard|Qt::TextInteractionFlag::TextSelectableByMouse + + + + + + + + + + + + + + widgetStyleComboBox + widgetStyleFolder + iconsComboBox + iconsFolder + catPackComboBox + catPackFolder + reloadThemesButton + consoleFont + fontSizeBox + catFitComboBox + catOpacitySlider + consolePreview + + + + diff --git a/launcher/ui/widgets/CheckComboBox.cpp b/launcher/ui/widgets/CheckComboBox.cpp new file mode 100644 index 0000000..dddf533 --- /dev/null +++ b/launcher/ui/widgets/CheckComboBox.cpp @@ -0,0 +1,206 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "CheckComboBox.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class CheckComboModel : public QIdentityProxyModel { + Q_OBJECT + + public: + explicit CheckComboModel(QObject* parent = nullptr) : QIdentityProxyModel(parent) {} + + virtual Qt::ItemFlags flags(const QModelIndex& index) const { return QIdentityProxyModel::flags(index) | Qt::ItemIsUserCheckable; } + virtual QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const + { + if (role == Qt::CheckStateRole) { + auto txt = QIdentityProxyModel::data(index, Qt::DisplayRole).toString(); + return m_checked.contains(txt) ? Qt::Checked : Qt::Unchecked; + } + if (role == Qt::DisplayRole) + return QIdentityProxyModel::data(index, Qt::DisplayRole); + return {}; + } + virtual bool setData(const QModelIndex& index, const QVariant& value, int role = Qt::EditRole) + { + if (role == Qt::CheckStateRole) { + auto txt = QIdentityProxyModel::data(index, Qt::DisplayRole).toString(); + if (m_checked.contains(txt)) { + m_checked.removeOne(txt); + } else { + m_checked.push_back(txt); + } + emit dataChanged(index, index); + emit checkStateChanged(); + return true; + } + return QIdentityProxyModel::setData(index, value, role); + } + QStringList getChecked() { return m_checked; } + + signals: + void checkStateChanged(); + + private: + QStringList m_checked; +}; + +CheckComboBox::CheckComboBox(QWidget* parent) : QComboBox(parent), m_separator(", ") +{ + view()->installEventFilter(this); + view()->window()->installEventFilter(this); + view()->viewport()->installEventFilter(this); + this->installEventFilter(this); +} + +void CheckComboBox::setSourceModel(QAbstractItemModel* new_model) +{ + auto proxy = new CheckComboModel(this); + proxy->setSourceModel(new_model); + model()->disconnect(this); + QComboBox::setModel(proxy); + connect(this, &QComboBox::activated, this, &CheckComboBox::toggleCheckState); + connect(proxy, &CheckComboModel::checkStateChanged, this, &CheckComboBox::emitCheckedItemsChanged); + connect(model(), &CheckComboModel::rowsInserted, this, &CheckComboBox::emitCheckedItemsChanged); + connect(model(), &CheckComboModel::rowsRemoved, this, &CheckComboBox::emitCheckedItemsChanged); +} + +void CheckComboBox::hidePopup() +{ + if (!m_containerMousePress) + QComboBox::hidePopup(); +} + +void CheckComboBox::emitCheckedItemsChanged() +{ + emit checkedItemsChanged(checkedItems()); +} + +QString CheckComboBox::defaultText() const +{ + return m_default_text; +} + +void CheckComboBox::setDefaultText(const QString& text) +{ + m_default_text = text; +} + +QString CheckComboBox::separator() const +{ + return m_separator; +} + +void CheckComboBox::setSeparator(const QString& separator) +{ + m_separator = separator; +} + +bool CheckComboBox::eventFilter(QObject* receiver, QEvent* event) +{ + switch (event->type()) { + case QEvent::KeyPress: + case QEvent::KeyRelease: { + QKeyEvent* keyEvent = static_cast(event); + if (receiver == this && (keyEvent->key() == Qt::Key_Up || keyEvent->key() == Qt::Key_Down)) { + showPopup(); + return true; + } else if (keyEvent->key() == Qt::Key_Enter || keyEvent->key() == Qt::Key_Return || keyEvent->key() == Qt::Key_Escape) { + QComboBox::hidePopup(); + return (keyEvent->key() != Qt::Key_Escape); + } + break; + } + case QEvent::MouseButtonPress: { + auto ev = static_cast(event); + m_containerMousePress = ev && view()->indexAt(ev->pos()).isValid() && view()->rect().contains(ev->pos()); + break; + } + case QEvent::Wheel: + return receiver == this; + default: + break; + } + return false; +} + +void CheckComboBox::toggleCheckState(int index) +{ + QVariant value = itemData(index, Qt::CheckStateRole); + if (value.isValid()) { + Qt::CheckState state = static_cast(value.toInt()); + setItemData(index, (state == Qt::Unchecked ? Qt::Checked : Qt::Unchecked), Qt::CheckStateRole); + } + emitCheckedItemsChanged(); +} + +Qt::CheckState CheckComboBox::itemCheckState(int index) const +{ + return static_cast(itemData(index, Qt::CheckStateRole).toInt()); +} + +void CheckComboBox::setItemCheckState(int index, Qt::CheckState state) +{ + setItemData(index, state, Qt::CheckStateRole); +} + +QStringList CheckComboBox::checkedItems() const +{ + if (model()) + return dynamic_cast(model())->getChecked(); + return {}; +} + +void CheckComboBox::setCheckedItems(const QStringList& items) +{ + for (auto text : items) { + auto index = findText(text); + setItemCheckState(index, index != -1 ? Qt::Checked : Qt::Unchecked); + } +} + +void CheckComboBox::paintEvent(QPaintEvent*) +{ + QStylePainter painter(this); + painter.setPen(palette().color(QPalette::Text)); + + // draw the combobox frame, focusrect and selected etc. + QStyleOptionComboBox opt; + initStyleOption(&opt); + QStringList items = checkedItems(); + if (items.isEmpty()) + opt.currentText = defaultText(); + else + opt.currentText = items.join(separator()); + painter.drawComplexControl(QStyle::CC_ComboBox, opt); + + // draw the icon and text + painter.drawControl(QStyle::CE_ComboBoxLabel, opt); +} + +#include "CheckComboBox.moc" diff --git a/launcher/ui/widgets/CheckComboBox.h b/launcher/ui/widgets/CheckComboBox.h new file mode 100644 index 0000000..10e2a81 --- /dev/null +++ b/launcher/ui/widgets/CheckComboBox.h @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include + +class CheckComboBox : public QComboBox { + Q_OBJECT + + public: + explicit CheckComboBox(QWidget* parent = nullptr); + virtual ~CheckComboBox() = default; + + void hidePopup() override; + + QString defaultText() const; + void setDefaultText(const QString& text); + + Qt::CheckState itemCheckState(int index) const; + void setItemCheckState(int index, Qt::CheckState state); + + QString separator() const; + void setSeparator(const QString& separator); + + QStringList checkedItems() const; + + void setSourceModel(QAbstractItemModel* model); + + public slots: + void setCheckedItems(const QStringList& items); + + signals: + void checkedItemsChanged(const QStringList& items); + + protected: + void paintEvent(QPaintEvent*) override; + + private: + void emitCheckedItemsChanged(); + bool eventFilter(QObject* receiver, QEvent* event) override; + void toggleCheckState(int index); + + private: + QString m_default_text; + QString m_separator; + bool m_containerMousePress = false; +}; diff --git a/launcher/ui/widgets/Common.cpp b/launcher/ui/widgets/Common.cpp new file mode 100644 index 0000000..097bb6d --- /dev/null +++ b/launcher/ui/widgets/Common.cpp @@ -0,0 +1,33 @@ +#include "Common.h" + +// Origin: Qt +// More specifically, this is a trimmed down version on the algorithm in: +// https://code.woboq.org/qt5/qtbase/src/widgets/styles/qcommonstyle.cpp.html#846 +QList> viewItemTextLayout(QTextLayout& textLayout, int lineWidth, qreal& height) +{ + QList> lines; + height = 0; + + textLayout.beginLayout(); + + QString str = textLayout.text(); + while (true) { + QTextLine line = textLayout.createLine(); + + if (!line.isValid()) + break; + if (line.textLength() == 0) + break; + + line.setLineWidth(lineWidth); + line.setPosition(QPointF(0, height)); + + height += line.height(); + + lines.append(std::make_pair(line.naturalTextWidth(), str.mid(line.textStart(), line.textLength()))); + } + + textLayout.endLayout(); + + return lines; +} diff --git a/launcher/ui/widgets/Common.h b/launcher/ui/widgets/Common.h new file mode 100644 index 0000000..b3dd5ca --- /dev/null +++ b/launcher/ui/widgets/Common.h @@ -0,0 +1,9 @@ +#pragma once + +#include + +/** Cuts out the text in textLayout into smaller pieces, according to the lineWidth. + * Returns a list of pairs, each containing the width of that line and that line's string, respectively. + * The total height of those lines is set in the last argument, 'height'. + */ +QList> viewItemTextLayout(QTextLayout& textLayout, int lineWidth, qreal& height); diff --git a/launcher/ui/widgets/CustomCommands.cpp b/launcher/ui/widgets/CustomCommands.cpp new file mode 100644 index 0000000..ddeaefc --- /dev/null +++ b/launcher/ui/widgets/CustomCommands.cpp @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "CustomCommands.h" +#include "ui_CustomCommands.h" + +CustomCommands::~CustomCommands() +{ + delete ui; +} + +CustomCommands::CustomCommands(QWidget* parent) : QWidget(parent), ui(new Ui::CustomCommands) +{ + ui->setupUi(this); + connect(ui->overrideCheckBox, &QCheckBox::toggled, ui->customCommandsWidget, &QWidget::setEnabled); +} + +void CustomCommands::initialize(bool checkable, bool checked, const QString& prelaunch, const QString& wrapper, const QString& postexit) +{ + ui->overrideCheckBox->setVisible(checkable); + if (checkable) { + ui->overrideCheckBox->setChecked(checked); + } + ui->preLaunchCmdTextBox->setText(prelaunch); + ui->wrapperCmdTextBox->setText(wrapper); + ui->postExitCmdTextBox->setText(postexit); +} + +void CustomCommands::retranslate() +{ + ui->retranslateUi(this); +} + +bool CustomCommands::checked() const +{ + return ui->overrideCheckBox->isChecked(); +} + +QString CustomCommands::prelaunchCommand() const +{ + return ui->preLaunchCmdTextBox->text(); +} + +QString CustomCommands::wrapperCommand() const +{ + return ui->wrapperCmdTextBox->text(); +} + +QString CustomCommands::postexitCommand() const +{ + return ui->postExitCmdTextBox->text(); +} diff --git a/launcher/ui/widgets/CustomCommands.h b/launcher/ui/widgets/CustomCommands.h new file mode 100644 index 0000000..5b410ae --- /dev/null +++ b/launcher/ui/widgets/CustomCommands.h @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +namespace Ui { +class CustomCommands; +} + +class CustomCommands : public QWidget { + Q_OBJECT + + public: + explicit CustomCommands(QWidget* parent = 0); + virtual ~CustomCommands(); + void initialize(bool checkable, bool checked, const QString& prelaunch, const QString& wrapper, const QString& postexit); + + void retranslate(); + bool checked() const; + QString prelaunchCommand() const; + QString wrapperCommand() const; + QString postexitCommand() const; + + private: + Ui::CustomCommands* ui; +}; diff --git a/launcher/ui/widgets/CustomCommands.ui b/launcher/ui/widgets/CustomCommands.ui new file mode 100644 index 0000000..6c1366c --- /dev/null +++ b/launcher/ui/widgets/CustomCommands.ui @@ -0,0 +1,158 @@ + + + CustomCommands + + + + 0 + 0 + 518 + 646 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Override &Global Settings + + + true + + + + + + + true + + + + 0 + + + 0 + + + 0 + + + + + &Pre-launch Command + + + preLaunchCmdTextBox + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 0 + 6 + + + + + + + + + + + + + + P&ost-exit Command + + + postExitCmdTextBox + + + + + + + + + + &Wrapper Command + + + wrapperCmdTextBox + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 0 + 6 + + + + + + + + + + + <html><head/><body><p>Pre-launch command runs before the instance launches and post-exit command runs after it exits.</p><p>Both will be run in the launcher's working folder with extra environment variables:</p><ul><li>$INST_NAME - Name of the instance</li><li>$INST_ID - ID of the instance (its folder name)</li><li>$INST_DIR - absolute path of the instance</li><li>$INST_MC_DIR - absolute path of Minecraft</li><li>$INST_JAVA - Java binary used for launch</li><li>$INST_JAVA_ARGS - command-line parameters used for launch (warning: will not work correctly if arguments contain spaces)</li></ul><p>Wrapper command allows launching using an extra wrapper program (like 'optirun' on Linux)</p></body></html> + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + true + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + diff --git a/launcher/ui/widgets/EnvironmentVariables.cpp b/launcher/ui/widgets/EnvironmentVariables.cpp new file mode 100644 index 0000000..9387ef2 --- /dev/null +++ b/launcher/ui/widgets/EnvironmentVariables.cpp @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include + +#include "Application.h" +#include "EnvironmentVariables.h" +#include "ui/dialogs/CustomMessageBox.h" +#include "ui_EnvironmentVariables.h" + +EnvironmentVariables::EnvironmentVariables(QWidget* parent) : QWidget(parent), ui(new Ui::EnvironmentVariables) +{ + ui->setupUi(this); + ui->list->installEventFilter(this); + + ui->list->sortItems(0, Qt::AscendingOrder); + ui->list->setSortingEnabled(true); + ui->list->header()->resizeSections(QHeaderView::Interactive); + ui->list->header()->resizeSection(0, 200); + + connect(ui->add, &QPushButton::clicked, this, [this] { + auto item = new QTreeWidgetItem(ui->list); + item->setText(0, "ENV_VAR"); + item->setText(1, "value"); + item->setFlags(item->flags() | Qt::ItemIsEditable); + ui->list->addTopLevelItem(item); + ui->list->selectionModel()->select(ui->list->model()->index(ui->list->indexOfTopLevelItem(item), 0), + QItemSelectionModel::ClearAndSelect | QItemSelectionModel::SelectionFlag::Rows); + ui->list->editItem(item); + }); + + connect(ui->remove, &QPushButton::clicked, this, [this] { + for (QTreeWidgetItem* item : ui->list->selectedItems()) + ui->list->takeTopLevelItem(ui->list->indexOfTopLevelItem(item)); + }); + + connect(ui->clear, &QPushButton::clicked, this, [this] { ui->list->clear(); }); + + connect(ui->overrideCheckBox, &QCheckBox::toggled, ui->settingsWidget, &QWidget::setEnabled); +} + +EnvironmentVariables::~EnvironmentVariables() +{ + delete ui; +} + +void EnvironmentVariables::initialize(bool instance, bool override, const QMap& value) +{ + // update widgets to settings + ui->overrideCheckBox->setVisible(instance); + ui->overrideCheckBox->setChecked(override); + + // populate + ui->list->clear(); + for (auto iter = value.begin(); iter != value.end(); iter++) { + auto item = new QTreeWidgetItem(ui->list); + item->setText(0, iter.key()); + item->setText(1, iter.value().toString()); + item->setFlags(item->flags() | Qt::ItemIsEditable); + ui->list->addTopLevelItem(item); + } +} + +bool EnvironmentVariables::eventFilter(QObject* watched, QEvent* event) +{ + if (watched == ui->list && event->type() == QEvent::KeyPress) { + const QKeyEvent* keyEvent = (QKeyEvent*)event; + if (keyEvent->key() == Qt::Key_Delete) { + emit ui->remove->clicked(); + return true; + } + } + + return QObject::eventFilter(watched, event); +} + +void EnvironmentVariables::retranslate() +{ + ui->retranslateUi(this); +} + +bool EnvironmentVariables::override() const +{ + return ui->overrideCheckBox->isChecked(); +} + +QMap EnvironmentVariables::value() const +{ + QMap result; + QTreeWidgetItem* item = ui->list->topLevelItem(0); + for (int i = 1; item != nullptr; item = ui->list->topLevelItem(i++)) + result[item->text(0)] = item->text(1); + + return result; +} diff --git a/launcher/ui/widgets/EnvironmentVariables.h b/launcher/ui/widgets/EnvironmentVariables.h new file mode 100644 index 0000000..092d586 --- /dev/null +++ b/launcher/ui/widgets/EnvironmentVariables.h @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include + +namespace Ui { +class EnvironmentVariables; +} + +class EnvironmentVariables : public QWidget { + Q_OBJECT + + public: + explicit EnvironmentVariables(QWidget* state = nullptr); + ~EnvironmentVariables() override; + void initialize(bool instance, bool override, const QMap& value); + bool eventFilter(QObject* watched, QEvent* event) override; + + void retranslate(); + bool override() const; + QMap value() const; + + private: + Ui::EnvironmentVariables* ui; +}; diff --git a/launcher/ui/widgets/EnvironmentVariables.ui b/launcher/ui/widgets/EnvironmentVariables.ui new file mode 100644 index 0000000..cc52b5d --- /dev/null +++ b/launcher/ui/widgets/EnvironmentVariables.ui @@ -0,0 +1,122 @@ + + + EnvironmentVariables + + + + 0 + 0 + 565 + 410 + + + + Form + + + + + + Override &Global Settings + + + true + + + + + + + true + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + &Add + + + + + + + &Remove + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + &Clear + + + + + + + + + true + + + QAbstractItemView::ExtendedSelection + + + false + + + false + + + true + + + false + + + + Name + + + + + Value + + + + + + + + + + + + diff --git a/launcher/ui/widgets/IconLabel.cpp b/launcher/ui/widgets/IconLabel.cpp new file mode 100644 index 0000000..2877668 --- /dev/null +++ b/launcher/ui/widgets/IconLabel.cpp @@ -0,0 +1,39 @@ +#include "IconLabel.h" + +#include +#include +#include +#include +#include + +IconLabel::IconLabel(QWidget* parent, QIcon icon, QSize size) : QWidget(parent), m_size(size), m_icon(icon) +{ + setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); +} + +QSize IconLabel::sizeHint() const +{ + return m_size; +} + +void IconLabel::setIcon(QIcon icon) +{ + m_icon = icon; + update(); +} + +void IconLabel::paintEvent(QPaintEvent*) +{ + QPainter p(this); + QRect rect = contentsRect(); + int width = rect.width(); + int height = rect.height(); + if (width < height) { + rect.setHeight(width); + rect.translate(0, (height - width) / 2); + } else if (width > height) { + rect.setWidth(height); + rect.translate((width - height) / 2, 0); + } + m_icon.paint(&p, rect); +} diff --git a/launcher/ui/widgets/IconLabel.h b/launcher/ui/widgets/IconLabel.h new file mode 100644 index 0000000..41d97f6 --- /dev/null +++ b/launcher/ui/widgets/IconLabel.h @@ -0,0 +1,25 @@ +#pragma once +#include +#include + +class QStyleOption; + +/** + * This is a trivial widget that paints a QIcon of the specified size. + */ +class IconLabel : public QWidget { + Q_OBJECT + + public: + /// Create a line separator. orientation is the orientation of the line. + explicit IconLabel(QWidget* parent, QIcon icon, QSize size); + + virtual QSize sizeHint() const; + virtual void paintEvent(QPaintEvent*); + + void setIcon(QIcon icon); + + private: + QSize m_size; + QIcon m_icon; +}; diff --git a/launcher/ui/widgets/InfoFrame.cpp b/launcher/ui/widgets/InfoFrame.cpp new file mode 100644 index 0000000..3542114 --- /dev/null +++ b/launcher/ui/widgets/InfoFrame.cpp @@ -0,0 +1,429 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include +#include +#include +#include +#include + +#include "InfoFrame.h" +#include "ui_InfoFrame.h" + +#include "ui/dialogs/CustomMessageBox.h" + +void setupLinkToolTip(QLabel* label) +{ + QObject::connect(label, &QLabel::linkHovered, [label](const QString& link) { + if (auto url = QUrl(link); !url.isValid() || (url.scheme() != "http" && url.scheme() != "https")) + return; + label->setToolTip(link); + }); +} + +InfoFrame::InfoFrame(QWidget* parent) : QFrame(parent), ui(new Ui::InfoFrame) +{ + ui->setupUi(this); + ui->descriptionLabel->setHidden(true); + ui->nameLabel->setHidden(true); + ui->licenseLabel->setHidden(true); + ui->issueTrackerLabel->setHidden(true); + + setupLinkToolTip(ui->iconLabel); + setupLinkToolTip(ui->descriptionLabel); + setupLinkToolTip(ui->nameLabel); + setupLinkToolTip(ui->licenseLabel); + setupLinkToolTip(ui->issueTrackerLabel); + updateHiddenState(); +} + +InfoFrame::~InfoFrame() +{ + delete ui; +} + +void InfoFrame::updateWithMod(const Mod& m) +{ + if (m.type() == ResourceType::FOLDER) { + clear(); + return; + } + + QString text = ""; + QString name = ""; + QString link = m.homepage(); + if (m.name().isEmpty()) + name = m.internal_id(); + else + name = renderColorCodes(m.name()); + + if (link.isEmpty()) + text = name; + else { + text = "" + name + ""; + } + if (!m.authors().isEmpty()) + text += " by " + m.authors().join(", "); + + setName(text); + + if (m.description().isEmpty()) { + setDescription(QString()); + } else { + setDescription(renderColorCodes(m.description())); + } + + setImage(m.icon({ 64, 64 })); + + auto licenses = m.licenses(); + QString licenseText = ""; + if (!licenses.empty()) { + for (auto l : licenses) { + if (!licenseText.isEmpty()) { + licenseText += "\n"; // add newline between licenses + } + if (!l.name.isEmpty()) { + if (l.url.isEmpty()) { + licenseText += l.name; + } else { + licenseText += "" + l.name + ""; + } + } else if (!l.url.isEmpty()) { + licenseText += "" + l.url + ""; + } + if (!l.description.isEmpty() && l.description != l.name) { + licenseText += " " + l.description; + } + } + } + if (!licenseText.isEmpty()) { + setLicense(tr("License: %1").arg(licenseText)); + } else { + setLicense(); + } + + QString issueTracker = ""; + if (!m.issueTracker().isEmpty()) { + issueTracker += tr("Report issues to: "); + issueTracker += "" + m.issueTracker() + ""; + } + setIssueTracker(issueTracker); +} + +void InfoFrame::updateWithResource(const Resource& resource) +{ + const QString homepage = resource.homepage(); + auto name = renderColorCodes(resource.name()); + + if (!homepage.isEmpty()) + setName("" + name + ""); + else + setName(name); + + setImage(); +} + +QString InfoFrame::renderColorCodes(QString input) +{ + // We have to manually set the colors for use. + // + // A color is set using §x, with x = a hex number from 0 to f. + // + // We traverse the description and, when one of those is found, we create + // a span element with that color set. + // + // TODO: Wrap links inside tags + + // https://minecraft.wiki/w/Formatting_codes#Color_codes + const QMap color_codes_map = { { '0', "#000000" }, { '1', "#0000AA" }, { '2', "#00AA00" }, { '3', "#00AAAA" }, + { '4', "#AA0000" }, { '5', "#AA00AA" }, { '6', "#FFAA00" }, { '7', "#AAAAAA" }, + { '8', "#555555" }, { '9', "#5555FF" }, { 'a', "#55FF55" }, { 'b', "#55FFFF" }, + { 'c', "#FF5555" }, { 'd', "#FF55FF" }, { 'e', "#FFFF55" }, { 'f', "#FFFFFF" } }; + // https://minecraft.wiki/w/Formatting_codes#Formatting_codes + const QMap formatting_codes_map = { { 'l', "b" }, { 'm', "s" }, { 'n', "u" }, { 'o', "i" } }; + + QString html(""); + QList tags{}; + + auto it = input.constBegin(); + while (it != input.constEnd()) { + // is current char § and is there a following char + if (*it == u'§' && (it + 1) != input.constEnd()) { + const auto& code = *(++it); // incrementing here! + + const auto color_entry = color_codes_map.constFind(code); + const auto tag_entry = formatting_codes_map.constFind(code); + + if (color_entry != color_codes_map.constEnd()) { // color code + html += QString("").arg(color_entry.value()); + tags << "span"; + } else if (tag_entry != formatting_codes_map.constEnd()) { // formatting code + html += QString("<%1>").arg(tag_entry.value()); + tags << tag_entry.value(); + } else if (code == 'r') { // reset all formatting + while (!tags.isEmpty()) { + html += QString("").arg(tags.takeLast()); + } + } else { // pass unknown codes through + html += QString("§%1").arg(code); + } + } else { + html += *it; + } + it++; + } + while (!tags.isEmpty()) { + html += QString("").arg(tags.takeLast()); + } + html += ""; + + html.replace("\n", "
    "); + return html; +} + +void InfoFrame::updateWithResourcePack(ResourcePack& resource_pack) +{ + QString name = renderColorCodes(resource_pack.name()); + + const QString homepage = resource_pack.homepage(); + if (!homepage.isEmpty()) { + name = "
    " + name + ""; + } + + setName(name); + setDescription(renderColorCodes(resource_pack.description())); + setImage(resource_pack.image({ 64, 64 })); +} + +void InfoFrame::updateWithDataPack(DataPack& data_pack) +{ + setName(renderColorCodes(data_pack.name())); + setDescription(renderColorCodes(data_pack.description())); + setImage(data_pack.image({ 64, 64 })); +} + +void InfoFrame::updateWithTexturePack(TexturePack& texture_pack) +{ + QString name = renderColorCodes(texture_pack.name()); + + const QString homepage = texture_pack.homepage(); + if (!homepage.isEmpty()) { + name = "" + name + ""; + } + + setName(name); + setDescription(renderColorCodes(texture_pack.description())); + setImage(texture_pack.image({ 64, 64 })); +} + +void InfoFrame::clear() +{ + setName(); + setDescription(); + setImage(); + setLicense(); + setIssueTracker(); +} + +void InfoFrame::updateHiddenState() +{ + if (ui->descriptionLabel->isHidden() && ui->nameLabel->isHidden() && ui->licenseLabel->isHidden() && + ui->issueTrackerLabel->isHidden()) { + setHidden(true); + } else { + setHidden(false); + } +} + +void InfoFrame::setName(QString text) +{ + resetScroll(); + if (text.isEmpty()) { + ui->nameLabel->setHidden(true); + } else { + ui->nameLabel->setText(text); + ui->nameLabel->setHidden(false); + } + updateHiddenState(); +} + +void InfoFrame::setDescription(QString text) +{ + if (text.isEmpty()) { + ui->descriptionLabel->setHidden(true); + updateHiddenState(); + return; + } else { + ui->descriptionLabel->setHidden(false); + updateHiddenState(); + } + ui->descriptionLabel->setToolTip(""); + QString intermediatetext = text.trimmed(); + bool prev(false); + QChar rem('\n'); + QString finaltext; + finaltext.reserve(intermediatetext.size()); + for (const QChar& c : intermediatetext) { + if (c == rem && prev) { + continue; + } + prev = c == rem; + finaltext += c; + } + QString labeltext; + labeltext.reserve(300); + + // elide rich text by getting characters without formatting + const int maxCharacterElide = 290; + QTextDocument doc; + doc.setHtml(text); + + if (doc.characterCount() > maxCharacterElide) { + ui->descriptionLabel->setOpenExternalLinks(false); + ui->descriptionLabel->setTextFormat(Qt::TextFormat::RichText); // This allows injecting HTML here. + m_description = text; + + // move the cursor to the character elide, doesn't see html + QTextCursor cursor(&doc); + cursor.movePosition(QTextCursor::End); + cursor.setPosition(maxCharacterElide, QTextCursor::KeepAnchor); + cursor.removeSelectedText(); + + // insert the post fix at the cursor + cursor.insertHtml("..."); + + labeltext.append(doc.toHtml()); + connect(ui->descriptionLabel, &QLabel::linkActivated, this, &InfoFrame::descriptionEllipsisHandler); + } else { + ui->descriptionLabel->setTextFormat(Qt::TextFormat::AutoText); + labeltext.append(finaltext); + } + ui->descriptionLabel->setText(labeltext); +} + +void InfoFrame::setLicense(QString text) +{ + if (text.isEmpty()) { + ui->licenseLabel->setHidden(true); + updateHiddenState(); + return; + } else { + ui->licenseLabel->setHidden(false); + updateHiddenState(); + } + ui->licenseLabel->setToolTip(""); + QString intermediatetext = text.trimmed(); + bool prev(false); + QChar rem('\n'); + QString finaltext; + finaltext.reserve(intermediatetext.size()); + for (const QChar& c : intermediatetext) { + if (c == rem && prev) { + continue; + } + prev = c == rem; + finaltext += c; + } + QString labeltext; + labeltext.reserve(300); + if (finaltext.length() > 290) { + ui->licenseLabel->setOpenExternalLinks(false); + ui->licenseLabel->setTextFormat(Qt::TextFormat::RichText); + m_license = text; + // This allows injecting HTML here. + labeltext.append("" + finaltext.left(287) + "..."); + connect(ui->licenseLabel, &QLabel::linkActivated, this, &InfoFrame::licenseEllipsisHandler); + } else { + ui->licenseLabel->setTextFormat(Qt::TextFormat::AutoText); + labeltext.append(finaltext); + } + ui->licenseLabel->setText(labeltext); +} + +void InfoFrame::setIssueTracker(QString text) +{ + if (text.isEmpty()) { + ui->issueTrackerLabel->setHidden(true); + } else { + ui->issueTrackerLabel->setText(text); + ui->issueTrackerLabel->setHidden(false); + } + updateHiddenState(); +} + +void InfoFrame::setImage(QPixmap img) +{ + if (img.isNull()) { + ui->iconLabel->setHidden(true); + } else { + ui->iconLabel->setHidden(false); + ui->iconLabel->setPixmap(img); + } +} + +void InfoFrame::descriptionEllipsisHandler([[maybe_unused]] QString link) +{ + if (!m_current_box) { + m_current_box = CustomMessageBox::selectable(this, "", m_description); + connect(m_current_box, &QMessageBox::finished, this, &InfoFrame::boxClosed); + m_current_box->show(); + } else { + m_current_box->setText(m_description); + } +} + +void InfoFrame::licenseEllipsisHandler([[maybe_unused]] QString link) +{ + if (!m_current_box) { + m_current_box = CustomMessageBox::selectable(this, "", m_license); + connect(m_current_box, &QMessageBox::finished, this, &InfoFrame::boxClosed); + m_current_box->show(); + } else { + m_current_box->setText(m_license); + } +} + +void InfoFrame::boxClosed([[maybe_unused]] int result) +{ + m_current_box = nullptr; +} + +void InfoFrame::resetScroll() +{ + ui->scrollArea->horizontalScrollBar()->setValue(0); + ui->scrollArea->verticalScrollBar()->setValue(0); +} diff --git a/launcher/ui/widgets/InfoFrame.h b/launcher/ui/widgets/InfoFrame.h new file mode 100644 index 0000000..b2c867c --- /dev/null +++ b/launcher/ui/widgets/InfoFrame.h @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +#include "minecraft/mod/DataPack.h" +#include "minecraft/mod/Mod.h" +#include "minecraft/mod/ResourcePack.h" +#include "minecraft/mod/TexturePack.h" + +namespace Ui { +class InfoFrame; +} + +class InfoFrame : public QFrame { + Q_OBJECT + + public: + InfoFrame(QWidget* parent = nullptr); + ~InfoFrame() override; + + void setName(QString text = {}); + void setDescription(QString text = {}); + void setImage(QPixmap img = {}); + void setLicense(QString text = {}); + void setIssueTracker(QString text = {}); + + void clear(); + + void updateWithMod(Mod const& m); + void updateWithResource(Resource const& resource); + void updateWithResourcePack(ResourcePack& rp); + void updateWithDataPack(DataPack& rp); + void updateWithTexturePack(TexturePack& tp); + + static QString renderColorCodes(QString input); + + public slots: + void descriptionEllipsisHandler(QString link); + void licenseEllipsisHandler(QString link); + void boxClosed(int result); + + private: + void updateHiddenState(); + void resetScroll(); + + private: + Ui::InfoFrame* ui; + QString m_description; + QString m_license; + class QMessageBox* m_current_box = nullptr; +}; diff --git a/launcher/ui/widgets/InfoFrame.ui b/launcher/ui/widgets/InfoFrame.ui new file mode 100644 index 0000000..58abcff --- /dev/null +++ b/launcher/ui/widgets/InfoFrame.ui @@ -0,0 +1,183 @@ + + + InfoFrame + + + + 0 + 0 + 527 + 120 + + + + + 0 + 0 + + + + + 16777215 + 120 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + + 64 + 64 + + + + + + + false + + + 0 + + + + + + + + 0 + 0 + + + + true + + + + + 0 + 0 + 455 + 118 + + + + + + + + + + Qt::RichText + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + true + + + true + + + Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse|Qt::TextBrowserInteraction|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse + + + + + + + + + + + + + Qt::RichText + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + true + + + true + + + Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse|Qt::TextBrowserInteraction|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse + + + + + + + + + + Qt::RichText + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + true + + + true + + + Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse|Qt::TextBrowserInteraction|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse + + + + + + + + + + Qt::RichText + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + true + + + true + + + Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse|Qt::TextBrowserInteraction|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse + + + + + + + + + + + + diff --git a/launcher/ui/widgets/JavaSettingsWidget.cpp b/launcher/ui/widgets/JavaSettingsWidget.cpp new file mode 100644 index 0000000..e13c847 --- /dev/null +++ b/launcher/ui/widgets/JavaSettingsWidget.cpp @@ -0,0 +1,311 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2024 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "JavaSettingsWidget.h" + +#include +#include +#include "Application.h" +#include "BuildConfig.h" +#include "FileSystem.h" +#include "HardwareInfo.h" +#include "JavaCommon.h" +#include "SysInfo.h" +#include "java/JavaInstallList.h" +#include "java/JavaUtils.h" +#include "settings/Setting.h" +#include "ui/dialogs/CustomMessageBox.h" +#include "ui/dialogs/VersionSelectDialog.h" +#include "ui/java/InstallJavaDialog.h" + +#include "ui_JavaSettingsWidget.h" + +JavaSettingsWidget::JavaSettingsWidget(BaseInstance* instance, QWidget* parent) + : QWidget(parent), m_instance(instance), m_ui(new Ui::JavaSettingsWidget) +{ + m_ui->setupUi(this); + + if (m_instance == nullptr) { + m_ui->javaDownloadBtn->hide(); + if (BuildConfig.JAVA_DOWNLOADER_ENABLED) { + connect(m_ui->autodetectJavaCheckBox, &QCheckBox::stateChanged, this, [this](bool state) { + m_ui->autodownloadJavaCheckBox->setEnabled(state); + if (!state) + m_ui->autodownloadJavaCheckBox->setChecked(false); + }); + } else { + m_ui->autodownloadJavaCheckBox->hide(); + } + } else { + m_ui->javaDownloadBtn->setVisible(BuildConfig.JAVA_DOWNLOADER_ENABLED); + m_ui->skipWizardCheckBox->hide(); + m_ui->autodetectJavaCheckBox->hide(); + m_ui->autodownloadJavaCheckBox->hide(); + + m_ui->javaInstallationGroupBox->setCheckable(true); + m_ui->memoryGroupBox->setCheckable(true); + m_ui->javaArgumentsGroupBox->setCheckable(true); + + SettingsObject* settings = m_instance->settings(); + + connect(settings->getSetting("OverrideJavaLocation").get(), &Setting::SettingChanged, m_ui->javaInstallationGroupBox, + [this, settings] { m_ui->javaInstallationGroupBox->setChecked(settings->get("OverrideJavaLocation").toBool()); }); + connect(settings->getSetting("JavaPath").get(), &Setting::SettingChanged, m_ui->javaInstallationGroupBox, + [this, settings] { m_ui->javaPathTextBox->setText(settings->get("JavaPath").toString()); }); + + connect(m_ui->javaDownloadBtn, &QPushButton::clicked, this, [this] { + auto javaDialog = new Java::InstallDialog({}, m_instance, this); + javaDialog->exec(); + }); + connect(m_ui->javaPathTextBox, &QLineEdit::textChanged, [this](QString newValue) { + if (m_instance->settings()->get("JavaPath").toString() != newValue) { + m_instance->settings()->set("AutomaticJava", false); + } + }); + } + + connect(m_ui->javaTestBtn, &QPushButton::clicked, this, &JavaSettingsWidget::onJavaTest); + connect(m_ui->javaDetectBtn, &QPushButton::clicked, this, &JavaSettingsWidget::onJavaAutodetect); + connect(m_ui->javaBrowseBtn, &QPushButton::clicked, this, &JavaSettingsWidget::onJavaBrowse); + + connect(m_ui->maxMemSpinBox, &QSpinBox::valueChanged, this, &JavaSettingsWidget::updateThresholds); + connect(m_ui->minMemSpinBox, &QSpinBox::valueChanged, this, &JavaSettingsWidget::updateThresholds); + + loadSettings(); + updateThresholds(); +} + +JavaSettingsWidget::~JavaSettingsWidget() +{ + delete m_ui; +} + +void JavaSettingsWidget::loadSettings() +{ + SettingsObject* settings; + + if (m_instance != nullptr) + settings = m_instance->settings(); + else + settings = APPLICATION->settings(); + + // Java Settings + m_ui->javaInstallationGroupBox->setChecked(settings->get("OverrideJavaLocation").toBool()); + m_ui->javaPathTextBox->setText(settings->get("JavaPath").toString()); + + m_ui->skipCompatibilityCheckBox->setChecked(settings->get("IgnoreJavaCompatibility").toBool()); + + m_ui->javaArgumentsGroupBox->setChecked(m_instance == nullptr || settings->get("OverrideJavaArgs").toBool()); + m_ui->jvmArgsTextBox->setPlainText(settings->get("JvmArgs").toString()); + + if (m_instance == nullptr) { + m_ui->skipWizardCheckBox->setChecked(settings->get("IgnoreJavaWizard").toBool()); + m_ui->autodetectJavaCheckBox->setChecked(settings->get("AutomaticJavaSwitch").toBool()); + m_ui->autodetectJavaCheckBox->stateChanged(m_ui->autodetectJavaCheckBox->isChecked()); + m_ui->autodownloadJavaCheckBox->setChecked(settings->get("AutomaticJavaDownload").toBool()); + } + + // Memory + m_ui->memoryGroupBox->setChecked(m_instance == nullptr || settings->get("OverrideMemory").toBool()); + int min = settings->get("MinMemAlloc").toInt(); + int max = settings->get("MaxMemAlloc").toInt(); + if (min < max) { + m_ui->minMemSpinBox->setValue(min); + m_ui->maxMemSpinBox->setValue(max); + } else { + m_ui->minMemSpinBox->setValue(max); + m_ui->maxMemSpinBox->setValue(min); + } + m_ui->permGenSpinBox->setValue(settings->get("PermGen").toInt()); + m_ui->lowMemWarningCheckBox->setChecked(settings->get("LowMemWarning").toBool()); + + // Java arguments + m_ui->javaArgumentsGroupBox->setChecked(m_instance == nullptr || settings->get("OverrideJavaArgs").toBool()); + m_ui->jvmArgsTextBox->setPlainText(settings->get("JvmArgs").toString()); +} + +void JavaSettingsWidget::saveSettings() +{ + SettingsObject* settings; + + if (m_instance != nullptr) + settings = m_instance->settings(); + else + settings = APPLICATION->settings(); + + SettingsObject::Lock lock(settings); + + // Java Install Settings + bool javaInstall = m_instance == nullptr || m_ui->javaInstallationGroupBox->isChecked(); + + if (m_instance != nullptr) + settings->set("OverrideJavaLocation", javaInstall); + + if (javaInstall) { + settings->set("JavaPath", m_ui->javaPathTextBox->text()); + settings->set("IgnoreJavaCompatibility", m_ui->skipCompatibilityCheckBox->isChecked()); + } else { + settings->reset("JavaPath"); + settings->reset("IgnoreJavaCompatibility"); + } + + if (m_instance == nullptr) { + settings->set("IgnoreJavaWizard", m_ui->skipWizardCheckBox->isChecked()); + settings->set("AutomaticJavaSwitch", m_ui->autodetectJavaCheckBox->isChecked()); + settings->set("AutomaticJavaDownload", m_ui->autodownloadJavaCheckBox->isChecked()); + } + + // Memory + bool memory = m_instance == nullptr || m_ui->memoryGroupBox->isChecked(); + + if (m_instance != nullptr) + settings->set("OverrideMemory", memory); + + if (memory) { + int min = m_ui->minMemSpinBox->value(); + int max = m_ui->maxMemSpinBox->value(); + if (min < max) { + settings->set("MinMemAlloc", min); + settings->set("MaxMemAlloc", max); + } else { + settings->set("MinMemAlloc", max); + settings->set("MaxMemAlloc", min); + } + settings->set("PermGen", m_ui->permGenSpinBox->value()); + settings->set("LowMemWarning", m_ui->lowMemWarningCheckBox->isChecked()); + } else { + settings->reset("MinMemAlloc"); + settings->reset("MaxMemAlloc"); + settings->reset("PermGen"); + settings->reset("LowMemWarning"); + } + + // Java arguments + bool javaArgs = m_instance == nullptr || m_ui->javaArgumentsGroupBox->isChecked(); + + if (m_instance != nullptr) + settings->set("OverrideJavaArgs", javaArgs); + + if (javaArgs) { + settings->set("JvmArgs", m_ui->jvmArgsTextBox->toPlainText().replace("\n", " ")); + } else { + settings->reset("JvmArgs"); + } +} + +void JavaSettingsWidget::onJavaBrowse() +{ + QString rawPath = QFileDialog::getOpenFileName(this, tr("Find Java executable")); + + // do not allow current dir - it's dirty. Do not allow dirs that don't exist + if (rawPath.isEmpty()) { + return; + } + + QString cookedPath = FS::NormalizePath(rawPath); + QFileInfo javaInfo(cookedPath); + if (!javaInfo.exists() || !javaInfo.isExecutable()) { + return; + } + m_ui->javaPathTextBox->setText(cookedPath); +} + +void JavaSettingsWidget::onJavaTest() +{ + if (m_checker != nullptr) + return; + + QString jvmArgs; + + if (m_instance == nullptr || m_ui->javaArgumentsGroupBox->isChecked()) + jvmArgs = m_ui->jvmArgsTextBox->toPlainText().replace("\n", " "); + else + jvmArgs = APPLICATION->settings()->get("JvmArgs").toString(); + + m_checker.reset(new JavaCommon::TestCheck(this, m_ui->javaPathTextBox->text(), jvmArgs, m_ui->minMemSpinBox->value(), + m_ui->maxMemSpinBox->value(), m_ui->permGenSpinBox->value())); + connect(m_checker.get(), &JavaCommon::TestCheck::finished, this, [this] { m_checker.reset(); }); + m_checker->run(); +} + +void JavaSettingsWidget::onJavaAutodetect() +{ + if (JavaUtils::getJavaCheckPath().isEmpty()) { + JavaCommon::javaCheckNotFound(this); + return; + } + + VersionSelectDialog versionDialog(APPLICATION->javalist(), tr("Select a Java version"), this, true); + versionDialog.setResizeOn(2); + versionDialog.exec(); + + if (versionDialog.result() == QDialog::Accepted && versionDialog.selectedVersion()) { + JavaInstallPtr java = std::dynamic_pointer_cast(versionDialog.selectedVersion()); + m_ui->javaPathTextBox->setText(java->path); + + if (!java->is_64bit && m_ui->maxMemSpinBox->value() > 2048) { + CustomMessageBox::selectable(this, tr("Confirm Selection"), + tr("You selected a 32-bit version of Java.\n" + "This installation does not support more than 2048MiB of RAM.\n" + "Please make sure that the maximum memory value is lower."), + QMessageBox::Warning, QMessageBox::Ok, QMessageBox::Ok) + ->exec(); + } + } +} +void JavaSettingsWidget::updateThresholds() +{ + auto sysMiB = HardwareInfo::totalRamMiB(); + unsigned int maxMem = m_ui->maxMemSpinBox->value(); + unsigned int minMem = m_ui->minMemSpinBox->value(); + + const QString warningColour(QStringLiteral("%1")); + + if (maxMem >= sysMiB) { + m_ui->labelMaxMemNotice->setText( + QString("%1").arg(tr("Your maximum memory allocation exceeds your system memory capacity."))); + m_ui->labelMaxMemNotice->show(); + } else if (maxMem > (sysMiB * 0.9)) { + m_ui->labelMaxMemNotice->setText(warningColour.arg(tr("Your maximum memory allocation is close to your system memory capacity."))); + m_ui->labelMaxMemNotice->show(); + } else if (maxMem < minMem) { + m_ui->labelMaxMemNotice->setText(warningColour.arg(tr("Your maximum memory allocation is below the minimum memory allocation."))); + m_ui->labelMaxMemNotice->show(); + } else { + m_ui->labelMaxMemNotice->hide(); + } +} diff --git a/launcher/ui/widgets/JavaSettingsWidget.h b/launcher/ui/widgets/JavaSettingsWidget.h new file mode 100644 index 0000000..6515459 --- /dev/null +++ b/launcher/ui/widgets/JavaSettingsWidget.h @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * Copyright (C) 2024 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include "BaseInstance.h" +#include "JavaCommon.h" + +namespace Ui { +class JavaSettingsWidget; +} + +class JavaSettingsWidget : public QWidget { + Q_OBJECT + + public: + explicit JavaSettingsWidget(QWidget* parent = nullptr) : JavaSettingsWidget(nullptr, parent) {} + explicit JavaSettingsWidget(BaseInstance* instance, QWidget* parent = nullptr); + ~JavaSettingsWidget() override; + + void loadSettings(); + void saveSettings(); + + private slots: + void onJavaBrowse(); + void onJavaAutodetect(); + void onJavaTest(); + void updateThresholds(); + + private: + BaseInstance* m_instance; + Ui::JavaSettingsWidget* m_ui; + unique_qobject_ptr m_checker; +}; diff --git a/launcher/ui/widgets/JavaSettingsWidget.ui b/launcher/ui/widgets/JavaSettingsWidget.ui new file mode 100644 index 0000000..14638cf --- /dev/null +++ b/launcher/ui/widgets/JavaSettingsWidget.ui @@ -0,0 +1,400 @@ + + + JavaSettingsWidget + + + + 0 + 0 + 500 + 1000 + + + + Form + + + + + + true + + + Java Insta&llation + + + false + + + false + + + + + + Auto-&detect Java version + + + + + + + + + &Detect + + + + + + + &Browse + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + + + + Test S&ettings + + + + + + + Open Java &Downloader + + + + + + + Qt::Orientation::Horizontal + + + + 0 + 0 + + + + + + + + + + Qt::Orientation::Vertical + + + QSizePolicy::Policy::Fixed + + + + 0 + 6 + + + + + + + + Automatically downloads and selects the Java build recommended by Mojang. + + + Auto-download &Mojang Java + + + + + + + If enabled, the launcher will not check if an instance is compatible with the selected Java version. + + + Skip Java compatibility checks + + + + + + + + + + Java &Executable + + + javaPathTextBox + + + + + + + If enabled, the launcher won't prompt you to choose a Java version if one is not found on startup. + + + Skip Java setup prompt on startup + + + + + + + Qt::Orientation::Vertical + + + QSizePolicy::Policy::Fixed + + + + 0 + 6 + + + + + + + + + + + true + + + Memor&y + + + false + + + false + + + + + + + + M&inimum Memory Usage: + + + minMemSpinBox + + + + + + + + + + 0 + 0 + + + + The amount of memory Minecraft is started with. + + + MiB + + + 8 + + + 1048576 + + + 128 + + + 256 + + + + + + + (-Xms) + + + + + + + + + Ma&ximum Memory Usage: + + + maxMemSpinBox + + + + + + + + + + 0 + 0 + + + + The maximum amount of memory Minecraft is allowed to use. + + + MiB + + + 8 + + + 1048576 + + + 128 + + + 1024 + + + + + + + (-Xmx) + + + + + + + + + &PermGen Size: + + + permGenSpinBox + + + + + + + + + + 0 + 0 + + + + The amount of memory available to store loaded Java classes. + + + MiB + + + 4 + + + 1048576 + + + 8 + + + 64 + + + + + + + (-XX:PermSize) + + + + + + + + + + + Warn when there is not enough memory available + + + + + + + Memory Notice + + + + + + + + + + true + + + Java Argumen&ts + + + false + + + false + + + + + + + + + + + + javaPathTextBox + javaDetectBtn + javaBrowseBtn + skipCompatibilityCheckBox + skipWizardCheckBox + autodetectJavaCheckBox + autodownloadJavaCheckBox + javaTestBtn + javaDownloadBtn + maxMemSpinBox + jvmArgsTextBox + + + + diff --git a/launcher/ui/widgets/JavaWizardWidget.cpp b/launcher/ui/widgets/JavaWizardWidget.cpp new file mode 100644 index 0000000..bcf498b --- /dev/null +++ b/launcher/ui/widgets/JavaWizardWidget.cpp @@ -0,0 +1,556 @@ +#include "JavaWizardWidget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "DesktopServices.h" +#include "FileSystem.h" +#include "JavaCommon.h" +#include "java/JavaChecker.h" +#include "java/JavaInstall.h" +#include "java/JavaInstallList.h" +#include "java/JavaUtils.h" + +#include "ui/dialogs/CustomMessageBox.h" +#include "ui/java/InstallJavaDialog.h" +#include "ui/widgets/VersionSelectWidget.h" + +#include "Application.h" +#include "BuildConfig.h" +#include "HardwareInfo.h" + +JavaWizardWidget::JavaWizardWidget(QWidget* parent) : QWidget(parent) +{ + m_availableMemory = HardwareInfo::totalRamMiB(); + + goodIcon = QIcon::fromTheme("status-good"); + yellowIcon = QIcon::fromTheme("status-yellow"); + badIcon = QIcon::fromTheme("status-bad"); + m_memoryTimer = new QTimer(this); + setupUi(); + + connect(m_minMemSpinBox, &QSpinBox::valueChanged, this, &JavaWizardWidget::onSpinBoxValueChanged); + connect(m_maxMemSpinBox, &QSpinBox::valueChanged, this, &JavaWizardWidget::onSpinBoxValueChanged); + connect(m_permGenSpinBox, &QSpinBox::valueChanged, this, &JavaWizardWidget::onSpinBoxValueChanged); + connect(m_memoryTimer, &QTimer::timeout, this, &JavaWizardWidget::memoryValueChanged); + connect(m_versionWidget, &VersionSelectWidget::selectedVersionChanged, this, &JavaWizardWidget::javaVersionSelected); + connect(m_javaBrowseBtn, &QPushButton::clicked, this, &JavaWizardWidget::on_javaBrowseBtn_clicked); + connect(m_javaPathTextBox, &QLineEdit::textEdited, this, &JavaWizardWidget::javaPathEdited); + connect(m_javaStatusBtn, &QToolButton::clicked, this, &JavaWizardWidget::on_javaStatusBtn_clicked); + if (BuildConfig.JAVA_DOWNLOADER_ENABLED) { + connect(m_javaDownloadBtn, &QPushButton::clicked, this, &JavaWizardWidget::javaDownloadBtn_clicked); + } +} + +void JavaWizardWidget::setupUi() +{ + setObjectName(QStringLiteral("javaSettingsWidget")); + m_verticalLayout = new QVBoxLayout(this); + m_verticalLayout->setObjectName(QStringLiteral("verticalLayout")); + + m_versionWidget = new VersionSelectWidget(this); + + m_horizontalLayout = new QHBoxLayout(); + m_horizontalLayout->setObjectName(QStringLiteral("horizontalLayout")); + m_javaPathTextBox = new QLineEdit(this); + m_javaPathTextBox->setObjectName(QStringLiteral("javaPathTextBox")); + + m_horizontalLayout->addWidget(m_javaPathTextBox); + + m_javaBrowseBtn = new QPushButton(this); + m_javaBrowseBtn->setObjectName(QStringLiteral("javaBrowseBtn")); + + m_horizontalLayout->addWidget(m_javaBrowseBtn); + + m_javaStatusBtn = new QToolButton(this); + m_javaStatusBtn->setIcon(yellowIcon); + m_horizontalLayout->addWidget(m_javaStatusBtn); + + m_memoryGroupBox = new QGroupBox(this); + m_memoryGroupBox->setObjectName(QStringLiteral("memoryGroupBox")); + m_gridLayout_2 = new QGridLayout(m_memoryGroupBox); + m_gridLayout_2->setObjectName(QStringLiteral("gridLayout_2")); + m_gridLayout_2->setColumnStretch(0, 1); + + m_labelMinMem = new QLabel(m_memoryGroupBox); + m_labelMinMem->setObjectName(QStringLiteral("labelMinMem")); + m_gridLayout_2->addWidget(m_labelMinMem, 0, 0, 1, 1); + + m_minMemSpinBox = new QSpinBox(m_memoryGroupBox); + m_minMemSpinBox->setObjectName(QStringLiteral("minMemSpinBox")); + m_minMemSpinBox->setSuffix(QStringLiteral(" MiB")); + m_minMemSpinBox->setMinimum(8); + m_minMemSpinBox->setMaximum(1048576); + m_minMemSpinBox->setSingleStep(128); + m_labelMinMem->setBuddy(m_minMemSpinBox); + m_gridLayout_2->addWidget(m_minMemSpinBox, 0, 1, 1, 1); + + m_labelMaxMem = new QLabel(m_memoryGroupBox); + m_labelMaxMem->setObjectName(QStringLiteral("labelMaxMem")); + m_gridLayout_2->addWidget(m_labelMaxMem, 1, 0, 1, 1); + + m_maxMemSpinBox = new QSpinBox(m_memoryGroupBox); + m_maxMemSpinBox->setObjectName(QStringLiteral("maxMemSpinBox")); + m_maxMemSpinBox->setSuffix(QStringLiteral(" MiB")); + m_maxMemSpinBox->setMinimum(8); + m_maxMemSpinBox->setMaximum(1048576); + m_maxMemSpinBox->setSingleStep(128); + m_labelMaxMem->setBuddy(m_maxMemSpinBox); + m_gridLayout_2->addWidget(m_maxMemSpinBox, 1, 1, 1, 1); + + m_labelMaxMemIcon = new QLabel(m_memoryGroupBox); + m_labelMaxMemIcon->setObjectName(QStringLiteral("labelMaxMemIcon")); + m_gridLayout_2->addWidget(m_labelMaxMemIcon, 1, 2, 1, 1); + + m_labelPermGen = new QLabel(m_memoryGroupBox); + m_labelPermGen->setObjectName(QStringLiteral("labelPermGen")); + m_labelPermGen->setText(QStringLiteral("PermGen:")); + m_gridLayout_2->addWidget(m_labelPermGen, 2, 0, 1, 1); + m_labelPermGen->setVisible(false); + + m_permGenSpinBox = new QSpinBox(m_memoryGroupBox); + m_permGenSpinBox->setObjectName(QStringLiteral("permGenSpinBox")); + m_permGenSpinBox->setSuffix(QStringLiteral(" MiB")); + m_permGenSpinBox->setMinimum(4); + m_permGenSpinBox->setMaximum(1048576); + m_permGenSpinBox->setSingleStep(8); + m_gridLayout_2->addWidget(m_permGenSpinBox, 2, 1, 1, 1); + m_permGenSpinBox->setVisible(false); + + m_verticalLayout->addWidget(m_memoryGroupBox); + + m_horizontalBtnLayout = new QHBoxLayout(); + m_horizontalBtnLayout->setObjectName(QStringLiteral("horizontalBtnLayout")); + + if (BuildConfig.JAVA_DOWNLOADER_ENABLED) { + m_javaDownloadBtn = new QPushButton(tr("Download Java"), this); + m_horizontalBtnLayout->addWidget(m_javaDownloadBtn); + } + + m_autoJavaGroupBox = new QGroupBox(this); + m_autoJavaGroupBox->setObjectName(QStringLiteral("autoJavaGroupBox")); + m_veriticalJavaLayout = new QVBoxLayout(m_autoJavaGroupBox); + m_veriticalJavaLayout->setObjectName(QStringLiteral("veriticalJavaLayout")); + + m_autodetectJavaCheckBox = new QCheckBox(m_autoJavaGroupBox); + m_autodetectJavaCheckBox->setObjectName("autodetectJavaCheckBox"); + m_autodetectJavaCheckBox->setChecked(true); + m_veriticalJavaLayout->addWidget(m_autodetectJavaCheckBox); + + if (BuildConfig.JAVA_DOWNLOADER_ENABLED) { + m_autodownloadCheckBox = new QCheckBox(m_autoJavaGroupBox); + m_autodownloadCheckBox->setObjectName("autodownloadCheckBox"); + m_autodownloadCheckBox->setEnabled(m_autodetectJavaCheckBox->isChecked()); + m_veriticalJavaLayout->addWidget(m_autodownloadCheckBox); + connect(m_autodetectJavaCheckBox, &QCheckBox::stateChanged, this, [this] { + m_autodownloadCheckBox->setEnabled(m_autodetectJavaCheckBox->isChecked()); + if (!m_autodetectJavaCheckBox->isChecked()) + m_autodownloadCheckBox->setChecked(false); + }); + + connect(m_autodownloadCheckBox, &QCheckBox::stateChanged, this, [this] { + auto isChecked = m_autodownloadCheckBox->isChecked(); + m_versionWidget->setVisible(!isChecked); + m_javaStatusBtn->setVisible(!isChecked); + m_javaBrowseBtn->setVisible(!isChecked); + m_javaPathTextBox->setVisible(!isChecked); + m_javaDownloadBtn->setVisible(!isChecked); + if (!isChecked) { + m_verticalLayout->removeItem(m_verticalSpacer); + } else { + m_verticalLayout->addSpacerItem(m_verticalSpacer); + } + }); + } + m_verticalLayout->addWidget(m_autoJavaGroupBox); + + m_verticalLayout->addLayout(m_horizontalBtnLayout); + + m_verticalLayout->addWidget(m_versionWidget); + m_verticalLayout->addLayout(m_horizontalLayout); + m_verticalSpacer = new QSpacerItem(20, 40, QSizePolicy::Minimum, QSizePolicy::Expanding); + + retranslate(); +} + +void JavaWizardWidget::initialize() +{ + m_versionWidget->initialize(APPLICATION->javalist()); + m_versionWidget->selectSearch(); + m_versionWidget->setResizeOn(2); + auto s = APPLICATION->settings(); + // Memory + observedMinMemory = s->get("MinMemAlloc").toInt(); + observedMaxMemory = s->get("MaxMemAlloc").toInt(); + observedPermGenMemory = s->get("PermGen").toInt(); + m_minMemSpinBox->setValue(observedMinMemory); + m_maxMemSpinBox->setValue(observedMaxMemory); + m_permGenSpinBox->setValue(observedPermGenMemory); + updateThresholds(); + if (BuildConfig.JAVA_DOWNLOADER_ENABLED) { + m_autodownloadCheckBox->setChecked(true); + } +} + +void JavaWizardWidget::refresh() +{ + if (BuildConfig.JAVA_DOWNLOADER_ENABLED && m_autodownloadCheckBox->isChecked()) { + return; + } + if (JavaUtils::getJavaCheckPath().isEmpty()) { + JavaCommon::javaCheckNotFound(this); + return; + } + m_versionWidget->loadList(); +} + +JavaWizardWidget::ValidationStatus JavaWizardWidget::validate() +{ + switch (javaStatus) { + default: + case JavaStatus::NotSet: + /* fallthrough */ + case JavaStatus::DoesNotExist: + /* fallthrough */ + case JavaStatus::DoesNotStart: + /* fallthrough */ + case JavaStatus::ReturnedInvalidData: { + if (!(BuildConfig.JAVA_DOWNLOADER_ENABLED && m_autodownloadCheckBox->isChecked())) { // the java will not be autodownloaded + int button = QMessageBox::No; + if (m_result.mojangPlatform == "32" && maxHeapSize() > 2048) { + button = CustomMessageBox::selectable( + this, tr("32-bit Java detected"), + tr("You selected a 32-bit installation of Java, but allocated more than 2048MiB as maximum memory.\n" + "%1 will not be able to start Minecraft.\n" + "Do you wish to proceed?" + "\n\n" + "You can change the Java version in the settings later.\n") + .arg(BuildConfig.LAUNCHER_DISPLAYNAME), + QMessageBox::Warning, QMessageBox::Yes | QMessageBox::No | QMessageBox::Help, QMessageBox::NoButton) + ->exec(); + + } else { + button = CustomMessageBox::selectable(this, tr("No Java version selected"), + tr("You either didn't select a Java version or selected one that does not work.\n" + "%1 will not be able to start Minecraft.\n" + "Do you wish to proceed without a functional version of Java?" + "\n\n" + "You can change the Java version in the settings later.\n") + .arg(BuildConfig.LAUNCHER_DISPLAYNAME), + QMessageBox::Warning, QMessageBox::Yes | QMessageBox::No | QMessageBox::Help, + QMessageBox::NoButton) + ->exec(); + } + switch (button) { + case QMessageBox::Yes: + return ValidationStatus::JavaBad; + case QMessageBox::Help: + DesktopServices::openUrl(QUrl(BuildConfig.HELP_URL.arg("java-wizard"))); + [[fallthrough]]; + case QMessageBox::No: + /* fallthrough */ + default: + return ValidationStatus::Bad; + } + } + return ValidationStatus::JavaBad; + } break; + case JavaStatus::Pending: { + return ValidationStatus::Bad; + } + case JavaStatus::Good: { + return ValidationStatus::AllOK; + } + } +} + +QString JavaWizardWidget::javaPath() const +{ + return m_javaPathTextBox->text(); +} + +int JavaWizardWidget::maxHeapSize() const +{ + auto min = m_minMemSpinBox->value(); + auto max = m_maxMemSpinBox->value(); + if (max < min) + max = min; + return max; +} + +int JavaWizardWidget::minHeapSize() const +{ + auto min = m_minMemSpinBox->value(); + auto max = m_maxMemSpinBox->value(); + if (min > max) + min = max; + return min; +} + +bool JavaWizardWidget::permGenEnabled() const +{ + return m_permGenSpinBox->isVisible(); +} + +int JavaWizardWidget::permGenSize() const +{ + return m_permGenSpinBox->value(); +} + +void JavaWizardWidget::memoryValueChanged() +{ + bool actuallyChanged = false; + unsigned int min = m_minMemSpinBox->value(); + unsigned int max = m_maxMemSpinBox->value(); + unsigned int permgen = m_permGenSpinBox->value(); + if (min != observedMinMemory) { + observedMinMemory = min; + actuallyChanged = true; + } + if (max != observedMaxMemory) { + observedMaxMemory = max; + actuallyChanged = true; + } + if (permgen != observedPermGenMemory) { + observedPermGenMemory = permgen; + actuallyChanged = true; + } + if (actuallyChanged) { + checkJavaPathOnEdit(m_javaPathTextBox->text()); + updateThresholds(); + } +} + +void JavaWizardWidget::javaVersionSelected(BaseVersion::Ptr version) +{ + auto java = std::dynamic_pointer_cast(version); + if (!java) { + return; + } + auto visible = java->id.requiresPermGen(); + m_labelPermGen->setVisible(visible); + m_permGenSpinBox->setVisible(visible); + m_javaPathTextBox->setText(java->path); + checkJavaPath(java->path); +} + +void JavaWizardWidget::on_javaBrowseBtn_clicked() +{ + auto filter = QString("Java (%1)").arg(JavaUtils::javaExecutable); + auto raw_path = QFileDialog::getOpenFileName(this, tr("Find Java executable"), QString(), filter); + if (raw_path.isEmpty()) { + return; + } + auto cooked_path = FS::NormalizePath(raw_path); + m_javaPathTextBox->setText(cooked_path); + checkJavaPath(cooked_path); +} + +void JavaWizardWidget::javaDownloadBtn_clicked() +{ + auto jdialog = new Java::InstallDialog({}, nullptr, this); + jdialog->exec(); +} + +void JavaWizardWidget::on_javaStatusBtn_clicked() +{ + QString text; + bool failed = false; + switch (javaStatus) { + case JavaStatus::NotSet: + checkJavaPath(m_javaPathTextBox->text()); + return; + case JavaStatus::DoesNotExist: + text += QObject::tr("The specified file either doesn't exist or is not a proper executable."); + failed = true; + break; + case JavaStatus::DoesNotStart: { + text += QObject::tr("The specified Java binary didn't start properly.
    "); + auto htmlError = m_result.errorLog; + if (!htmlError.isEmpty()) { + htmlError.replace('\n', "
    "); + text += QString("%1").arg(htmlError); + } + failed = true; + break; + } + case JavaStatus::ReturnedInvalidData: { + text += QObject::tr("The specified Java binary returned unexpected results:
    "); + auto htmlOut = m_result.outLog; + if (!htmlOut.isEmpty()) { + htmlOut.replace('\n', "
    "); + text += QString("%1").arg(htmlOut); + } + failed = true; + break; + } + case JavaStatus::Good: + text += QObject::tr( + "Java test succeeded!
    Platform reported: %1
    Java version " + "reported: %2
    ") + .arg(m_result.realPlatform, m_result.javaVersion.toString()); + break; + case JavaStatus::Pending: + // TODO: abort here? + return; + } + CustomMessageBox::selectable(this, failed ? QObject::tr("Java test failure") : QObject::tr("Java test success"), text, + failed ? QMessageBox::Critical : QMessageBox::Information) + ->show(); +} + +void JavaWizardWidget::setJavaStatus(JavaWizardWidget::JavaStatus status) +{ + javaStatus = status; + switch (javaStatus) { + case JavaStatus::Good: + m_javaStatusBtn->setIcon(goodIcon); + break; + case JavaStatus::NotSet: + case JavaStatus::Pending: + m_javaStatusBtn->setIcon(yellowIcon); + break; + default: + m_javaStatusBtn->setIcon(badIcon); + break; + } +} + +void JavaWizardWidget::javaPathEdited(const QString& path) +{ + checkJavaPathOnEdit(path); +} + +void JavaWizardWidget::checkJavaPathOnEdit(const QString& path) +{ + auto realPath = FS::ResolveExecutable(path); + QFileInfo pathInfo(realPath); + if (pathInfo.baseName().toLower().contains("java")) { + checkJavaPath(path); + } else { + if (!m_checker) { + setJavaStatus(JavaStatus::NotSet); + } + } +} + +void JavaWizardWidget::checkJavaPath(const QString& path) +{ + if (m_checker) { + queuedCheck = path; + return; + } + auto realPath = FS::ResolveExecutable(path); + if (realPath.isNull()) { + setJavaStatus(JavaStatus::DoesNotExist); + return; + } + setJavaStatus(JavaStatus::Pending); + m_checker.reset( + new JavaChecker(path, "", minHeapSize(), maxHeapSize(), m_permGenSpinBox->isVisible() ? m_permGenSpinBox->value() : 0, 0)); + connect(m_checker.get(), &JavaChecker::checkFinished, this, &JavaWizardWidget::checkFinished); + m_checker->start(); +} + +void JavaWizardWidget::checkFinished(const JavaChecker::Result& result) +{ + m_result = result; + switch (result.validity) { + case JavaChecker::Result::Validity::Valid: { + setJavaStatus(JavaStatus::Good); + break; + } + case JavaChecker::Result::Validity::ReturnedInvalidData: { + setJavaStatus(JavaStatus::ReturnedInvalidData); + break; + } + case JavaChecker::Result::Validity::Errored: { + setJavaStatus(JavaStatus::DoesNotStart); + break; + } + } + updateThresholds(); + m_checker.reset(); + if (!queuedCheck.isNull()) { + checkJavaPath(queuedCheck); + queuedCheck.clear(); + } +} + +void JavaWizardWidget::retranslate() +{ + m_memoryGroupBox->setTitle(tr("Memory")); + m_maxMemSpinBox->setToolTip(tr("The maximum amount of memory Minecraft is allowed to use.")); + m_labelMinMem->setText(tr("Minimum memory allocation:")); + m_labelMaxMem->setText(tr("Maximum memory allocation:")); + m_minMemSpinBox->setToolTip(tr("The amount of memory Minecraft is started with.")); + m_permGenSpinBox->setToolTip(tr("The amount of memory available to store loaded Java classes.")); + m_javaBrowseBtn->setText(tr("Browse")); + if (BuildConfig.JAVA_DOWNLOADER_ENABLED) { + m_autodownloadCheckBox->setText(tr("Auto-download Mojang Java")); + } + m_autodetectJavaCheckBox->setText(tr("Auto-detect Java version")); + m_autoJavaGroupBox->setTitle(tr("Autodetect Java")); +} + +void JavaWizardWidget::updateThresholds() +{ + QString iconName; + + if (observedMaxMemory >= m_availableMemory) { + iconName = "status-bad"; + m_labelMaxMemIcon->setToolTip(tr("Your maximum memory allocation exceeds your system memory capacity.")); + } else if (observedMaxMemory > (m_availableMemory * 0.9)) { + iconName = "status-yellow"; + m_labelMaxMemIcon->setToolTip(tr("Your maximum memory allocation approaches your system memory capacity.")); + } else if (observedMaxMemory < observedMinMemory) { + iconName = "status-yellow"; + m_labelMaxMemIcon->setToolTip(tr("Your maximum memory allocation is smaller than the minimum value")); + } else if (BuildConfig.JAVA_DOWNLOADER_ENABLED && m_autodownloadCheckBox->isChecked()) { + iconName = "status-good"; + m_labelMaxMemIcon->setToolTip(""); + } else if (observedMaxMemory > 2048 && !m_result.is_64bit) { + iconName = "status-bad"; + m_labelMaxMemIcon->setToolTip(tr("You are exceeding the maximum allocation supported by 32-bit installations of Java.")); + } else { + iconName = "status-good"; + m_labelMaxMemIcon->setToolTip(""); + } + + { + auto height = m_labelMaxMemIcon->fontInfo().pixelSize(); + QIcon icon = QIcon::fromTheme(iconName); + QPixmap pix = icon.pixmap(height, height); + m_labelMaxMemIcon->setPixmap(pix); + } +} + +bool JavaWizardWidget::autoDownloadJava() const +{ + return m_autodownloadCheckBox && m_autodownloadCheckBox->isChecked(); +} + +bool JavaWizardWidget::autoDetectJava() const +{ + return m_autodetectJavaCheckBox->isChecked(); +} + +void JavaWizardWidget::onSpinBoxValueChanged(int) +{ + m_memoryTimer->start(500); +} + +JavaWizardWidget::~JavaWizardWidget() +{ + delete m_verticalSpacer; +}; diff --git a/launcher/ui/widgets/JavaWizardWidget.h b/launcher/ui/widgets/JavaWizardWidget.h new file mode 100644 index 0000000..4a877d8 --- /dev/null +++ b/launcher/ui/widgets/JavaWizardWidget.h @@ -0,0 +1,103 @@ +#pragma once +#include + +#include +#include +#include +#include + +class QCheckBox; +class QLineEdit; +class VersionSelectWidget; +class QSpinBox; +class QPushButton; +class QVBoxLayout; +class QHBoxLayout; +class QGroupBox; +class QGridLayout; +class QLabel; +class QToolButton; +class QSpacerItem; + +class JavaWizardWidget : public QWidget { + Q_OBJECT + + public: + explicit JavaWizardWidget(QWidget* parent); + virtual ~JavaWizardWidget(); + + enum class JavaStatus { NotSet, Pending, Good, DoesNotExist, DoesNotStart, ReturnedInvalidData } javaStatus = JavaStatus::NotSet; + + enum class ValidationStatus { Bad, JavaBad, AllOK }; + + void refresh(); + void initialize(); + ValidationStatus validate(); + void retranslate(); + + bool permGenEnabled() const; + int permGenSize() const; + int minHeapSize() const; + int maxHeapSize() const; + QString javaPath() const; + bool autoDetectJava() const; + bool autoDownloadJava() const; + + void updateThresholds(); + + protected slots: + void onSpinBoxValueChanged(int); + void memoryValueChanged(); + void javaPathEdited(const QString& path); + void javaVersionSelected(BaseVersion::Ptr version); + void on_javaBrowseBtn_clicked(); + void on_javaStatusBtn_clicked(); + void javaDownloadBtn_clicked(); + void checkFinished(const JavaChecker::Result& result); + + protected: /* methods */ + void checkJavaPathOnEdit(const QString& path); + void checkJavaPath(const QString& path); + void setJavaStatus(JavaStatus status); + void setupUi(); + + private: /* data */ + VersionSelectWidget* m_versionWidget = nullptr; + QVBoxLayout* m_verticalLayout = nullptr; + QSpacerItem* m_verticalSpacer = nullptr; + + QLineEdit* m_javaPathTextBox = nullptr; + QPushButton* m_javaBrowseBtn = nullptr; + QToolButton* m_javaStatusBtn = nullptr; + QHBoxLayout* m_horizontalLayout = nullptr; + + QGroupBox* m_memoryGroupBox = nullptr; + QGridLayout* m_gridLayout_2 = nullptr; + QSpinBox* m_maxMemSpinBox = nullptr; + QLabel* m_labelMinMem = nullptr; + QLabel* m_labelMaxMem = nullptr; + QLabel* m_labelMaxMemIcon = nullptr; + QSpinBox* m_minMemSpinBox = nullptr; + QLabel* m_labelPermGen = nullptr; + QSpinBox* m_permGenSpinBox = nullptr; + + QHBoxLayout* m_horizontalBtnLayout = nullptr; + QPushButton* m_javaDownloadBtn = nullptr; + QIcon goodIcon; + QIcon yellowIcon; + QIcon badIcon; + + QGroupBox* m_autoJavaGroupBox = nullptr; + QVBoxLayout* m_veriticalJavaLayout = nullptr; + QCheckBox* m_autodetectJavaCheckBox = nullptr; + QCheckBox* m_autodownloadCheckBox = nullptr; + + unsigned int observedMinMemory = 0; + unsigned int observedMaxMemory = 0; + unsigned int observedPermGenMemory = 0; + QString queuedCheck; + uint64_t m_availableMemory = 0ull; + shared_qobject_ptr m_checker; + JavaChecker::Result m_result; + QTimer* m_memoryTimer; +}; diff --git a/launcher/ui/widgets/LabeledToolButton.cpp b/launcher/ui/widgets/LabeledToolButton.cpp new file mode 100644 index 0000000..46114e0 --- /dev/null +++ b/launcher/ui/widgets/LabeledToolButton.cpp @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "LabeledToolButton.h" +#include +#include +#include +#include +#include +#include + +/* + * + * Tool Button with a label on it, instead of the normal text rendering + * + */ + +LabeledToolButton::LabeledToolButton(QWidget* parent) : QToolButton(parent), m_label(new QLabel(this)) +{ + // QToolButton::setText(" "); + m_label->setWordWrap(true); + m_label->setMouseTracking(false); + m_label->setAlignment(Qt::AlignCenter); + m_label->setTextInteractionFlags(Qt::NoTextInteraction); + // somehow, this makes word wrap work in the QLabel. yay. + // m_label->setMinimumWidth(100); +} + +QString LabeledToolButton::text() const +{ + return m_label->text(); +} + +void LabeledToolButton::setText(const QString& text) +{ + m_label->setText(text); +} + +void LabeledToolButton::setIcon(QIcon icon) +{ + m_icon = icon; + resetIcon(); +} + +/*! + \reimp +*/ +QSize LabeledToolButton::sizeHint() const +{ + /* + Q_D(const QToolButton); + if (d->sizeHint.isValid()) + return d->sizeHint; + */ + ensurePolished(); + + int w = 0, h = 0; + QStyleOptionToolButton opt; + initStyleOption(&opt); + QSize sz = m_label->sizeHint(); + w = sz.width(); + h = sz.height(); + + opt.rect.setSize(QSize(w, h)); // PM_MenuButtonIndicator depends on the height + if (popupMode() == MenuButtonPopup) + w += style()->pixelMetric(QStyle::PM_MenuButtonIndicator, &opt, this); + + return style()->sizeFromContents(QStyle::CT_ToolButton, &opt, QSize(w, h), this); +} + +void LabeledToolButton::resizeEvent(QResizeEvent* event) +{ + m_label->setGeometry(QRect(4, 4, width() - 8, height() - 8)); + if (!m_icon.isNull()) { + resetIcon(); + } + QWidget::resizeEvent(event); +} + +void LabeledToolButton::resetIcon() +{ + auto iconSz = m_icon.actualSize(QSize(160, 80)); + float w = iconSz.width(); + float h = iconSz.height(); + float ar = w / h; + // FIXME: hardcoded max size of 160x80 + int newW = 80 * ar; + if (newW > 160) + newW = 160; + QSize newSz(newW, 80); + auto pixmap = m_icon.pixmap(newSz); + m_label->setPixmap(pixmap); + m_label->setMinimumHeight(80); + m_label->setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Preferred); +} diff --git a/launcher/ui/widgets/LabeledToolButton.h b/launcher/ui/widgets/LabeledToolButton.h new file mode 100644 index 0000000..0bb5e28 --- /dev/null +++ b/launcher/ui/widgets/LabeledToolButton.h @@ -0,0 +1,40 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +class QLabel; + +class LabeledToolButton : public QToolButton { + Q_OBJECT + + QLabel* m_label; + QIcon m_icon; + + public: + LabeledToolButton(QWidget* parent = 0); + + QString text() const; + void setText(const QString& text); + void setIcon(QIcon icon); + virtual QSize sizeHint() const; + + protected: + void resizeEvent(QResizeEvent* event); + void resetIcon(); +}; diff --git a/launcher/ui/widgets/LanguageSelectionWidget.cpp b/launcher/ui/widgets/LanguageSelectionWidget.cpp new file mode 100644 index 0000000..a32f703 --- /dev/null +++ b/launcher/ui/widgets/LanguageSelectionWidget.cpp @@ -0,0 +1,85 @@ +#include "LanguageSelectionWidget.h" + +#include +#include +#include +#include +#include +#include "Application.h" +#include "settings/SettingsObject.h" +#include "BuildConfig.h" +#include "settings/Setting.h" +#include "translations/TranslationsModel.h" + +LanguageSelectionWidget::LanguageSelectionWidget(QWidget* parent) : QWidget(parent) +{ + verticalLayout = new QVBoxLayout(this); + verticalLayout->setObjectName(QStringLiteral("verticalLayout")); + languageView = new QTreeView(this); + languageView->setObjectName(QStringLiteral("languageView")); + languageView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + languageView->setAlternatingRowColors(true); + languageView->setRootIsDecorated(false); + languageView->setItemsExpandable(false); + languageView->setWordWrap(true); + languageView->header()->setCascadingSectionResizes(true); + languageView->header()->setStretchLastSection(false); + verticalLayout->addWidget(languageView); + helpUsLabel = new QLabel(this); + helpUsLabel->setObjectName(QStringLiteral("helpUsLabel")); + helpUsLabel->setTextInteractionFlags(Qt::LinksAccessibleByMouse); + helpUsLabel->setOpenExternalLinks(true); + helpUsLabel->setWordWrap(true); + verticalLayout->addWidget(helpUsLabel); + + formatCheckbox = new QCheckBox(this); + formatCheckbox->setObjectName(QStringLiteral("formatCheckbox")); + formatCheckbox->setCheckState(APPLICATION->settings()->get("UseSystemLocale").toBool() ? Qt::Checked : Qt::Unchecked); + connect(formatCheckbox, &QCheckBox::stateChanged, + [this]() { APPLICATION->translations()->setUseSystemLocale(formatCheckbox->isChecked()); }); + verticalLayout->addWidget(formatCheckbox); + + auto translations = APPLICATION->translations(); + auto index = translations->selectedIndex(); + languageView->setModel(translations); + languageView->setCurrentIndex(index); + languageView->header()->setSectionResizeMode(QHeaderView::ResizeToContents); + languageView->header()->setSectionResizeMode(0, QHeaderView::Stretch); + connect(languageView->selectionModel(), &QItemSelectionModel::currentRowChanged, this, &LanguageSelectionWidget::languageRowChanged); + verticalLayout->setContentsMargins(0, 0, 0, 0); + + auto language_setting = APPLICATION->settings()->getSetting("Language"); + connect(language_setting.get(), &Setting::SettingChanged, this, &LanguageSelectionWidget::languageSettingChanged); +} + +QString LanguageSelectionWidget::getSelectedLanguageKey() const +{ + auto translations = APPLICATION->translations(); + return translations->data(languageView->currentIndex(), Qt::UserRole).toString(); +} + +void LanguageSelectionWidget::retranslate() +{ + QString text = tr("Don't see your language or the quality is poor?
    Help us with translations!") + .arg(BuildConfig.TRANSLATIONS_URL); + helpUsLabel->setText(text); + formatCheckbox->setText(tr("Use system locales")); +} + +void LanguageSelectionWidget::languageRowChanged(const QModelIndex& current, const QModelIndex& previous) +{ + if (current == previous) { + return; + } + auto translations = APPLICATION->translations(); + QString key = translations->data(current, Qt::UserRole).toString(); + translations->selectLanguage(key); + translations->updateLanguage(key); +} + +void LanguageSelectionWidget::languageSettingChanged(const Setting&, const QVariant&) +{ + auto translations = APPLICATION->translations(); + auto index = translations->selectedIndex(); + languageView->setCurrentIndex(index); +} diff --git a/launcher/ui/widgets/LanguageSelectionWidget.h b/launcher/ui/widgets/LanguageSelectionWidget.h new file mode 100644 index 0000000..cf1f5bf --- /dev/null +++ b/launcher/ui/widgets/LanguageSelectionWidget.h @@ -0,0 +1,44 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +class QVBoxLayout; +class QTreeView; +class QLabel; +class Setting; +class QCheckBox; + +class LanguageSelectionWidget : public QWidget { + Q_OBJECT + public: + explicit LanguageSelectionWidget(QWidget* parent = 0); + virtual ~LanguageSelectionWidget() {}; + + QString getSelectedLanguageKey() const; + void retranslate(); + + protected slots: + void languageRowChanged(const QModelIndex& current, const QModelIndex& previous); + void languageSettingChanged(const Setting&, const QVariant&); + + private: + QVBoxLayout* verticalLayout = nullptr; + QTreeView* languageView = nullptr; + QLabel* helpUsLabel = nullptr; + QCheckBox* formatCheckbox = nullptr; +}; diff --git a/launcher/ui/widgets/LogView.cpp b/launcher/ui/widgets/LogView.cpp new file mode 100644 index 0000000..73496a0 --- /dev/null +++ b/launcher/ui/widgets/LogView.cpp @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "LogView.h" +#include +#include +#include + +LogView::LogView(QWidget* parent) : QPlainTextEdit(parent) +{ + setWordWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere); + m_defaultFormat = new QTextCharFormat(currentCharFormat()); + setUndoRedoEnabled(false); +} + +LogView::~LogView() +{ + delete m_defaultFormat; +} + +void LogView::setWordWrap(bool wrapping) +{ + if (wrapping) { + setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + setLineWrapMode(QPlainTextEdit::WidgetWidth); + } else { + setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded); + setLineWrapMode(QPlainTextEdit::NoWrap); + } +} + +void LogView::setColorLines(bool colorLines) +{ + if (m_colorLines == colorLines) + return; + m_colorLines = colorLines; + repopulate(); +} + +void LogView::setModel(QAbstractItemModel* model) +{ + if (m_model) { + disconnect(m_model, &QAbstractItemModel::modelReset, this, &LogView::repopulate); + disconnect(m_model, &QAbstractItemModel::rowsInserted, this, &LogView::rowsInserted); + disconnect(m_model, &QAbstractItemModel::rowsAboutToBeInserted, this, &LogView::rowsAboutToBeInserted); + disconnect(m_model, &QAbstractItemModel::rowsRemoved, this, &LogView::rowsRemoved); + } + m_model = model; + if (m_model) { + connect(m_model, &QAbstractItemModel::modelReset, this, &LogView::repopulate); + connect(m_model, &QAbstractItemModel::rowsInserted, this, &LogView::rowsInserted); + connect(m_model, &QAbstractItemModel::rowsAboutToBeInserted, this, &LogView::rowsAboutToBeInserted); + connect(m_model, &QAbstractItemModel::rowsRemoved, this, &LogView::rowsRemoved); + connect(m_model, &QAbstractItemModel::destroyed, this, &LogView::modelDestroyed); + } + repopulate(); +} + +QAbstractItemModel* LogView::model() const +{ + return m_model; +} + +void LogView::modelDestroyed(QObject* model) +{ + if (m_model == model) { + setModel(nullptr); + } +} + +void LogView::repopulate() +{ + auto doc = document(); + doc->clear(); + if (!m_model) { + return; + } + rowsInserted(QModelIndex(), 0, m_model->rowCount() - 1); +} + +void LogView::rowsAboutToBeInserted(const QModelIndex& parent, int first, int last) +{ + Q_UNUSED(parent) + Q_UNUSED(first) + Q_UNUSED(last) + QScrollBar* bar = verticalScrollBar(); + int max_bar = bar->maximum(); + int val_bar = bar->value(); + if (m_scroll) { + m_scroll = (max_bar - val_bar) <= 1; + } else { + m_scroll = val_bar == max_bar; + } +} + +void LogView::rowsInserted(const QModelIndex& parent, int first, int last) +{ + QTextDocument document; + QTextCursor cursor(&document); + + cursor.movePosition(QTextCursor::End); + cursor.beginEditBlock(); + for (int i = first; i <= last; i++) { + auto idx = m_model->index(i, 0, parent); + auto text = m_model->data(idx, Qt::DisplayRole).toString(); + QTextCharFormat format(*m_defaultFormat); + auto font = m_model->data(idx, Qt::FontRole); + if (font.isValid()) { + format.setFont(font.value()); + } + auto fg = m_model->data(idx, Qt::ForegroundRole); + if (fg.isValid() && m_colorLines) { + format.setForeground(fg.value()); + } + auto bg = m_model->data(idx, Qt::BackgroundRole); + if (bg.isValid() && m_colorLines) { + format.setBackground(bg.value()); + } + cursor.insertText(text, format); + cursor.insertBlock(); + } + cursor.endEditBlock(); + + QTextDocumentFragment fragment(&document); + QTextCursor workCursor = textCursor(); + workCursor.movePosition(QTextCursor::End); + workCursor.insertFragment(fragment); + + if (m_scroll && !m_scrolling) { + m_scrolling = true; + QMetaObject::invokeMethod(this, "scrollToBottom", Qt::QueuedConnection); + } +} + +void LogView::rowsRemoved(const QModelIndex& parent, int first, int last) +{ + // TODO: some day... maybe + Q_UNUSED(parent) + Q_UNUSED(first) + Q_UNUSED(last) +} + +void LogView::scrollToBottom() +{ + m_scrolling = false; + verticalScrollBar()->setSliderPosition(verticalScrollBar()->maximum()); +} + +void LogView::findNext(const QString& what, bool reverse) +{ + if (what.isEmpty()) + return; + + const QTextDocument::FindFlags flags(reverse ? QTextDocument::FindBackward : 0); + + if (find(what, flags)) + return; + + QTextCursor cursor = textCursor(); + + if (reverse) { + if (cursor.atEnd()) + return; + + cursor.movePosition(QTextCursor::End); + } else { + if (cursor.atStart()) + return; + + cursor.movePosition(QTextCursor::Start); + } + + cursor = document()->find(what, cursor, flags); + + if (!cursor.isNull()) + setTextCursor(cursor); +} diff --git a/launcher/ui/widgets/LogView.h b/launcher/ui/widgets/LogView.h new file mode 100644 index 0000000..69ca332 --- /dev/null +++ b/launcher/ui/widgets/LogView.h @@ -0,0 +1,37 @@ +#pragma once +#include +#include + +class QAbstractItemModel; + +class LogView : public QPlainTextEdit { + Q_OBJECT + public: + explicit LogView(QWidget* parent = nullptr); + virtual ~LogView(); + + virtual void setModel(QAbstractItemModel* model); + QAbstractItemModel* model() const; + + public slots: + void setWordWrap(bool wrapping); + void setColorLines(bool colorLines); + void findNext(const QString& what, bool reverse); + void scrollToBottom(); + + protected slots: + void repopulate(); + // note: this supports only appending + void rowsInserted(const QModelIndex& parent, int first, int last); + void rowsAboutToBeInserted(const QModelIndex& parent, int first, int last); + // note: this supports only removing from front + void rowsRemoved(const QModelIndex& parent, int first, int last); + void modelDestroyed(QObject* model); + + protected: + QAbstractItemModel* m_model = nullptr; + QTextCharFormat* m_defaultFormat = nullptr; + bool m_scroll = false; + bool m_scrolling = false; + bool m_colorLines = true; +}; diff --git a/launcher/ui/widgets/MinecraftSettingsWidget.cpp b/launcher/ui/widgets/MinecraftSettingsWidget.cpp new file mode 100644 index 0000000..460068b --- /dev/null +++ b/launcher/ui/widgets/MinecraftSettingsWidget.cpp @@ -0,0 +1,581 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2024 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "MinecraftSettingsWidget.h" +#include "modplatform/ModIndex.h" +#include "ui_MinecraftSettingsWidget.h" + +#include +#include "Application.h" +#include "BuildConfig.h" +#include "Json.h" +#include "minecraft/PackProfile.h" +#include "minecraft/WorldList.h" +#include "minecraft/auth/AccountList.h" +#include "settings/Setting.h" + +MinecraftSettingsWidget::MinecraftSettingsWidget(MinecraftInstance* instance, QWidget* parent) + : QWidget(parent), m_instance(std::move(instance)), m_ui(new Ui::MinecraftSettingsWidget) +{ + m_ui->setupUi(this); + + if (m_instance == nullptr) { + m_ui->settingsTabs->removeTab(1); + + m_ui->openGlobalSettingsButton->setVisible(false); + m_ui->instanceAccountGroupBox->hide(); + m_ui->serverJoinGroupBox->hide(); + m_ui->globalDataPacksGroupBox->hide(); + m_ui->loaderGroup->hide(); + } else { + m_javaSettings = new JavaSettingsWidget(m_instance, this); + m_ui->javaScrollArea->setWidget(m_javaSettings); + + m_ui->showGameTime->setText(tr("Show time &playing this instance")); + m_ui->recordGameTime->setText(tr("&Record time playing this instance")); + m_ui->showGlobalGameTime->hide(); + m_ui->showGameTimeWithoutDays->hide(); + + m_ui->maximizedWarning->setText( + tr("Warning: The maximized option is " + "not fully supported on this Minecraft version.")); + + m_ui->consoleSettingsBox->setCheckable(true); + m_ui->windowSizeGroupBox->setCheckable(true); + m_ui->nativeWorkaroundsGroupBox->setCheckable(true); + m_ui->perfomanceGroupBox->setCheckable(true); + m_ui->gameTimeGroupBox->setCheckable(true); + m_ui->legacySettingsGroupBox->setCheckable(true); + + m_quickPlaySingleplayer = m_instance->traits().contains("feature:is_quick_play_singleplayer"); + if (m_quickPlaySingleplayer) { + auto worlds = m_instance->worldList(); + worlds->update(); + for (const auto& world : worlds->allWorlds()) { + m_ui->worldsCb->addItem(world.folderName()); + } + } else { + m_ui->worldsCb->hide(); + m_ui->worldJoinButton->hide(); + m_ui->serverJoinAddressButton->setChecked(true); + m_ui->serverJoinAddress->setEnabled(true); + m_ui->serverJoinAddressButton->setStyleSheet("QRadioButton::indicator { width: 0px; height: 0px; }"); + } + + connect(m_ui->openGlobalSettingsButton, &QCommandLinkButton::clicked, this, &MinecraftSettingsWidget::openGlobalSettings); + connect(m_ui->serverJoinAddressButton, &QAbstractButton::toggled, m_ui->serverJoinAddress, &QWidget::setEnabled); + connect(m_ui->worldJoinButton, &QAbstractButton::toggled, m_ui->worldsCb, &QWidget::setEnabled); + + connect(m_ui->globalDataPacksGroupBox, &QGroupBox::toggled, this, [this](bool value) { + m_instance->settings()->set("GlobalDataPacksEnabled", value); + if (!value) + m_instance->settings()->reset("GlobalDataPacksPath"); + }); + connect(m_ui->dataPacksPathEdit, &QLineEdit::editingFinished, this, &MinecraftSettingsWidget::saveDataPacksPath); + connect(m_ui->dataPacksPathBrowse, &QPushButton::clicked, this, &MinecraftSettingsWidget::selectDataPacksFolder); + + connect(m_ui->loaderGroup, &QGroupBox::toggled, this, [this](bool value) { + m_instance->settings()->set("OverrideModDownloadLoaders", value); + if (value) + saveSelectedLoaders(); + else + m_instance->settings()->reset("ModDownloadLoaders"); + }); + + for (auto c : { m_ui->neoForge, m_ui->forge, m_ui->fabric, m_ui->quilt, m_ui->liteLoader, m_ui->babric, m_ui->btaBabric, + m_ui->legacyFabric, m_ui->ornithe, m_ui->rift }) { + connect(c, &QCheckBox::stateChanged, this, &MinecraftSettingsWidget::saveSelectedLoaders); + } + } + + m_ui->maximizedWarning->hide(); + + connect(m_ui->maximizedCheckBox, &QCheckBox::toggled, this, + [this](const bool value) { m_ui->maximizedWarning->setVisible(value && (m_instance == nullptr || !m_instance->isLegacy())); }); + +#if !defined(Q_OS_LINUX) + m_ui->perfomanceGroupBox->hide(); +#endif + + if (!(APPLICATION->capabilities() & Application::SupportsGameMode)) { + m_ui->enableFeralGamemodeCheck->setDisabled(true); + m_ui->enableFeralGamemodeCheck->setToolTip(tr("Feral Interactive's GameMode could not be found on your system.")); + } + + if (!(APPLICATION->capabilities() & Application::SupportsMangoHud)) { + m_ui->enableMangoHud->setEnabled(false); + m_ui->enableMangoHud->setToolTip(tr("MangoHud could not be found on your system.")); + } + + connect(m_ui->useNativeOpenALCheck, &QAbstractButton::toggled, m_ui->lineEditOpenALPath, &QWidget::setEnabled); + connect(m_ui->useNativeGLFWCheck, &QAbstractButton::toggled, m_ui->lineEditGLFWPath, &QWidget::setEnabled); + + loadSettings(); +} + +MinecraftSettingsWidget::~MinecraftSettingsWidget() +{ + delete m_ui; +} + +void MinecraftSettingsWidget::loadSettings() +{ + SettingsObject* settings; + + if (m_instance != nullptr) + settings = m_instance->settings(); + else + settings = APPLICATION->settings(); + + // Game Window + m_ui->windowSizeGroupBox->setChecked(m_instance == nullptr || settings->get("OverrideWindow").toBool() || + settings->get("OverrideMiscellaneous").toBool()); + m_ui->maximizedCheckBox->setChecked(settings->get("LaunchMaximized").toBool()); + m_ui->windowWidthSpinBox->setValue(settings->get("MinecraftWinWidth").toInt()); + m_ui->windowHeightSpinBox->setValue(settings->get("MinecraftWinHeight").toInt()); + m_ui->closeAfterLaunchCheck->setChecked(settings->get("CloseAfterLaunch").toBool()); + m_ui->quitAfterGameStopCheck->setChecked(settings->get("QuitAfterGameStop").toBool()); + + // Game Time + m_ui->gameTimeGroupBox->setChecked(m_instance == nullptr || settings->get("OverrideGameTime").toBool()); + m_ui->showGameTime->setChecked(settings->get("ShowGameTime").toBool()); + m_ui->recordGameTime->setChecked(settings->get("RecordGameTime").toBool()); + m_ui->showGlobalGameTime->setChecked(m_instance == nullptr && settings->get("ShowGlobalGameTime").toBool()); + m_ui->showGameTimeWithoutDays->setChecked(m_instance == nullptr && settings->get("ShowGameTimeWithoutDays").toBool()); + + // Console + m_ui->consoleSettingsBox->setChecked(m_instance == nullptr || settings->get("OverrideConsole").toBool()); + m_ui->showConsoleCheck->setChecked(settings->get("ShowConsole").toBool()); + m_ui->autoCloseConsoleCheck->setChecked(settings->get("AutoCloseConsole").toBool()); + m_ui->showConsoleErrorCheck->setChecked(settings->get("ShowConsoleOnError").toBool()); + + if (m_javaSettings != nullptr) + m_javaSettings->loadSettings(); + + // Custom commands + m_ui->customCommands->initialize(m_instance != nullptr, m_instance == nullptr || settings->get("OverrideCommands").toBool(), + settings->get("PreLaunchCommand").toString(), settings->get("WrapperCommand").toString(), + settings->get("PostExitCommand").toString()); + + // Environment variables + m_ui->environmentVariables->initialize(m_instance != nullptr, m_instance == nullptr || settings->get("OverrideEnv").toBool(), + Json::toMap(settings->get("Env").toString())); + + // Legacy Tweaks + m_ui->legacySettingsGroupBox->setChecked(m_instance == nullptr || settings->get("OverrideLegacySettings").toBool()); + m_ui->onlineFixes->setChecked(settings->get("OnlineFixes").toBool()); + + // Native Libraries + m_ui->nativeWorkaroundsGroupBox->setChecked(m_instance == nullptr || settings->get("OverrideNativeWorkarounds").toBool()); + m_ui->useNativeGLFWCheck->setChecked(settings->get("UseNativeGLFW").toBool()); + m_ui->lineEditGLFWPath->setText(settings->get("CustomGLFWPath").toString().trimmed()); +#ifdef Q_OS_LINUX + m_ui->lineEditGLFWPath->setPlaceholderText(APPLICATION->m_detectedGLFWPath); +#else + m_ui->lineEditGLFWPath->setPlaceholderText(tr("Path to %1 library file").arg(BuildConfig.GLFW_LIBRARY_NAME)); +#endif + m_ui->useNativeOpenALCheck->setChecked(settings->get("UseNativeOpenAL").toBool()); + m_ui->lineEditOpenALPath->setText(settings->get("CustomOpenALPath").toString().trimmed()); +#ifdef Q_OS_LINUX + m_ui->lineEditOpenALPath->setPlaceholderText(APPLICATION->m_detectedOpenALPath); +#else + m_ui->lineEditOpenALPath->setPlaceholderText(tr("Path to %1 library file").arg(BuildConfig.OPENAL_LIBRARY_NAME)); +#endif + + // Performance + m_ui->perfomanceGroupBox->setChecked(m_instance == nullptr || settings->get("OverridePerformance").toBool()); + m_ui->enableFeralGamemodeCheck->setChecked(settings->get("EnableFeralGamemode").toBool()); + m_ui->enableMangoHud->setChecked(settings->get("EnableMangoHud").toBool()); + m_ui->useDiscreteGpuCheck->setChecked(settings->get("UseDiscreteGpu").toBool()); + m_ui->useZink->setChecked(settings->get("UseZink").toBool()); + + if (m_instance != nullptr) { + // HACK: if we change enable state of child widgets while it's unchecked this creates inconsistency + m_ui->serverJoinGroupBox->setChecked(true); + + if (auto server = settings->get("JoinServerOnLaunchAddress").toString(); !server.isEmpty()) { + m_ui->serverJoinAddress->setText(server); + m_ui->serverJoinAddressButton->setChecked(true); + m_ui->worldJoinButton->setChecked(false); + m_ui->serverJoinAddress->setEnabled(true); + m_ui->worldsCb->setEnabled(false); + } else if (auto world = settings->get("JoinWorldOnLaunch").toString(); !world.isEmpty() && m_quickPlaySingleplayer) { + m_ui->worldsCb->setCurrentText(world); + m_ui->serverJoinAddressButton->setChecked(false); + m_ui->worldJoinButton->setChecked(true); + m_ui->serverJoinAddress->setEnabled(false); + m_ui->worldsCb->setEnabled(true); + } else { + m_ui->serverJoinAddressButton->setChecked(true); + m_ui->worldJoinButton->setChecked(false); + m_ui->serverJoinAddress->setEnabled(true); + m_ui->worldsCb->setEnabled(false); + } + + m_ui->serverJoinGroupBox->setChecked(settings->get("JoinServerOnLaunch").toBool()); + + m_ui->instanceAccountGroupBox->setChecked(settings->get("UseAccountForInstance").toBool()); + updateAccountsMenu(*settings); + + auto blockSignalsCheckBoxes = { m_ui->neoForge, m_ui->forge, m_ui->fabric, m_ui->quilt, m_ui->liteLoader, + m_ui->babric, m_ui->btaBabric, m_ui->legacyFabric, m_ui->ornithe, m_ui->rift }; + m_ui->loaderGroup->blockSignals(true); + for (auto c : blockSignalsCheckBoxes) { + c->blockSignals(true); + } + + const bool overrideLoaders = settings->get("OverrideModDownloadLoaders").toBool(); + const QStringList loaders = Json::toStringList(settings->get("ModDownloadLoaders").toString()); + + m_ui->loaderGroup->setChecked(overrideLoaders); + + if (overrideLoaders) { + m_ui->neoForge->setChecked(loaders.contains(getModLoaderAsString(ModPlatform::NeoForge))); + m_ui->forge->setChecked(loaders.contains(getModLoaderAsString(ModPlatform::Forge))); + m_ui->fabric->setChecked(loaders.contains(getModLoaderAsString(ModPlatform::Fabric))); + m_ui->quilt->setChecked(loaders.contains(getModLoaderAsString(ModPlatform::Quilt))); + m_ui->liteLoader->setChecked(loaders.contains(getModLoaderAsString(ModPlatform::LiteLoader))); + m_ui->babric->setChecked(loaders.contains(getModLoaderAsString(ModPlatform::Babric))); + m_ui->btaBabric->setChecked(loaders.contains(getModLoaderAsString(ModPlatform::BTA))); + m_ui->legacyFabric->setChecked(loaders.contains(getModLoaderAsString(ModPlatform::LegacyFabric))); + m_ui->ornithe->setChecked(loaders.contains(getModLoaderAsString(ModPlatform::Ornithe))); + m_ui->rift->setChecked(loaders.contains(getModLoaderAsString(ModPlatform::Rift))); + } else { + auto instLoaders = m_instance->getPackProfile()->getSupportedModLoaders().value_or(ModPlatform::ModLoaderTypes(0)); + + m_ui->neoForge->setChecked(instLoaders & ModPlatform::NeoForge); + m_ui->forge->setChecked(instLoaders & ModPlatform::Forge); + m_ui->fabric->setChecked(instLoaders & ModPlatform::Fabric); + m_ui->quilt->setChecked(instLoaders & ModPlatform::Quilt); + m_ui->liteLoader->setChecked(instLoaders & ModPlatform::LiteLoader); + m_ui->babric->setChecked(instLoaders & ModPlatform::Babric); + m_ui->btaBabric->setChecked(instLoaders & ModPlatform::BTA); + m_ui->legacyFabric->setChecked(instLoaders & ModPlatform::LegacyFabric); + m_ui->ornithe->setChecked(instLoaders & ModPlatform::Ornithe); + m_ui->rift->setChecked(instLoaders & ModPlatform::Rift); + } + + m_ui->loaderGroup->blockSignals(false); + for (auto c : blockSignalsCheckBoxes) { + c->blockSignals(false); + } + } + + m_ui->legacySettingsGroupBox->setChecked(settings->get("OverrideLegacySettings").toBool()); + m_ui->onlineFixes->setChecked(settings->get("OnlineFixes").toBool()); + + m_ui->globalDataPacksGroupBox->blockSignals(true); + m_ui->dataPacksPathEdit->blockSignals(true); + m_ui->globalDataPacksGroupBox->setChecked(settings->get("GlobalDataPacksEnabled").toBool()); + m_ui->dataPacksPathEdit->setText(settings->get("GlobalDataPacksPath").toString().trimmed()); + m_ui->globalDataPacksGroupBox->blockSignals(false); + m_ui->dataPacksPathEdit->blockSignals(false); +} + +void MinecraftSettingsWidget::saveSettings() +{ + SettingsObject* settings; + + if (m_instance != nullptr) + settings = m_instance->settings(); + else + settings = APPLICATION->settings(); + + { + SettingsObject::Lock lock(settings); + + // Console + bool console = m_instance == nullptr || m_ui->consoleSettingsBox->isChecked(); + + if (m_instance != nullptr) + settings->set("OverrideConsole", console); + + if (console) { + settings->set("ShowConsole", m_ui->showConsoleCheck->isChecked()); + settings->set("AutoCloseConsole", m_ui->autoCloseConsoleCheck->isChecked()); + settings->set("ShowConsoleOnError", m_ui->showConsoleErrorCheck->isChecked()); + } else { + settings->reset("ShowConsole"); + settings->reset("AutoCloseConsole"); + settings->reset("ShowConsoleOnError"); + } + + // Game Window + bool window = m_instance == nullptr || m_ui->windowSizeGroupBox->isChecked(); + + if (m_instance != nullptr) { + settings->set("OverrideWindow", window); + settings->set("OverrideMiscellaneous", window); + } + + if (window) { + settings->set("LaunchMaximized", m_ui->maximizedCheckBox->isChecked()); + settings->set("MinecraftWinWidth", m_ui->windowWidthSpinBox->value()); + settings->set("MinecraftWinHeight", m_ui->windowHeightSpinBox->value()); + settings->set("CloseAfterLaunch", m_ui->closeAfterLaunchCheck->isChecked()); + settings->set("QuitAfterGameStop", m_ui->quitAfterGameStopCheck->isChecked()); + } else { + settings->reset("LaunchMaximized"); + settings->reset("MinecraftWinWidth"); + settings->reset("MinecraftWinHeight"); + settings->reset("CloseAfterLaunch"); + settings->reset("QuitAfterGameStop"); + } + + // Custom Commands + bool custcmd = m_instance == nullptr || m_ui->customCommands->checked(); + + if (m_instance != nullptr) + settings->set("OverrideCommands", custcmd); + + if (custcmd) { + settings->set("PreLaunchCommand", m_ui->customCommands->prelaunchCommand()); + settings->set("WrapperCommand", m_ui->customCommands->wrapperCommand()); + settings->set("PostExitCommand", m_ui->customCommands->postexitCommand()); + } else { + settings->reset("PreLaunchCommand"); + settings->reset("WrapperCommand"); + settings->reset("PostExitCommand"); + } + + // Environment Variables + auto env = m_instance == nullptr || m_ui->environmentVariables->override(); + + if (m_instance != nullptr) + settings->set("OverrideEnv", env); + + if (env) + settings->set("Env", Json::fromMap(m_ui->environmentVariables->value())); + else + settings->reset("Env"); + + // Workarounds + bool workarounds = m_instance == nullptr || m_ui->nativeWorkaroundsGroupBox->isChecked(); + + if (m_instance != nullptr) + settings->set("OverrideNativeWorkarounds", workarounds); + + if (workarounds) { + settings->set("UseNativeGLFW", m_ui->useNativeGLFWCheck->isChecked()); + settings->set("CustomGLFWPath", m_ui->lineEditGLFWPath->text()); + settings->set("UseNativeOpenAL", m_ui->useNativeOpenALCheck->isChecked()); + settings->set("CustomOpenALPath", m_ui->lineEditOpenALPath->text()); + } else { + settings->reset("UseNativeGLFW"); + settings->reset("CustomGLFWPath"); + settings->reset("UseNativeOpenAL"); + settings->reset("CustomOpenALPath"); + } + + // Performance + bool performance = m_instance == nullptr || m_ui->perfomanceGroupBox->isChecked(); + + if (m_instance != nullptr) + settings->set("OverridePerformance", performance); + + if (performance) { + settings->set("EnableFeralGamemode", m_ui->enableFeralGamemodeCheck->isChecked()); + settings->set("EnableMangoHud", m_ui->enableMangoHud->isChecked()); + settings->set("UseDiscreteGpu", m_ui->useDiscreteGpuCheck->isChecked()); + settings->set("UseZink", m_ui->useZink->isChecked()); + } else { + settings->reset("EnableFeralGamemode"); + settings->reset("EnableMangoHud"); + settings->reset("UseDiscreteGpu"); + settings->reset("UseZink"); + } + + // Game time + bool gameTime = m_instance == nullptr || m_ui->gameTimeGroupBox->isChecked(); + + if (m_instance != nullptr) + settings->set("OverrideGameTime", gameTime); + + if (gameTime) { + settings->set("ShowGameTime", m_ui->showGameTime->isChecked()); + settings->set("RecordGameTime", m_ui->recordGameTime->isChecked()); + } else { + settings->reset("ShowGameTime"); + settings->reset("RecordGameTime"); + } + + if (m_instance == nullptr) { + settings->set("ShowGlobalGameTime", m_ui->showGlobalGameTime->isChecked()); + settings->set("ShowGameTimeWithoutDays", m_ui->showGameTimeWithoutDays->isChecked()); + } + + if (m_instance != nullptr) { + // Join server on launch + bool joinServerOnLaunch = m_ui->serverJoinGroupBox->isChecked(); + settings->set("JoinServerOnLaunch", joinServerOnLaunch); + if (joinServerOnLaunch) { + if (m_ui->serverJoinAddressButton->isChecked() || !m_quickPlaySingleplayer) { + settings->set("JoinServerOnLaunchAddress", m_ui->serverJoinAddress->text()); + settings->reset("JoinWorldOnLaunch"); + } else { + settings->set("JoinWorldOnLaunch", m_ui->worldsCb->currentText()); + settings->reset("JoinServerOnLaunchAddress"); + } + } else { + settings->reset("JoinServerOnLaunchAddress"); + settings->reset("JoinWorldOnLaunch"); + } + + // Use an account for this instance + bool useAccountForInstance = m_ui->instanceAccountGroupBox->isChecked(); + settings->set("UseAccountForInstance", useAccountForInstance); + if (useAccountForInstance) { + int accountIndex = m_ui->instanceAccountSelector->currentIndex(); + + if (accountIndex != -1) { + const MinecraftAccountPtr account = APPLICATION->accounts()->at(accountIndex); + if (account != nullptr) + settings->set("InstanceAccountId", account->profileId()); + } + } else { + settings->reset("InstanceAccountId"); + } + } + + bool overrideLegacySettings = m_instance == nullptr || m_ui->legacySettingsGroupBox->isChecked(); + + if (m_instance != nullptr) + settings->set("OverrideLegacySettings", overrideLegacySettings); + + if (overrideLegacySettings) { + settings->set("OnlineFixes", m_ui->onlineFixes->isChecked()); + } else { + settings->reset("OnlineFixes"); + } + } + + if (m_javaSettings != nullptr) + m_javaSettings->saveSettings(); +} + +void MinecraftSettingsWidget::openGlobalSettings() +{ + const QString id = m_ui->settingsTabs->currentWidget()->objectName(); + + qDebug() << id; + + if (id == "javaPage") + APPLICATION->ShowGlobalSettings(this, "java-settings"); + else // TODO select tab + APPLICATION->ShowGlobalSettings(this, "minecraft-settings"); +} + +void MinecraftSettingsWidget::updateAccountsMenu(SettingsObject& settings) +{ + m_ui->instanceAccountSelector->clear(); + auto accounts = APPLICATION->accounts(); + int accountIndex = accounts->findAccountByProfileId(settings.get("InstanceAccountId").toString()); + + for (int i = 0; i < accounts->count(); i++) { + MinecraftAccountPtr account = accounts->at(i); + + QIcon face = account->getFace(); + + if (face.isNull()) + face = QIcon::fromTheme("noaccount"); + + m_ui->instanceAccountSelector->addItem(face, account->profileName(), i); + if (i == accountIndex) + m_ui->instanceAccountSelector->setCurrentIndex(i); + } +} + +bool MinecraftSettingsWidget::isQuickPlaySupported() +{ + return m_instance->traits().contains("feature:is_quick_play_singleplayer"); +} + +void MinecraftSettingsWidget::saveSelectedLoaders() +{ + QStringList loaders; + + if (m_ui->neoForge->isChecked()) + loaders << getModLoaderAsString(ModPlatform::NeoForge); + if (m_ui->forge->isChecked()) + loaders << getModLoaderAsString(ModPlatform::Forge); + if (m_ui->fabric->isChecked()) + loaders << getModLoaderAsString(ModPlatform::Fabric); + if (m_ui->quilt->isChecked()) + loaders << getModLoaderAsString(ModPlatform::Quilt); + if (m_ui->liteLoader->isChecked()) + loaders << getModLoaderAsString(ModPlatform::LiteLoader); + if (m_ui->babric->isChecked()) + loaders << getModLoaderAsString(ModPlatform::Babric); + if (m_ui->btaBabric->isChecked()) + loaders << getModLoaderAsString(ModPlatform::BTA); + if (m_ui->legacyFabric->isChecked()) + loaders << getModLoaderAsString(ModPlatform::LegacyFabric); + if (m_ui->ornithe->isChecked()) + loaders << getModLoaderAsString(ModPlatform::Ornithe); + if (m_ui->rift->isChecked()) + loaders << getModLoaderAsString(ModPlatform::Rift); + + m_instance->settings()->set("ModDownloadLoaders", Json::fromStringList(loaders)); +} + +void MinecraftSettingsWidget::saveDataPacksPath() +{ + if (QDir::separator() != '/') + m_ui->dataPacksPathEdit->setText(m_ui->dataPacksPathEdit->text().replace(QDir::separator(), '/')); + + m_instance->settings()->set("GlobalDataPacksPath", m_ui->dataPacksPathEdit->text()); +} + +void MinecraftSettingsWidget::selectDataPacksFolder() +{ + QString path = QFileDialog::getExistingDirectory(this, tr("Select Global Data Packs Folder"), m_instance->gameRoot()); + + if (path.isEmpty()) + return; + + // if it's inside the instance dir, set path relative to .minecraft + // (so that if it's directly in instance dir it will still lead with .. but more than two levels up are kept absolute) + + const QUrl instanceRootUrl = QUrl::fromLocalFile(m_instance->instanceRoot()); + const QUrl pathUrl = QUrl::fromLocalFile(path); + + if (instanceRootUrl.isParentOf(pathUrl)) + path = QDir(m_instance->gameRoot()).relativeFilePath(path); + + m_ui->dataPacksPathEdit->setText(path); + m_instance->settings()->set("GlobalDataPacksPath", path); +} diff --git a/launcher/ui/widgets/MinecraftSettingsWidget.h b/launcher/ui/widgets/MinecraftSettingsWidget.h new file mode 100644 index 0000000..847e058 --- /dev/null +++ b/launcher/ui/widgets/MinecraftSettingsWidget.h @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * Copyright (C) 2024 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include "JavaSettingsWidget.h" +#include "minecraft/MinecraftInstance.h" + +namespace Ui { +class MinecraftSettingsWidget; +} + +class MinecraftSettingsWidget : public QWidget { + public: + MinecraftSettingsWidget(MinecraftInstance* instance, QWidget* parent = nullptr); + ~MinecraftSettingsWidget() override; + + void loadSettings(); + void saveSettings(); + + private: + void openGlobalSettings(); + void updateAccountsMenu(SettingsObject& settings); + bool isQuickPlaySupported(); + private slots: + void saveSelectedLoaders(); + void saveDataPacksPath(); + void selectDataPacksFolder(); + + MinecraftInstance* m_instance; + Ui::MinecraftSettingsWidget* m_ui; + JavaSettingsWidget* m_javaSettings = nullptr; + bool m_quickPlaySingleplayer = false; +}; diff --git a/launcher/ui/widgets/MinecraftSettingsWidget.ui b/launcher/ui/widgets/MinecraftSettingsWidget.ui new file mode 100644 index 0000000..80fb853 --- /dev/null +++ b/launcher/ui/widgets/MinecraftSettingsWidget.ui @@ -0,0 +1,894 @@ + + + MinecraftSettingsWidget + + + + 0 + 0 + 653 + 600 + + + + + 0 + + + 0 + + + 6 + + + 0 + + + + + Open &Global Settings + + + The settings here are overrides for global settings. + + + + + + + 0 + + + + General + + + + + + + 0 + 0 + + + + true + + + + + 0 + 0 + 623 + 1352 + + + + + + + true + + + Game &Window + + + false + + + false + + + + + + The base game only supports resolution. In order to simulate the maximized behavior the current implementation approximates the maximum display size. + + + <html><head/><body><p><span style=" font-weight:600; color:#f5c211;">Warning</span><span style=" color:#f5c211;">: The maximized option may not be fully supported on all Minecraft versions.</span></p></body></html> + + + + + + + When the game window closes, quit the launcher + + + + + + + Start Minecraft maximized + + + + + + + When the game window opens, hide the launcher + + + + + + + + 0 + 0 + + + + + + + 1 + + + 65536 + + + 1 + + + 854 + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 0 + 6 + + + + + + + + + 0 + 0 + + + + + + + 1 + + + 65536 + + + 480 + + + + + + + &Window Size: + + + windowWidthSpinBox + + + + + + + × + + + + + + + pixels + + + + + + + Qt::Horizontal + + + + 0 + 0 + + + + + + + + + + + true + + + &Console Window + + + false + + + false + + + + + + When the game is launched, show the console window + + + + + + + When the game crashes, show the console window + + + + + + + When the game quits, hide the console window + + + + + + + + + + &Global Data Packs + + + true + + + true + + + + + + Allows installing data packs across all worlds if an applicable mod is installed. +It is most likely you will need to change the path - please refer to the mod's website. + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 0 + 6 + + + + + + + + Folder Path + + + + + + + + + datapacks + + + + + + + Browse + + + + + + + + + + + + true + + + Game &Time + + + false + + + false + + + + + + Show time spent &playing instances + + + + + + + &Record time spent playing instances + + + + + + + Show the &total time played across instances + + + + + + + Always show durations in &hours + + + + + + + + + + Override &Default Account + + + true + + + false + + + + + + Account: + + + + + + + + 0 + 0 + + + + + + + + Qt::Horizontal + + + + 0 + 0 + + + + + + + + + + + Enable Auto-&join + + + true + + + false + + + + + + + 0 + 0 + + + + + + + + Singleplayer world: + + + + + + + Server address: + + + + + + + + 200 + 16777215 + + + + + + + + Qt::Horizontal + + + + 0 + 0 + + + + + + + + + + + Override Mod Download &Loaders + + + true + + + false + + + + + + NeoForge + + + + + + + Forge + + + + + + + Fabric + + + + + + + Quilt + + + + + + + LiteLoader + + + + + + + Babric + + + + + + + BTA (Babric) + + + + + + + Legacy Fabric + + + + + + + Ornithe + + + + + + + Rift + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + + Java + + + + + + true + + + + + 0 + 0 + 623 + 484 + + + + + + + + + + Tweaks + + + + + + true + + + + + 0 + 0 + 609 + 499 + + + + + + + &Legacy Tweaks + + + false + + + false + + + + + + <html><head/><body><p>Emulates usages of old online services which are no longer operating.</p><p>Current fixes include: skin and online mode support.</p></body></html> + + + Enable online fixes (experimental) + + + + + + + + + + true + + + &Native Libraries + + + false + + + false + + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter + + + + + &GLFW library path: + + + lineEditGLFWPath + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 0 + 6 + + + + + + + + &OpenAL library path: + + + lineEditOpenALPath + + + + + + + false + + + + + + + Use system installation of GLFW + + + + + + + Use system installation of OpenAL + + + + + + + false + + + + + + + + + + true + + + &Performance + + + false + + + false + + + + + + <html><head/><body><p>Enable Feral Interactive's GameMode, to potentially improve gaming performance.</p></body></html> + + + Enable Feral GameMode + + + + + + + <html><head/><body><p>Enable MangoHud's advanced performance overlay.</p></body></html> + + + Enable MangoHud + + + + + + + <html><head/><body><p>Use the discrete GPU instead of the primary GPU.</p></body></html> + + + Use discrete GPU + + + + + + + Use Zink, a Mesa OpenGL driver that implements OpenGL on top of Vulkan. Performance may vary depending on the situation. Note: If no suitable Vulkan driver is found, software rendering will be used. + + + Use Zink + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + + Custom Commands + + + + + + + + + + Environment Variables + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + + + + CustomCommands + QWidget +
    ui/widgets/CustomCommands.h
    + 1 +
    + + EnvironmentVariables + QWidget +
    ui/widgets/EnvironmentVariables.h
    + 1 +
    +
    + + openGlobalSettingsButton + settingsTabs + scrollArea + maximizedCheckBox + windowHeightSpinBox + windowWidthSpinBox + closeAfterLaunchCheck + quitAfterGameStopCheck + showConsoleCheck + showConsoleErrorCheck + autoCloseConsoleCheck + showGameTime + recordGameTime + showGlobalGameTime + showGameTimeWithoutDays + instanceAccountGroupBox + instanceAccountSelector + serverJoinGroupBox + serverJoinAddressButton + serverJoinAddress + worldJoinButton + worldsCb + javaScrollArea + scrollArea_2 + onlineFixes + useNativeGLFWCheck + lineEditGLFWPath + useNativeOpenALCheck + lineEditOpenALPath + enableFeralGamemodeCheck + enableMangoHud + useDiscreteGpuCheck + useZink + + + +
    diff --git a/launcher/ui/widgets/ModFilterWidget.cpp b/launcher/ui/widgets/ModFilterWidget.cpp new file mode 100644 index 0000000..6fab2b2 --- /dev/null +++ b/launcher/ui/widgets/ModFilterWidget.cpp @@ -0,0 +1,414 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ModFilterWidget.h" +#include +#include +#include +#include +#include +#include "BaseVersionList.h" +#include "Json.h" +#include "Version.h" +#include "meta/Index.h" +#include "modplatform/ModIndex.h" +#include "ui/widgets/CheckComboBox.h" +#include "ui_ModFilterWidget.h" + +#include "Application.h" +#include "minecraft/PackProfile.h" + +std::unique_ptr ModFilterWidget::create(MinecraftInstance* instance, bool extended) +{ + return std::unique_ptr(new ModFilterWidget(instance, extended)); +} + +class VersionBasicModel : public QIdentityProxyModel { + Q_OBJECT + + public: + explicit VersionBasicModel(QObject* parent = nullptr) : QIdentityProxyModel(parent) {} + + virtual QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override + { + if (role == Qt::DisplayRole) + return QIdentityProxyModel::data(index, BaseVersionList::VersionIdRole); + if (role == Qt::UserRole) + return QIdentityProxyModel::data(index, BaseVersionList::VersionIdRole); + return {}; + } +}; + +class AllVersionProxyModel : public QSortFilterProxyModel { + Q_OBJECT + + public: + AllVersionProxyModel(QObject* parent = nullptr) : QSortFilterProxyModel(parent) {} + + int rowCount(const QModelIndex& parent = QModelIndex()) const override { return QSortFilterProxyModel::rowCount(parent) + 1; } + + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override + { + if (!index.isValid()) { + return {}; + } + + if (index.row() == 0) { + if (role == Qt::DisplayRole) { + return tr("All Versions"); + } + if (role == Qt::UserRole) { + return "all"; + } + return {}; + } + + QModelIndex newIndex = QSortFilterProxyModel::index(index.row() - 1, index.column()); + return QSortFilterProxyModel::data(newIndex, role); + } + + Qt::ItemFlags flags(const QModelIndex& index) const override + { + if (index.row() == 0) { + return Qt::ItemIsSelectable | Qt::ItemIsEnabled; + } + return QSortFilterProxyModel::flags(index); + } +}; + +ModFilterWidget::ModFilterWidget(MinecraftInstance* instance, bool extended) + : QTabWidget(), ui(new Ui::ModFilterWidget), m_instance(instance), m_filter(new Filter()) +{ + ui->setupUi(this); + + m_versions_proxy = new VersionProxyModel(this); + m_versions_proxy->setFilter(BaseVersionList::TypeRole, Filters::equals("release")); + + QAbstractProxyModel* proxy = new VersionBasicModel(this); + proxy->setSourceModel(m_versions_proxy); + + if (extended) { + if (!m_instance) { + ui->environmentGroup->hide(); + } + ui->versions->setSourceModel(proxy); + ui->versions->setSeparator(", "); + ui->versions->setDefaultText(tr("All Versions")); + ui->version->hide(); + } else { + auto allVersions = new AllVersionProxyModel(this); + allVersions->setSourceModel(proxy); + proxy = allVersions; + ui->version->setModel(proxy); + ui->versions->hide(); + ui->showAllVersions->hide(); + ui->environmentGroup->hide(); + ui->openSource->hide(); + } + + ui->versions->setStyleSheet("combobox-popup: 0;"); + ui->version->setStyleSheet("combobox-popup: 0;"); + connect(ui->showAllVersions, &QCheckBox::stateChanged, this, &ModFilterWidget::onShowAllVersionsChanged); + connect(ui->versions, &QComboBox::currentIndexChanged, this, &ModFilterWidget::onVersionFilterChanged); + connect(ui->versions, &CheckComboBox::checkedItemsChanged, this, [this] { onVersionFilterChanged(0); }); + connect(ui->version, &QComboBox::currentTextChanged, this, &ModFilterWidget::onVersionFilterTextChanged); + + connect(ui->neoForge, &QCheckBox::stateChanged, this, &ModFilterWidget::onLoadersFilterChanged); + connect(ui->forge, &QCheckBox::stateChanged, this, &ModFilterWidget::onLoadersFilterChanged); + connect(ui->fabric, &QCheckBox::stateChanged, this, &ModFilterWidget::onLoadersFilterChanged); + connect(ui->quilt, &QCheckBox::stateChanged, this, &ModFilterWidget::onLoadersFilterChanged); + connect(ui->liteLoader, &QCheckBox::stateChanged, this, &ModFilterWidget::onLoadersFilterChanged); + connect(ui->babric, &QCheckBox::stateChanged, this, &ModFilterWidget::onLoadersFilterChanged); + connect(ui->btaBabric, &QCheckBox::stateChanged, this, &ModFilterWidget::onLoadersFilterChanged); + connect(ui->legacyFabric, &QCheckBox::stateChanged, this, &ModFilterWidget::onLoadersFilterChanged); + connect(ui->ornithe, &QCheckBox::stateChanged, this, &ModFilterWidget::onLoadersFilterChanged); + connect(ui->rift, &QCheckBox::stateChanged, this, &ModFilterWidget::onLoadersFilterChanged); + + connect(ui->showMoreButton, &QPushButton::clicked, this, &ModFilterWidget::onShowMoreClicked); + + if (!extended) { + ui->showMoreButton->setVisible(false); + ui->extendedModLoadersWidget->setVisible(false); + } + + if (extended) { + connect(ui->clientSide, &QCheckBox::stateChanged, this, &ModFilterWidget::onSideFilterChanged); + connect(ui->serverSide, &QCheckBox::stateChanged, this, &ModFilterWidget::onSideFilterChanged); + } + + connect(ui->hideInstalled, &QCheckBox::stateChanged, this, &ModFilterWidget::onHideInstalledFilterChanged); + connect(ui->openSource, &QCheckBox::stateChanged, this, &ModFilterWidget::onOpenSourceFilterChanged); + + connect(ui->releaseCb, &QCheckBox::stateChanged, this, &ModFilterWidget::onReleaseFilterChanged); + connect(ui->betaCb, &QCheckBox::stateChanged, this, &ModFilterWidget::onReleaseFilterChanged); + connect(ui->alphaCb, &QCheckBox::stateChanged, this, &ModFilterWidget::onReleaseFilterChanged); + connect(ui->unknownCb, &QCheckBox::stateChanged, this, &ModFilterWidget::onReleaseFilterChanged); + + setHidden(true); + loadVersionList(); + prepareBasicFilter(); +} + +auto ModFilterWidget::getFilter() -> std::shared_ptr +{ + m_filter_changed = false; + return m_filter; +} + +ModFilterWidget::~ModFilterWidget() +{ + delete ui; +} + +void ModFilterWidget::loadVersionList() +{ + m_version_list = APPLICATION->metadataIndex()->get("net.minecraft"); + if (!m_version_list->isLoaded()) { + QEventLoop load_version_list_loop; + + QTimer time_limit_for_list_load; + time_limit_for_list_load.setTimerType(Qt::TimerType::CoarseTimer); + time_limit_for_list_load.setSingleShot(true); + time_limit_for_list_load.callOnTimeout(&load_version_list_loop, &QEventLoop::quit); + time_limit_for_list_load.start(4000); + + auto task = m_version_list->getLoadTask(); + + connect(task.get(), &Task::failed, [this] { + ui->versions->setEnabled(false); + ui->showAllVersions->setEnabled(false); + }); + connect(task.get(), &Task::finished, &load_version_list_loop, &QEventLoop::quit); + + if (!task->isRunning()) + task->start(); + + load_version_list_loop.exec(); + if (time_limit_for_list_load.isActive()) + time_limit_for_list_load.stop(); + } + m_versions_proxy->setSourceModel(m_version_list.get()); +} + +void ModFilterWidget::prepareBasicFilter() +{ + m_filter->openSource = false; + if (m_instance) { + m_filter->hideInstalled = false; + m_filter->side = ModPlatform::Side::NoSide; // or "both" + ModPlatform::ModLoaderTypes loaders; + if (m_instance->settings()->get("OverrideModDownloadLoaders").toBool()) { + for (auto loader : Json::toStringList(m_instance->settings()->get("ModDownloadLoaders").toString())) { + loaders |= ModPlatform::getModLoaderFromString(loader); + } + } else { + loaders = m_instance->getPackProfile()->getSupportedModLoaders().value(); + } + ui->neoForge->setChecked(loaders & ModPlatform::NeoForge); + ui->forge->setChecked(loaders & ModPlatform::Forge); + ui->fabric->setChecked(loaders & ModPlatform::Fabric); + ui->quilt->setChecked(loaders & ModPlatform::Quilt); + ui->liteLoader->setChecked(loaders & ModPlatform::LiteLoader); + ui->babric->setChecked(loaders & ModPlatform::Babric); + ui->btaBabric->setChecked(loaders & ModPlatform::BTA); + ui->legacyFabric->setChecked(loaders & ModPlatform::LegacyFabric); + ui->ornithe->setChecked(loaders & ModPlatform::Ornithe); + ui->rift->setChecked(loaders & ModPlatform::Rift); + m_filter->loaders = loaders; + auto def = m_instance->getPackProfile()->getComponentVersion("net.minecraft"); + m_filter->versions.emplace_back(def); + ui->versions->setCheckedItems({ def }); + ui->version->setCurrentIndex(ui->version->findText(def)); + } else { + ui->hideInstalled->hide(); + } +} + +void ModFilterWidget::onShowAllVersionsChanged() +{ + if (ui->showAllVersions->isChecked()) + m_versions_proxy->clearFilters(); + else + m_versions_proxy->setFilter(BaseVersionList::TypeRole, Filters::equals("release")); +} + +void ModFilterWidget::onVersionFilterChanged(int) +{ + auto versions = ui->versions->checkedItems(); + versions.sort(); + std::vector current_list; + + for (const QString& version : versions) + current_list.emplace_back(version); + + m_filter_changed = m_filter->versions.size() != current_list.size() || + !std::equal(m_filter->versions.begin(), m_filter->versions.end(), current_list.begin(), current_list.end()); + m_filter->versions = current_list; + if (m_filter_changed) + emit filterChanged(); +} + +void ModFilterWidget::onLoadersFilterChanged() +{ + ModPlatform::ModLoaderTypes loaders; + if (ui->neoForge->isChecked()) + loaders |= ModPlatform::NeoForge; + if (ui->forge->isChecked()) + loaders |= ModPlatform::Forge; + if (ui->fabric->isChecked()) + loaders |= ModPlatform::Fabric; + if (ui->quilt->isChecked()) + loaders |= ModPlatform::Quilt; + if (ui->liteLoader->isChecked()) + loaders |= ModPlatform::LiteLoader; + if (ui->babric->isChecked()) + loaders |= ModPlatform::Babric; + if (ui->btaBabric->isChecked()) + loaders |= ModPlatform::BTA; + if (ui->legacyFabric->isChecked()) + loaders |= ModPlatform::LegacyFabric; + if (ui->ornithe->isChecked()) + loaders |= ModPlatform::Ornithe; + if (ui->rift->isChecked()) + loaders |= ModPlatform::Rift; + m_filter_changed = loaders != m_filter->loaders; + m_filter->loaders = loaders; + if (m_filter_changed) + emit filterChanged(); +} + +void ModFilterWidget::onSideFilterChanged() +{ + ModPlatform::Side side; + + if (ui->clientSide->isChecked() && !ui->serverSide->isChecked()) { + side = ModPlatform::Side::ClientSide; + } else if (!ui->clientSide->isChecked() && ui->serverSide->isChecked()) { + side = ModPlatform::Side::ServerSide; + } else if (ui->clientSide->isChecked() && ui->serverSide->isChecked()) { + side = ModPlatform::Side::UniversalSide; + } else { + side = ModPlatform::Side::NoSide; + } + + m_filter_changed = side != m_filter->side; + m_filter->side = side; + if (m_filter_changed) + emit filterChanged(); +} + +void ModFilterWidget::onHideInstalledFilterChanged() +{ + auto hide = ui->hideInstalled->isChecked(); + m_filter_changed = hide != m_filter->hideInstalled; + m_filter->hideInstalled = hide; + if (m_filter_changed) + emit filterChanged(); +} + +void ModFilterWidget::onVersionFilterTextChanged(const QString& version) +{ + m_filter->versions.clear(); + if (ui->version->currentData(Qt::UserRole) != "all") { + m_filter->versions.emplace_back(version); + } + m_filter_changed = true; + emit filterChanged(); +} + +void ModFilterWidget::setCategories(const QList& categories) +{ + m_categories = categories; + + delete ui->categoryGroup->layout(); + auto layout = new QVBoxLayout(ui->categoryGroup); + + for (const auto& category : categories) { + auto name = category.name; + name.replace("-", " "); + name.replace("&", "&&"); + auto checkbox = new QCheckBox(name); + auto font = checkbox->font(); + font.setCapitalization(QFont::Capitalize); + checkbox->setFont(font); + + layout->addWidget(checkbox); + + const QString id = category.id; + connect(checkbox, &QCheckBox::toggled, this, [this, id](bool checked) { + if (checked) + m_filter->categoryIds.append(id); + else + m_filter->categoryIds.removeOne(id); + + m_filter_changed = true; + emit filterChanged(); + }); + } +} + +void ModFilterWidget::onOpenSourceFilterChanged() +{ + auto open = ui->openSource->isChecked(); + m_filter_changed = open != m_filter->openSource; + m_filter->openSource = open; + if (m_filter_changed) + emit filterChanged(); +} + +void ModFilterWidget::onReleaseFilterChanged() +{ + std::vector releases; + if (ui->releaseCb->isChecked()) + releases.push_back(ModPlatform::IndexedVersionType::Release); + if (ui->betaCb->isChecked()) + releases.push_back(ModPlatform::IndexedVersionType::Beta); + if (ui->alphaCb->isChecked()) + releases.push_back(ModPlatform::IndexedVersionType::Alpha); + if (ui->unknownCb->isChecked()) + releases.push_back(ModPlatform::IndexedVersionType::Unknown); + m_filter_changed = releases != m_filter->releases; + m_filter->releases = releases; + if (m_filter_changed) + emit filterChanged(); +} + +void ModFilterWidget::onShowMoreClicked() +{ + ui->extendedModLoadersWidget->setVisible(true); + ui->showMoreButton->setVisible(false); +} + +#include "ModFilterWidget.moc" diff --git a/launcher/ui/widgets/ModFilterWidget.h b/launcher/ui/widgets/ModFilterWidget.h new file mode 100644 index 0000000..85deb51 --- /dev/null +++ b/launcher/ui/widgets/ModFilterWidget.h @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include + +#include "Version.h" + +#include "VersionProxyModel.h" +#include "meta/VersionList.h" + +#include "minecraft/MinecraftInstance.h" +#include "modplatform/ModIndex.h" + +class MinecraftInstance; + +namespace Ui { +class ModFilterWidget; +} + +class ModFilterWidget : public QTabWidget { + Q_OBJECT + public: + struct Filter { + std::vector versions; + std::vector releases; + ModPlatform::ModLoaderTypes loaders; + ModPlatform::Side side; + bool hideInstalled; + QStringList categoryIds; + bool openSource; + + bool operator==(const Filter& other) const + { + return hideInstalled == other.hideInstalled && side == other.side && loaders == other.loaders && versions == other.versions && + releases == other.releases && categoryIds == other.categoryIds && openSource == other.openSource; + } + bool operator!=(const Filter& other) const { return !(*this == other); } + + bool checkMcVersions(QStringList value) + { + for (auto mcVersion : versions) + if (value.contains(mcVersion.toString())) + return true; + + return versions.empty(); + } + + bool checkModpackFilters(const ModPlatform::IndexedVersion& v) + { + return ((!loaders || !v.loaders || loaders & v.loaders) && // loaders + (releases.empty() || // releases + std::find(releases.cbegin(), releases.cend(), v.version_type) != releases.cend()) && + checkMcVersions({ v.mcVersion })); // gameVersion} + } + }; + + static std::unique_ptr create(MinecraftInstance* instance, bool extended); + virtual ~ModFilterWidget(); + + auto getFilter() -> std::shared_ptr; + auto changed() const -> bool { return m_filter_changed; } + + signals: + void filterChanged(); + + public slots: + void setCategories(const QList&); + + private: + ModFilterWidget(MinecraftInstance* instance, bool extendedSupport); + + void loadVersionList(); + void prepareBasicFilter(); + + private slots: + void onVersionFilterChanged(int); + void onVersionFilterTextChanged(const QString& version); + void onLoadersFilterChanged(); + void onSideFilterChanged(); + void onHideInstalledFilterChanged(); + void onShowAllVersionsChanged(); + void onOpenSourceFilterChanged(); + void onReleaseFilterChanged(); + void onShowMoreClicked(); + + private: + Ui::ModFilterWidget* ui; + + MinecraftInstance* m_instance = nullptr; + std::shared_ptr m_filter; + bool m_filter_changed = false; + + Meta::VersionList::Ptr m_version_list; + VersionProxyModel* m_versions_proxy = nullptr; + + QList m_categories; +}; diff --git a/launcher/ui/widgets/ModFilterWidget.ui b/launcher/ui/widgets/ModFilterWidget.ui new file mode 100644 index 0000000..500d663 --- /dev/null +++ b/launcher/ui/widgets/ModFilterWidget.ui @@ -0,0 +1,333 @@ + + + ModFilterWidget + + + + 0 + 0 + 310 + 600 + + + + + 0 + 0 + + + + + 275 + 0 + + + + + 310 + 16777215 + + + + Form + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 275 + 0 + + + + QAbstractScrollArea::AdjustToContentsOnFirstShow + + + true + + + + + 0 + 0 + 294 + 817 + + + + + + + Categories + + + false + + + false + + + + + + + Loaders + + + false + + + false + + + + + + NeoForge + + + + + + + Forge + + + + + + + Fabric + + + + + + + Quilt + + + + + + + Show More + + + + + + + false + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + LiteLoader + + + + + + + Babric + + + + + + + BTA (Babric) + + + + + + + Legacy Fabric + + + + + + + Ornithe + + + + + + + Rift + + + + + + + + + + + + + Versions + + + false + + + false + + + + + + Show all versions + + + + + + + + + + + + + + + + Environments + + + false + + + false + + + + + + Client + + + + + + + Server + + + + + + + + + + Hide installed items + + + + + + + Open source only + + + + + + + Release type + + + + + + Release + + + + + + + Beta + + + + + + + Alpha + + + + + + + Unknown + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + + CheckComboBox + QComboBox +
    ui/widgets/CheckComboBox.h
    +
    +
    + + +
    diff --git a/launcher/ui/widgets/ModListView.cpp b/launcher/ui/widgets/ModListView.cpp new file mode 100644 index 0000000..c2191ca --- /dev/null +++ b/launcher/ui/widgets/ModListView.cpp @@ -0,0 +1,69 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ModListView.h" +#include +#include +#include +#include +#include + +ModListView::ModListView(QWidget* parent) : QTreeView(parent) +{ + setAllColumnsShowFocus(true); + setExpandsOnDoubleClick(false); + setRootIsDecorated(false); + setSortingEnabled(true); + setAlternatingRowColors(true); + setSelectionMode(QAbstractItemView::ExtendedSelection); + setHeaderHidden(false); + setSelectionBehavior(QAbstractItemView::SelectRows); + setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded); + setDropIndicatorShown(true); + setDragEnabled(true); + setDragDropMode(QAbstractItemView::DropOnly); + viewport()->setAcceptDrops(true); + setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); +} + +void ModListView::setModel(QAbstractItemModel* model) +{ + QTreeView::setModel(model); + auto head = header(); + head->setStretchLastSection(false); + // HACK: this is true for the checkbox column of mod lists + auto string = model->headerData(0, head->orientation()).toString(); + if (head->count() < 1) { + return; + } + if (!string.size()) { + head->setSectionResizeMode(0, QHeaderView::Interactive); + head->setSectionResizeMode(1, QHeaderView::Stretch); + for (int i = 2; i < head->count(); i++) + head->setSectionResizeMode(i, QHeaderView::Interactive); + } else { + head->setSectionResizeMode(0, QHeaderView::Stretch); + for (int i = 1; i < head->count(); i++) + head->setSectionResizeMode(i, QHeaderView::Interactive); + } +} + +void ModListView::setResizeModes(const QList& modes) +{ + auto head = header(); + for (int i = 0; i < modes.count(); i++) { + head->setSectionResizeMode(i, modes[i]); + } +} diff --git a/launcher/ui/widgets/ModListView.h b/launcher/ui/widgets/ModListView.h new file mode 100644 index 0000000..8631645 --- /dev/null +++ b/launcher/ui/widgets/ModListView.h @@ -0,0 +1,26 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once +#include +#include + +class ModListView : public QTreeView { + Q_OBJECT + public: + explicit ModListView(QWidget* parent = 0); + virtual void setModel(QAbstractItemModel* model); + virtual void setResizeModes(const QList& modes); +}; diff --git a/launcher/ui/widgets/PageContainer.cpp b/launcher/ui/widgets/PageContainer.cpp new file mode 100644 index 0000000..58b0922 --- /dev/null +++ b/launcher/ui/widgets/PageContainer.cpp @@ -0,0 +1,283 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (c) 2022 Jamie Mansfield + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "PageContainer.h" +#include "BuildConfig.h" +#include "PageContainer_p.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "settings/SettingsObject.h" + +#include "ui/widgets/IconLabel.h" + +#include "Application.h" +#include "DesktopServices.h" + +class PageEntryFilterModel : public QSortFilterProxyModel { + public: + explicit PageEntryFilterModel(QObject* parent = 0) : QSortFilterProxyModel(parent) {} + + protected: + bool filterAcceptsRow(int sourceRow, const QModelIndex& sourceParent) const + { + const QString pattern = filterRegularExpression().pattern(); + const auto model = static_cast(sourceModel()); + const auto page = model->pages().at(sourceRow); + if (!page->shouldDisplay()) + return false; + // Regular contents check, then check page-filter. + return QSortFilterProxyModel::filterAcceptsRow(sourceRow, sourceParent); + } +}; + +PageContainer::PageContainer(BasePageProvider* pageProvider, QString defaultId, QWidget* parent) : QWidget(parent) +{ + createUI(); + useSidebarStyle(true); + + m_model = new PageModel(this); + m_proxyModel = new PageEntryFilterModel(this); + int counter = 0; + auto pages = pageProvider->getPages(); + for (auto page : pages) { + auto widget = dynamic_cast(page); + widget->setParent(this); + page->stackIndex = m_pageStack->addWidget(widget); + page->listIndex = counter; + page->setParentContainer(this); + counter++; + page->updateExtraInfo = [this](QString id, QString info) { + if (m_currentPage && id == m_currentPage->id()) + m_header->setText(m_currentPage->displayName() + info); + }; + } + m_model->setPages(pages); + + m_proxyModel->setSourceModel(m_model); + m_proxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive); + + m_pageList->setIconSize(QSize(pageIconSize, pageIconSize)); + m_pageList->setSelectionMode(QAbstractItemView::SingleSelection); + m_pageList->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); + m_pageList->setSizeAdjustPolicy(QAbstractScrollArea::AdjustToContents); + m_pageList->setModel(m_proxyModel); + connect(m_pageList->selectionModel(), &QItemSelectionModel::currentRowChanged, this, &PageContainer::currentChanged); + m_pageStack->setStackingMode(QStackedLayout::StackOne); + m_pageList->setFocus(); + selectPage(defaultId); +} + +bool PageContainer::selectPage(QString pageId) +{ + // now find what we want to have selected... + auto page = m_model->findPageEntryById(pageId); + QModelIndex index; + if (page) { + index = m_proxyModel->mapFromSource(m_model->index(page->listIndex)); + } + if (!index.isValid()) { + index = m_proxyModel->index(0, 0); + } + if (index.isValid()) { + m_pageList->setCurrentIndex(index); + return true; + } + return false; +} + +BasePage* PageContainer::getPage(QString pageId) +{ + return m_model->findPageEntryById(pageId); +} + +BasePage* PageContainer::selectedPage() const +{ + return m_currentPage; +} + +const QList& PageContainer::getPages() const +{ + return m_model->pages(); +} + +void PageContainer::refreshContainer() +{ + m_proxyModel->invalidate(); + if (!m_currentPage->shouldDisplay()) { + auto index = m_proxyModel->index(0, 0); + if (index.isValid()) { + m_pageList->setCurrentIndex(index); + } else { + // FIXME: unhandled corner case: what to do when there's no page to select? + } + } +} + +void PageContainer::createUI() +{ + m_pageStack = new QStackedLayout; + m_pageList = new PageView; + m_header = new QLabel(); + + QFont headerLabelFont = m_header->font(); + headerLabelFont.setBold(true); + const int pointSize = headerLabelFont.pointSize(); + if (pointSize > 0) + headerLabelFont.setPointSize(pointSize + 2); + m_header->setFont(headerLabelFont); + + QHBoxLayout* headerHLayout = new QHBoxLayout; + const int leftMargin = APPLICATION->style()->pixelMetric(QStyle::PM_LayoutLeftMargin); + headerHLayout->addSpacerItem(new QSpacerItem(leftMargin, 0, QSizePolicy::Fixed, QSizePolicy::Ignored)); + headerHLayout->addWidget(m_header); + headerHLayout->setContentsMargins(0, 6, 0, 0); + + m_pageStack->setContentsMargins(0, 0, 0, 0); + m_pageStack->addWidget(new QWidget(this)); + + m_layout = new QGridLayout; + m_layout->addLayout(headerHLayout, 0, 1, 1, 1); + m_layout->addWidget(m_pageList, 0, 0, 3, 1); + m_layout->addLayout(m_pageStack, 1, 1, 1, 1); + m_layout->setColumnStretch(1, 4); + m_layout->setContentsMargins(0, 0, 0, 0); + setLayout(m_layout); +} + +void PageContainer::retranslate() +{ + if (m_currentPage) + m_header->setText(m_currentPage->displayName()); + + for (auto page : m_model->pages()) + page->retranslate(); +} + +void PageContainer::addButtons(QWidget* buttons) +{ + m_layout->addWidget(buttons, 2, 1, 1, 2); +} + +void PageContainer::addButtons(QLayout* buttons) +{ + m_layout->addLayout(buttons, 2, 1, 1, 2); +} + +void PageContainer::useSidebarStyle(bool sidebar) +{ + m_pageList->setProperty("_kde_side_panel_view", sidebar); +} + +void PageContainer::showPage(int row) +{ + if (m_currentPage) { + m_currentPage->closed(); + } + if (row != -1) { + m_currentPage = m_model->pages().at(row); + } else { + m_currentPage = nullptr; + } + if (m_currentPage) { + m_pageStack->setCurrentIndex(m_currentPage->stackIndex); + m_header->setText(m_currentPage->displayName()); + m_currentPage->opened(); + } else { + m_pageStack->setCurrentIndex(0); + m_header->setText(QString()); + } +} + +void PageContainer::help() +{ + if (m_currentPage) { + QString pageId = m_currentPage->helpPage(); + if (pageId.isEmpty()) + return; + DesktopServices::openUrl(QUrl(BuildConfig.HELP_URL.arg(pageId))); + } +} + +void PageContainer::currentChanged(const QModelIndex& current) +{ + int selected_index = current.isValid() ? m_proxyModel->mapToSource(current).row() : -1; + + auto* selected = m_model->pages().at(selected_index); + auto* previous = m_currentPage; + + emit selectedPageChanged(previous, selected); + + showPage(selected_index); +} + +bool PageContainer::prepareToClose() +{ + if (!saveAll()) { + return false; + } + if (m_currentPage) { + m_currentPage->closed(); + } + return true; +} + +bool PageContainer::saveAll() +{ + for (auto page : m_model->pages()) { + if (!page->apply()) + return false; + } + return true; +} + +void PageContainer::changeEvent(QEvent* event) +{ + if (event->type() == QEvent::LanguageChange) { + retranslate(); + } + QWidget::changeEvent(event); +} diff --git a/launcher/ui/widgets/PageContainer.h b/launcher/ui/widgets/PageContainer.h new file mode 100644 index 0000000..2c7ca9e --- /dev/null +++ b/launcher/ui/widgets/PageContainer.h @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 Jamie Mansfield + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +#include "ui/pages/BasePageContainer.h" +#include "ui/pages/BasePageProvider.h" + +class QLayout; +class IconLabel; +class QSortFilterProxyModel; +class PageModel; +class QLabel; +class QListView; +class QLineEdit; +class QStackedLayout; +class QGridLayout; + +class PageContainer : public QWidget, public BasePageContainer { + Q_OBJECT + public: + explicit PageContainer(BasePageProvider* pageProvider, QString defaultId = QString(), QWidget* parent = 0); + virtual ~PageContainer() {} + + void addButtons(QWidget* buttons); + void addButtons(QLayout* buttons); + + void useSidebarStyle(bool sidebar); + + /* + * Save any unsaved state and prepare to be closed. + * @return true if everything can be saved, false if there is something that requires attention + */ + bool prepareToClose(); + bool saveAll(); + + /* request close - used by individual pages */ + bool requestClose() override + { + if (m_container) { + return m_container->requestClose(); + } + return false; + } + + bool selectPage(QString pageId) override; + BasePage* selectedPage() const override; + BasePage* getPage(QString pageId) override; + const QList& getPages() const; + + void refreshContainer() override; + virtual void setParentContainer(BasePageContainer* container) { m_container = container; }; + + void changeEvent(QEvent*) override; + + void hidePageList() { m_pageList->hide(); } + + private: + void createUI(); + void retranslate(); + + public slots: + void help(); + + signals: + /** Emitted when the currently selected page is changed */ + void selectedPageChanged(BasePage* previous, BasePage* selected); + + private slots: + void currentChanged(const QModelIndex& current); + void showPage(int row); + + private: + BasePageContainer* m_container = nullptr; + BasePage* m_currentPage = 0; + QSortFilterProxyModel* m_proxyModel; + PageModel* m_model; + QStackedLayout* m_pageStack; + QListView* m_pageList; + QLabel* m_header; + QGridLayout* m_layout; +}; diff --git a/launcher/ui/widgets/PageContainer_p.h b/launcher/ui/widgets/PageContainer_p.h new file mode 100644 index 0000000..9a7651c --- /dev/null +++ b/launcher/ui/widgets/PageContainer_p.h @@ -0,0 +1,108 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include + +class BasePage; +const int pageIconSize = 24; + +class PageViewDelegate : public QStyledItemDelegate { + public: + PageViewDelegate(QObject* parent) : QStyledItemDelegate(parent) {} + QSize sizeHint(const QStyleOptionViewItem& option, const QModelIndex& index) const + { + QSize size = QStyledItemDelegate::sizeHint(option, index); + size.setHeight(qMax(size.height(), 32)); + return size; + } +}; + +class PageModel : public QAbstractListModel { + public: + PageModel(QObject* parent = 0) : QAbstractListModel(parent) + { + QPixmap empty(pageIconSize, pageIconSize); + empty.fill(Qt::transparent); + m_emptyIcon = QIcon(empty); + } + virtual ~PageModel() {} + + int rowCount(const QModelIndex& parent = QModelIndex()) const { return parent.isValid() ? 0 : m_pages.size(); } + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const + { + switch (role) { + case Qt::DisplayRole: + return m_pages.at(index.row())->displayName(); + case Qt::DecorationRole: { + QIcon icon = m_pages.at(index.row())->icon(); + if (icon.isNull()) + icon = m_emptyIcon; + // HACK: fixes icon stretching on windows. TODO: report Qt bug for this + return QIcon(icon.pixmap(QSize(48, 48))); + } + } + return QVariant(); + } + + void setPages(const QList& pages) + { + beginResetModel(); + m_pages = pages; + endResetModel(); + } + const QList& pages() const { return m_pages; } + + BasePage* findPageEntryById(QString id) + { + for (auto page : m_pages) { + if (page->id() == id) + return page; + } + return nullptr; + } + + QList m_pages; + QIcon m_emptyIcon; +}; + +class PageView : public QListView { + public: + PageView(QWidget* parent = 0) : QListView(parent) + { + setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Expanding); + setItemDelegate(new PageViewDelegate(this)); + setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + } + + virtual QSize sizeHint() const + { + int width = sizeHintForColumn(0) + frameWidth() * 2 + 5; + if (verticalScrollBar()->isVisible()) + width += verticalScrollBar()->width(); + return QSize(width, 100); + } + + virtual bool eventFilter(QObject* obj, QEvent* event) + { + if (obj == verticalScrollBar() && (event->type() == QEvent::Show || event->type() == QEvent::Hide)) + updateGeometry(); + return QListView::eventFilter(obj, event); + } +}; diff --git a/launcher/ui/widgets/ProgressWidget.cpp b/launcher/ui/widgets/ProgressWidget.cpp new file mode 100644 index 0000000..69c7e6f --- /dev/null +++ b/launcher/ui/widgets/ProgressWidget.cpp @@ -0,0 +1,115 @@ +// Licensed under the Apache-2.0 license. See README.md for details. + +#include "ProgressWidget.h" +#include +#include +#include +#include + +#include "tasks/Task.h" + +ProgressWidget::ProgressWidget(QWidget* parent, bool show_label) : QWidget(parent) +{ + auto* layout = new QVBoxLayout(this); + + if (show_label) { + m_label = new QLabel(this); + m_label->setWordWrap(true); + layout->addWidget(m_label); + } + + m_bar = new QProgressBar(this); + m_bar->setMinimum(0); + m_bar->setMaximum(100); + layout->addWidget(m_bar); + + setLayout(layout); +} + +void ProgressWidget::reset() +{ + m_bar->reset(); +} + +void ProgressWidget::progressFormat(QString format) +{ + if (format.isEmpty()) + m_bar->setTextVisible(false); + else + m_bar->setFormat(format); +} + +void ProgressWidget::watch(Task* task) +{ + if (!task) + return; + + if (m_task) + disconnect(m_task, nullptr, this, nullptr); + + m_task = task; + + connect(m_task, &Task::finished, this, &ProgressWidget::handleTaskFinish); + connect(m_task, &Task::status, this, &ProgressWidget::handleTaskStatus); + // TODO: should we connect &Task::details + connect(m_task, &Task::progress, this, &ProgressWidget::handleTaskProgress); + connect(m_task, &Task::destroyed, this, &ProgressWidget::taskDestroyed); + + if (m_task->isRunning()) + show(); + else + connect(m_task, &Task::started, this, &ProgressWidget::show); +} + +void ProgressWidget::start(Task* task) +{ + watch(task); + if (!m_task->isRunning()) + QMetaObject::invokeMethod(m_task, "start", Qt::QueuedConnection); +} + +bool ProgressWidget::exec(std::shared_ptr task) +{ + QEventLoop loop; + + connect(task.get(), &Task::finished, &loop, &QEventLoop::quit); + + start(task.get()); + + if (task->isRunning()) + loop.exec(); + + return task->wasSuccessful(); +} + +void ProgressWidget::show() +{ + setHidden(false); +} +void ProgressWidget::hide() +{ + setHidden(true); +} + +void ProgressWidget::handleTaskFinish() +{ + if (!m_task->wasSuccessful() && m_label) + m_label->setText(m_task->failReason()); + + if (m_hide_if_inactive) + hide(); +} +void ProgressWidget::handleTaskStatus(const QString& status) +{ + if (m_label) + m_label->setText(status); +} +void ProgressWidget::handleTaskProgress(qint64 current, qint64 total) +{ + m_bar->setMaximum(total); + m_bar->setValue(current); +} +void ProgressWidget::taskDestroyed() +{ + m_task = nullptr; +} diff --git a/launcher/ui/widgets/ProgressWidget.h b/launcher/ui/widgets/ProgressWidget.h new file mode 100644 index 0000000..4d9097b --- /dev/null +++ b/launcher/ui/widgets/ProgressWidget.h @@ -0,0 +1,56 @@ +// Licensed under the Apache-2.0 license. See README.md for details. + +#pragma once + +#include +#include + +class Task; +class QProgressBar; +class QLabel; + +class ProgressWidget : public QWidget { + Q_OBJECT + public: + explicit ProgressWidget(QWidget* parent = nullptr, bool show_label = true); + + /** Whether to hide the widget automatically if it's watching no running task. */ + void hideIfInactive(bool hide) { m_hide_if_inactive = hide; } + + /** Reset the displayed progress to 0 */ + void reset(); + + /** The text that shows up in the middle of the progress bar. + * By default it's '%p%', with '%p' being the total progress in percentage. + */ + void progressFormat(QString); + + public slots: + /** Watch the progress of a task. */ + void watch(Task* task); + + /** Watch the progress of a task, and start it if needed */ + void start(Task* task); + + /** Blocking way of waiting for a task to finish. */ + bool exec(std::shared_ptr task); + + /** Un-hide the widget if needed. */ + void show(); + + /** Make the widget invisible. */ + void hide(); + + private slots: + void handleTaskFinish(); + void handleTaskStatus(const QString& status); + void handleTaskProgress(qint64 current, qint64 total); + void taskDestroyed(); + + private: + QLabel* m_label = nullptr; + QProgressBar* m_bar = nullptr; + Task* m_task = nullptr; + + bool m_hide_if_inactive = false; +}; diff --git a/launcher/ui/widgets/ProjectDescriptionPage.cpp b/launcher/ui/widgets/ProjectDescriptionPage.cpp new file mode 100644 index 0000000..c7e79a1 --- /dev/null +++ b/launcher/ui/widgets/ProjectDescriptionPage.cpp @@ -0,0 +1,23 @@ +#include "ProjectDescriptionPage.h" + +#include "VariableSizedImageObject.h" + +#include + +ProjectDescriptionPage::ProjectDescriptionPage(QWidget* parent) : QTextBrowser(parent), m_image_text_object(new VariableSizedImageObject) +{ + m_image_text_object->setParent(this); + document()->documentLayout()->registerHandler(QTextFormat::ImageObject, m_image_text_object.get()); +} + +void ProjectDescriptionPage::setMetaEntry(QString entry) +{ + if (m_image_text_object) + m_image_text_object->setMetaEntry(entry); +} + +void ProjectDescriptionPage::flush() +{ + if (m_image_text_object) + m_image_text_object->flush(); +} diff --git a/launcher/ui/widgets/ProjectDescriptionPage.h b/launcher/ui/widgets/ProjectDescriptionPage.h new file mode 100644 index 0000000..3dd8530 --- /dev/null +++ b/launcher/ui/widgets/ProjectDescriptionPage.h @@ -0,0 +1,32 @@ +#pragma once + +#include + +#include "QObjectPtr.h" + +QT_BEGIN_NAMESPACE +class VariableSizedImageObject; +QT_END_NAMESPACE + +/** This subclasses QTextBrowser to provide additional capabilities + * to it, like allowing for images to be shown. + */ +class ProjectDescriptionPage final : public QTextBrowser { + Q_OBJECT + + public: + ProjectDescriptionPage(QWidget* parent = nullptr); + + void setMetaEntry(QString entry); + + public slots: + /** Flushes the current processing happening in the page. + * + * Should be called when changing the page's content entirely, to + * prevent old tasks from changing the new content. + */ + void flush(); + + private: + shared_qobject_ptr m_image_text_object; +}; diff --git a/launcher/ui/widgets/ProjectItem.cpp b/launcher/ui/widgets/ProjectItem.cpp new file mode 100644 index 0000000..2fd5c97 --- /dev/null +++ b/launcher/ui/widgets/ProjectItem.cpp @@ -0,0 +1,203 @@ +#include "ProjectItem.h" + +#include + +#include +#include +#include +#include "Common.h" + +ProjectItemDelegate::ProjectItemDelegate(QWidget* parent) : QStyledItemDelegate(parent) {} + +void ProjectItemDelegate::paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const +{ + painter->save(); + + QStyleOptionViewItem opt(option); + initStyleOption(&opt, index); + + auto isInstalled = index.data(UserDataTypes::INSTALLED).toBool(); + auto isChecked = opt.checkState == Qt::Checked; + auto isSelected = option.state & QStyle::State_Selected; + + const QStyle* style = opt.widget == nullptr ? QApplication::style() : opt.widget->style(); + + auto rect = opt.rect; + + bool windows = style->objectName().startsWith("windows"); + + if (!windows) + style->drawPrimitive(QStyle::PE_PanelItemViewItem, &opt, painter, opt.widget); + + if (isSelected) { + if (windows) + painter->fillRect(rect, opt.palette.highlight()); + + painter->setPen(opt.palette.highlightedText().color()); + } + + if (opt.features & QStyleOptionViewItem::HasCheckIndicator) { + QStyleOptionViewItem checkboxOpt = makeCheckboxStyleOption(opt, style); + style->drawPrimitive(QStyle::PE_IndicatorItemViewItemCheck, &checkboxOpt, painter, opt.widget); + + rect.setX(checkboxOpt.rect.right()); + } + + if (!isSelected && !isChecked && isInstalled) { + painter->setOpacity(0.4); // Fade out the entire item + } + // The default icon size will be a square (and height is usually the lower value). + auto icon_width = rect.height(), icon_height = rect.height(); + int icon_x_margin = (rect.height() - icon_width) / 2; + int icon_y_margin = (rect.height() - icon_height) / 2; + + if (!opt.icon.isNull()) { // Icon painting + { + auto icon_size = opt.decorationSize; + icon_width = icon_size.width(); + icon_height = icon_size.height(); + + icon_y_margin = (rect.height() - icon_height) / 2; + icon_x_margin = icon_y_margin; // use same margins for consistency + } + + // Centralize icon with a margin to separate from the other elements + int x = rect.x() + icon_x_margin; + int y = rect.y() + icon_y_margin; + + if (opt.features & QStyleOptionViewItem::HasCheckIndicator) + rect.translate(icon_x_margin / 2, 0); + + // Prevent 'scaling null pixmap' warnings + if (icon_width > 0 && icon_height > 0) + opt.icon.paint(painter, x, y, icon_width, icon_height); + } + + // Change the rect so that funther painting is easier + auto remaining_width = rect.width() - icon_width - 2 * icon_x_margin; + rect.setRect(rect.x() + icon_width + 2 * icon_x_margin, rect.y(), remaining_width, rect.height()); + + int title_height = 0; + + { // Title painting + auto title = index.data(UserDataTypes::TITLE).toString(); + + painter->save(); + + auto font = opt.font; + if (isChecked) { + font.setBold(true); + } + if (isInstalled) { + title = tr("%1 [installed]").arg(title); + } + + font.setPointSize(font.pointSize() + 2); + painter->setFont(font); + + title_height = QFontMetrics(font).height(); + + // On the top, aligned to the left after the icon + painter->drawText(rect.x(), rect.y() + title_height, title); + + painter->restore(); + } + + { // Description painting + auto description = index.data(UserDataTypes::DESCRIPTION).toString().simplified(); + + QTextLayout text_layout(description, opt.font); + + qreal height = 0; + auto cut_text = viewItemTextLayout(text_layout, remaining_width, height); + + // Get first line unconditionally + description = cut_text.first().second; + auto num_lines = 1; + + // Get second line, elided if needed + if (cut_text.size() > 1) { + // 2.5x so because there should be some margin left from the 2x so things don't get too squishy. + if (rect.height() - title_height <= 2.5 * opt.fontMetrics.height()) { + // If there's not enough space, show only a single line, elided. + description = opt.fontMetrics.elidedText(description, opt.textElideMode, cut_text.at(0).first); + } else { + if (cut_text.size() > 2) { + description += opt.fontMetrics.elidedText(cut_text.at(1).second, opt.textElideMode, cut_text.at(1).first); + } else { + description += cut_text.at(1).second; + } + num_lines += 1; + } + } + + int description_x = rect.x(); + + // Have the y-value be set based on the number of lines in the description, to centralize the + // description text with the space between the base and the title. + int description_y = rect.y() + title_height + (rect.height() - title_height) / 2; + if (num_lines == 1) + description_y -= opt.fontMetrics.height() / 2; + else + description_y -= opt.fontMetrics.height(); + + // On the bottom, aligned to the left after the icon, and featuring at most two lines of text (with some margin space to spare) + painter->drawText(description_x, description_y, remaining_width, cut_text.size() * opt.fontMetrics.height(), Qt::TextWordWrap, + description); + } + + painter->restore(); +} + +bool ProjectItemDelegate::editorEvent(QEvent* event, + QAbstractItemModel* model, + const QStyleOptionViewItem& option, + const QModelIndex& index) +{ + if (!(event->type() == QEvent::MouseButtonRelease || event->type() == QEvent::MouseButtonPress || + event->type() == QEvent::MouseButtonDblClick)) + return false; + + auto mouseEvent = (QMouseEvent*)event; + + if (mouseEvent->button() != Qt::LeftButton) + return false; + + QStyleOptionViewItem opt(option); + initStyleOption(&opt, index); + + const QStyle* style = opt.widget == nullptr ? QApplication::style() : opt.widget->style(); + + const QStyleOptionViewItem checkboxOpt = makeCheckboxStyleOption(opt, style); + + if (!checkboxOpt.rect.contains(mouseEvent->pos().x(), mouseEvent->pos().y())) + return false; + + // swallow other events + // (prevents item being selected or double click action triggering) + if (event->type() != QEvent::MouseButtonRelease) + return true; + + emit checkboxClicked(index); + return true; +} + +QStyleOptionViewItem ProjectItemDelegate::makeCheckboxStyleOption(const QStyleOptionViewItem& opt, const QStyle* style) const +{ + QStyleOptionViewItem checkboxOpt = opt; + + checkboxOpt.state &= ~QStyle::State_HasFocus; + + if (checkboxOpt.checkState == Qt::Checked) + checkboxOpt.state |= QStyle::State_On; + else + checkboxOpt.state |= QStyle::State_Off; + + QRect checkboxRect = style->subElementRect(QStyle::SE_ItemViewItemCheckIndicator, &checkboxOpt, opt.widget); + // 5px is the typical top margin for image + // we don't want the checkboxes to be all over the place :) + checkboxOpt.rect = QRect(opt.rect.x() + 5, opt.rect.y() + (opt.rect.height() / 2 - checkboxRect.height() / 2), checkboxRect.width(), + checkboxRect.height()); + + return checkboxOpt; +} diff --git a/launcher/ui/widgets/ProjectItem.h b/launcher/ui/widgets/ProjectItem.h new file mode 100644 index 0000000..068358a --- /dev/null +++ b/launcher/ui/widgets/ProjectItem.h @@ -0,0 +1,32 @@ +#pragma once + +#include + +/* Custom data types for our custom list models :) */ +enum UserDataTypes { + TITLE = 257, // QString + DESCRIPTION = 258, // QString + INSTALLED = 259 // bool +}; + +/** This is an item delegate composed of: + * - An Icon on the left + * - A title + * - A description + * */ +class ProjectItemDelegate final : public QStyledItemDelegate { + Q_OBJECT + + public: + ProjectItemDelegate(QWidget* parent); + + void paint(QPainter*, const QStyleOptionViewItem&, const QModelIndex&) const override; + + bool editorEvent(QEvent* event, QAbstractItemModel* model, const QStyleOptionViewItem& option, const QModelIndex& index) override; + + signals: + void checkboxClicked(const QModelIndex& index); + + private: + QStyleOptionViewItem makeCheckboxStyleOption(const QStyleOptionViewItem& opt, const QStyle* style) const; +}; diff --git a/launcher/ui/widgets/SubTaskProgressBar.cpp b/launcher/ui/widgets/SubTaskProgressBar.cpp new file mode 100644 index 0000000..b0e62e0 --- /dev/null +++ b/launcher/ui/widgets/SubTaskProgressBar.cpp @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * PrismLaucher - Minecraft Launcher + * Copyright (C) 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "SubTaskProgressBar.h" +#include "ui_SubTaskProgressBar.h" + +unique_qobject_ptr SubTaskProgressBar::create(QWidget* parent) +{ + auto progress_bar = new SubTaskProgressBar(parent); + return unique_qobject_ptr(progress_bar); +} + +SubTaskProgressBar::SubTaskProgressBar(QWidget* parent) : QWidget(parent), ui(new Ui::SubTaskProgressBar) +{ + ui->setupUi(this); +} +SubTaskProgressBar::~SubTaskProgressBar() +{ + delete ui; +} + +void SubTaskProgressBar::setRange(int min, int max) +{ + ui->progressBar->setRange(min, max); +} + +void SubTaskProgressBar::setValue(int value) +{ + ui->progressBar->setValue(value); +} + +void SubTaskProgressBar::setStatus(QString status) +{ + ui->statusLabel->setText(status); +} + +void SubTaskProgressBar::setDetails(QString details) +{ + ui->statusDetailsLabel->setText(details); +} diff --git a/launcher/ui/widgets/SubTaskProgressBar.h b/launcher/ui/widgets/SubTaskProgressBar.h new file mode 100644 index 0000000..cd08809 --- /dev/null +++ b/launcher/ui/widgets/SubTaskProgressBar.h @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * PrismLaucher - Minecraft Launcher + * Copyright (C) 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +#pragma once + +#include +#include "QObjectPtr.h" + +namespace Ui { +class SubTaskProgressBar; +} + +class SubTaskProgressBar : public QWidget { + Q_OBJECT + + public: + static unique_qobject_ptr create(QWidget* parent = nullptr); + + SubTaskProgressBar(QWidget* parent = nullptr); + ~SubTaskProgressBar(); + + void setRange(int min, int max); + void setValue(int value); + void setStatus(QString status); + void setDetails(QString details); + + private: + Ui::SubTaskProgressBar* ui; +}; diff --git a/launcher/ui/widgets/SubTaskProgressBar.ui b/launcher/ui/widgets/SubTaskProgressBar.ui new file mode 100644 index 0000000..aabb683 --- /dev/null +++ b/launcher/ui/widgets/SubTaskProgressBar.ui @@ -0,0 +1,100 @@ + + + SubTaskProgressBar + + + + 0 + 0 + 312 + 86 + + + + + 0 + 0 + + + + Form + + + + 0 + + + + + 8 + + + + + + 0 + 0 + + + + + 8 + + + + Sub Task Status... + + + true + + + Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse|Qt::TextBrowserInteraction|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse + + + + + + + + 0 + 0 + + + + + 8 + + + + Status Details + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse|Qt::TextBrowserInteraction|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse + + + + + + + + + + 8 + + + + 24 + + + true + + + + + + + + diff --git a/launcher/ui/widgets/VariableSizedImageObject.cpp b/launcher/ui/widgets/VariableSizedImageObject.cpp new file mode 100644 index 0000000..c52d828 --- /dev/null +++ b/launcher/ui/widgets/VariableSizedImageObject.cpp @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "VariableSizedImageObject.h" + +#include +#include +#include +#include +#include + +#include "Application.h" + +#include "net/ApiDownload.h" +#include "net/NetJob.h" + +enum FormatProperties { ImageData = QTextFormat::UserProperty + 1 }; + +QSizeF VariableSizedImageObject::intrinsicSize(QTextDocument* doc, int posInDocument, const QTextFormat& format) +{ + Q_UNUSED(posInDocument); + + auto image = qvariant_cast(format.property(ImageData)); + auto size = image.size(); + if (size.isEmpty()) // can't resize an empty image + return { size }; + + // calculate the new image size based on the properties + int width = 0; + int height = 0; + auto widthVar = format.property(QTextFormat::ImageWidth); + if (widthVar.isValid()) { + width = widthVar.toInt(); + } + auto heigthVar = format.property(QTextFormat::ImageHeight); + if (heigthVar.isValid()) { + height = heigthVar.toInt(); + } + if (width != 0 && height != 0) { + size.setWidth(width); + size.setHeight(height); + } else if (width != 0) { + size.setHeight((width * size.height()) / size.width()); + size.setWidth(width); + } else if (height != 0) { + size.setWidth((height * size.width()) / size.height()); + size.setHeight(height); + } + + // Get the width of the text content to make the image similar sized. + // doc->textWidth() includes the margin, so we need to remove it. + auto doc_width = doc->textWidth() - 2 * doc->documentMargin(); + + if (size.width() > doc_width) + size *= doc_width / (double)size.width(); + + return { size }; +} + +void VariableSizedImageObject::drawObject(QPainter* painter, + const QRectF& rect, + QTextDocument* doc, + int posInDocument, + const QTextFormat& format) +{ + if (!format.hasProperty(ImageData)) { + QUrl image_url{ qvariant_cast(format.property(QTextFormat::ImageName)) }; + if (m_fetching_images.contains(image_url) || image_url.isEmpty()) + return; + + auto meta = std::make_shared(); + meta->posInDocument = posInDocument; + meta->url = image_url; + + auto widthVar = format.property(QTextFormat::ImageWidth); + if (widthVar.isValid()) { + meta->width = widthVar.toInt(); + } + auto heigthVar = format.property(QTextFormat::ImageHeight); + if (heigthVar.isValid()) { + meta->height = heigthVar.toInt(); + } + + loadImage(doc, meta); + return; + } + + auto image = qvariant_cast(format.property(ImageData)); + + painter->setRenderHint(QPainter::RenderHint::SmoothPixmapTransform); + painter->drawImage(rect, image); +} + +void VariableSizedImageObject::flush() +{ + m_fetching_images.clear(); +} + +void VariableSizedImageObject::parseImage(QTextDocument* doc, std::shared_ptr meta) +{ + QTextCursor cursor(doc); + cursor.setPosition(meta->posInDocument); + cursor.setKeepPositionOnInsert(true); + + auto image_char_format = cursor.charFormat(); + + image_char_format.setObjectType(QTextFormat::ImageObject); + image_char_format.setProperty(ImageData, meta->image); + image_char_format.setProperty(QTextFormat::ImageName, meta->url.toDisplayString()); + image_char_format.setProperty(QTextFormat::ImageWidth, meta->width); + image_char_format.setProperty(QTextFormat::ImageHeight, meta->height); + + // Qt doesn't allow us to modify the properties of an existing object in the document. + // So we remove the old one and add the new one with the ImageData property set. + cursor.deleteChar(); + cursor.insertText(QString(QChar::ObjectReplacementCharacter), image_char_format); +} + +void VariableSizedImageObject::loadImage(QTextDocument* doc, std::shared_ptr meta) +{ + m_fetching_images.insert(meta->url); + + MetaEntryPtr entry = APPLICATION->metacache()->resolveEntry( + m_meta_entry, + QString("images/%1").arg(QString(QCryptographicHash::hash(meta->url.toEncoded(), QCryptographicHash::Algorithm::Sha1).toHex()))); + + auto job = new NetJob(QString("Load Image: %1").arg(meta->url.fileName()), APPLICATION->network()); + job->setAskRetry(false); + job->addNetAction(Net::ApiDownload::makeCached(meta->url, entry)); + + auto full_entry_path = entry->getFullPath(); + auto source_url = meta->url; + auto loadImage = [this, doc, full_entry_path, source_url, meta](const QImage& image) { + doc->addResource(QTextDocument::ImageResource, source_url, image); + + meta->image = image; + parseImage(doc, meta); + + // This size hack is needed to prevent the content from being laid out in an area smaller + // than the total width available (weird). + auto size = doc->pageSize(); + doc->adjustSize(); + doc->setPageSize(size); + + m_fetching_images.remove(source_url); + }; + connect(job, &NetJob::succeeded, this, [this, full_entry_path, source_url, loadImage] { + qDebug() << "Loaded resource at:" << full_entry_path; + // If we flushed, don't proceed. + if (!m_fetching_images.contains(source_url)) + return; + + QImage image(full_entry_path); + loadImage(image); + }); + connect(job, &NetJob::failed, this, [this, full_entry_path, source_url, loadImage](QString reason) { + qWarning() << "Failed resource at:" << full_entry_path << "because:" << reason; + // If we flushed, don't proceed. + if (!m_fetching_images.contains(source_url)) + return; + + loadImage(QImage()); + }); + connect(job, &NetJob::finished, job, &NetJob::deleteLater); + + job->start(); +} diff --git a/launcher/ui/widgets/VariableSizedImageObject.h b/launcher/ui/widgets/VariableSizedImageObject.h new file mode 100644 index 0000000..df3ab4f --- /dev/null +++ b/launcher/ui/widgets/VariableSizedImageObject.h @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2022 flowln + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include +#include +#include +#include + +/** Custom image text object to be used instead of the normal one in ProjectDescriptionPage. + * + * Why? Because we want to re-scale images dynamically based on the document's size, in order to + * not have images being weirdly cropped out in different resolutions. + */ +class VariableSizedImageObject final : public QObject, public QTextObjectInterface { + Q_OBJECT + Q_INTERFACES(QTextObjectInterface) + + struct ImageMetadata { + int posInDocument; + QUrl url; + QImage image; + int width; + int height; + }; + + public: + QSizeF intrinsicSize(QTextDocument* doc, int posInDocument, const QTextFormat& format) override; + void drawObject(QPainter* painter, const QRectF& rect, QTextDocument* doc, int posInDocument, const QTextFormat& format) override; + + void setMetaEntry(QString meta_entry) { m_meta_entry = meta_entry; } + + public slots: + /** Stops all currently loading images from modifying the document. + * + * This does not stop the ongoing network tasks, it only prevents their result + * from impacting the document any further. + */ + void flush(); + + private: + /** Adds the image to the document, in the given position. + */ + void parseImage(QTextDocument* doc, std::shared_ptr meta); + + /** Loads an image from an external source, and adds it to the document. + * + * This uses m_meta_entry to cache the image. + */ + void loadImage(QTextDocument* doc, std::shared_ptr meta); + + private: + QString m_meta_entry; + + QSet m_fetching_images; +}; diff --git a/launcher/ui/widgets/VersionListView.cpp b/launcher/ui/widgets/VersionListView.cpp new file mode 100644 index 0000000..475c3da --- /dev/null +++ b/launcher/ui/widgets/VersionListView.cpp @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "VersionListView.h" +#include +#include +#include +#include +#include + +VersionListView::VersionListView(QWidget* parent) : QTreeView(parent) +{ + m_emptyString = tr("No versions are currently available."); + setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); +} + +void VersionListView::rowsInserted(const QModelIndex& parent, int start, int end) +{ + m_itemCount += end - start + 1; + updateEmptyViewPort(); + QTreeView::rowsInserted(parent, start, end); +} + +void VersionListView::rowsAboutToBeRemoved(const QModelIndex& parent, int start, int end) +{ + m_itemCount -= end - start + 1; + updateEmptyViewPort(); + QTreeView::rowsInserted(parent, start, end); +} + +void VersionListView::setModel(QAbstractItemModel* model) +{ + m_itemCount = model->rowCount(); + updateEmptyViewPort(); + QTreeView::setModel(model); +} + +void VersionListView::reset() +{ + if (model()) { + m_itemCount = model()->rowCount(); + } else { + m_itemCount = 0; + } + updateEmptyViewPort(); + QTreeView::reset(); +} + +void VersionListView::setEmptyString(QString emptyString) +{ + m_emptyString = emptyString; + updateEmptyViewPort(); +} + +void VersionListView::setEmptyErrorString(QString emptyErrorString) +{ + m_emptyErrorString = emptyErrorString; + updateEmptyViewPort(); +} + +void VersionListView::setEmptyMode(VersionListView::EmptyMode mode) +{ + m_emptyMode = mode; + updateEmptyViewPort(); +} + +void VersionListView::updateEmptyViewPort() +{ +#ifndef QT_NO_ACCESSIBILITY + setAccessibleDescription(currentEmptyString()); +#endif /* !QT_NO_ACCESSIBILITY */ + + if (!m_itemCount) { + viewport()->update(); + } +} + +void VersionListView::paintEvent(QPaintEvent* event) +{ + if (m_itemCount) { + QTreeView::paintEvent(event); + } else { + paintInfoLabel(event); + } +} + +QString VersionListView::currentEmptyString() const +{ + switch (m_emptyMode) { + default: + case VersionListView::String: + return m_emptyString; + case VersionListView::ErrorString: + return m_emptyErrorString; + } +} + +void VersionListView::paintInfoLabel(QPaintEvent* event) const +{ + QString emptyString = currentEmptyString(); + + // calculate the rect for the overlay + QPainter painter(viewport()); + painter.setRenderHint(QPainter::Antialiasing, true); + QFont font("sans", 20); + font.setBold(true); + + QRect bounds = viewport()->geometry(); + bounds.moveTop(0); + auto innerBounds = bounds; + innerBounds.adjust(10, 10, -10, -10); + + QColor background = QApplication::palette().color(QPalette::WindowText); + QColor foreground = QApplication::palette().color(QPalette::Base); + foreground.setAlpha(190); + painter.setFont(font); + auto fontMetrics = painter.fontMetrics(); + auto textRect = fontMetrics.boundingRect(innerBounds, Qt::AlignHCenter | Qt::TextWordWrap, emptyString); + textRect.moveCenter(bounds.center()); + + auto wrapRect = textRect; + wrapRect.adjust(-10, -10, 10, 10); + + // check if we are allowed to draw in our area + if (!event->rect().intersects(wrapRect)) { + return; + } + + painter.setBrush(QBrush(background)); + painter.setPen(foreground); + painter.drawRoundedRect(wrapRect, 5.0, 5.0); + + painter.setPen(foreground); + painter.setFont(font); + painter.drawText(textRect, Qt::AlignHCenter | Qt::TextWordWrap, emptyString); +} diff --git a/launcher/ui/widgets/VersionListView.h b/launcher/ui/widgets/VersionListView.h new file mode 100644 index 0000000..07792db --- /dev/null +++ b/launcher/ui/widgets/VersionListView.h @@ -0,0 +1,49 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once +#include + +class VersionListView : public QTreeView { + Q_OBJECT + public: + explicit VersionListView(QWidget* parent = 0); + virtual void paintEvent(QPaintEvent* event) override; + virtual void setModel(QAbstractItemModel* model) override; + + enum EmptyMode { Empty, String, ErrorString }; + + void setEmptyString(QString emptyString); + void setEmptyErrorString(QString emptyErrorString); + void setEmptyMode(EmptyMode mode); + + public slots: + virtual void reset() override; + + protected slots: + virtual void rowsAboutToBeRemoved(const QModelIndex& parent, int start, int end) override; + virtual void rowsInserted(const QModelIndex& parent, int start, int end) override; + + private: /* methods */ + void paintInfoLabel(QPaintEvent* event) const; + void updateEmptyViewPort(); + QString currentEmptyString() const; + + private: /* variables */ + int m_itemCount = 0; + QString m_emptyString; + QString m_emptyErrorString; + EmptyMode m_emptyMode = Empty; +}; diff --git a/launcher/ui/widgets/VersionSelectWidget.cpp b/launcher/ui/widgets/VersionSelectWidget.cpp new file mode 100644 index 0000000..040355f --- /dev/null +++ b/launcher/ui/widgets/VersionSelectWidget.cpp @@ -0,0 +1,243 @@ +#include "VersionSelectWidget.h" + +#include +#include +#include +#include +#include +#include + +#include "VersionProxyModel.h" + +#include "ui/dialogs/CustomMessageBox.h" + +VersionSelectWidget::VersionSelectWidget(QWidget* parent) : QWidget(parent) +{ + setObjectName(QStringLiteral("VersionSelectWidget")); + verticalLayout = new QVBoxLayout(this); + verticalLayout->setObjectName(QStringLiteral("verticalLayout")); + verticalLayout->setContentsMargins(0, 0, 0, 0); + + m_proxyModel = new VersionProxyModel(this); + + listView = new VersionListView(this); + listView->setObjectName(QStringLiteral("listView")); + listView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + listView->setAlternatingRowColors(true); + listView->setRootIsDecorated(false); + listView->setItemsExpandable(false); + listView->setWordWrap(true); + listView->header()->setCascadingSectionResizes(true); + listView->header()->setStretchLastSection(false); + listView->setModel(m_proxyModel); + verticalLayout->addWidget(listView); + + search = new QLineEdit(this); + search->setPlaceholderText(tr("Search")); + search->setClearButtonEnabled(true); + verticalLayout->addWidget(search); + connect(search, &QLineEdit::textEdited, [this](const QString& value) { + m_proxyModel->setSearch(value); + if (!value.isEmpty() || !listView->selectionModel()->hasSelection()) { + const QModelIndex first = listView->model()->index(0, 0); + listView->selectionModel()->setCurrentIndex(first, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows); + listView->scrollToTop(); + } else + listView->scrollTo(listView->selectionModel()->currentIndex(), QAbstractItemView::PositionAtCenter); + }); + search->installEventFilter(this); + + sneakyProgressBar = new QProgressBar(this); + sneakyProgressBar->setObjectName(QStringLiteral("sneakyProgressBar")); + sneakyProgressBar->setFormat(QStringLiteral("%p%")); + verticalLayout->addWidget(sneakyProgressBar); + sneakyProgressBar->setHidden(true); + connect(listView->selectionModel(), &QItemSelectionModel::currentRowChanged, this, &VersionSelectWidget::currentRowChanged); + + QMetaObject::connectSlotsByName(this); +} + +void VersionSelectWidget::setCurrentVersion(const QString& version) +{ + m_currentVersion = version; + m_proxyModel->setCurrentVersion(version); +} + +void VersionSelectWidget::setEmptyString(QString emptyString) +{ + listView->setEmptyString(emptyString); +} + +void VersionSelectWidget::setEmptyErrorString(QString emptyErrorString) +{ + listView->setEmptyErrorString(emptyErrorString); +} + +void VersionSelectWidget::setEmptyMode(VersionListView::EmptyMode mode) +{ + listView->setEmptyMode(mode); +} + +VersionSelectWidget::~VersionSelectWidget() {} + +void VersionSelectWidget::setResizeOn(int column) +{ + listView->header()->setSectionResizeMode(resizeOnColumn, QHeaderView::ResizeToContents); + resizeOnColumn = column; + listView->header()->setSectionResizeMode(resizeOnColumn, QHeaderView::Stretch); +} + +bool VersionSelectWidget::eventFilter(QObject* watched, QEvent* event) +{ + if (watched == search && event->type() == QEvent::KeyPress) { + const QKeyEvent* keyEvent = (QKeyEvent*)event; + const bool up = keyEvent->key() == Qt::Key_Up; + const bool down = keyEvent->key() == Qt::Key_Down; + if (up || down) { + const QModelIndex index = listView->model()->index(listView->currentIndex().row() + (up ? -1 : 1), 0); + if (index.row() >= 0 && index.row() < listView->model()->rowCount()) { + listView->selectionModel()->setCurrentIndex(index, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows); + return true; + } + } + } + + return QObject::eventFilter(watched, event); +} + +void VersionSelectWidget::initialize(BaseVersionList* vlist, bool forceLoad) +{ + m_vlist = vlist; + m_proxyModel->setSourceModel(vlist); + listView->header()->setSectionResizeMode(QHeaderView::ResizeToContents); + listView->header()->setSectionResizeMode(resizeOnColumn, QHeaderView::Stretch); + + if (!m_vlist->isLoaded() || forceLoad) { + loadList(); + } else { + if (m_proxyModel->rowCount() == 0) { + listView->setEmptyMode(VersionListView::String); + } + preselect(); + } +} + +void VersionSelectWidget::closeEvent(QCloseEvent* event) +{ + QWidget::closeEvent(event); +} + +void VersionSelectWidget::loadList() +{ + m_load_task = m_vlist->getLoadTask(); + connect(m_load_task.get(), &Task::succeeded, this, &VersionSelectWidget::onTaskSucceeded); + connect(m_load_task.get(), &Task::failed, this, &VersionSelectWidget::onTaskFailed); + connect(m_load_task.get(), &Task::progress, this, &VersionSelectWidget::changeProgress); + if (!m_load_task->isRunning()) { + m_load_task->start(); + } + sneakyProgressBar->setHidden(false); +} + +void VersionSelectWidget::onTaskSucceeded() +{ + if (m_proxyModel->rowCount() == 0) { + listView->setEmptyMode(VersionListView::String); + } + sneakyProgressBar->setHidden(true); + preselect(); + m_load_task.reset(); +} + +void VersionSelectWidget::onTaskFailed(const QString& reason) +{ + CustomMessageBox::selectable(this, tr("Error"), tr("List update failed:\n%1").arg(reason), QMessageBox::Warning)->show(); + onTaskSucceeded(); +} + +void VersionSelectWidget::changeProgress(qint64 current, qint64 total) +{ + sneakyProgressBar->setMaximum(total); + sneakyProgressBar->setValue(current); +} + +void VersionSelectWidget::currentRowChanged(const QModelIndex& current, const QModelIndex&) +{ + auto variant = m_proxyModel->data(current, BaseVersionList::VersionPointerRole); + emit selectedVersionChanged(variant.value()); +} + +void VersionSelectWidget::preselect() +{ + if (preselectedAlready) + return; + selectCurrent(); + if (preselectedAlready) + return; + selectRecommended(); +} + +void VersionSelectWidget::selectCurrent() +{ + if (m_currentVersion.isEmpty()) { + return; + } + auto idx = m_proxyModel->getVersion(m_currentVersion); + if (idx.isValid()) { + preselectedAlready = true; + listView->selectionModel()->setCurrentIndex(idx, QItemSelectionModel::SelectCurrent | QItemSelectionModel::Rows); + listView->scrollTo(idx, QAbstractItemView::PositionAtCenter); + } +} + +void VersionSelectWidget::selectSearch() +{ + search->setFocus(); +} + +VersionListView* VersionSelectWidget::view() +{ + return listView; +} + +void VersionSelectWidget::selectRecommended() +{ + auto idx = m_proxyModel->getRecommended(); + if (idx.isValid()) { + preselectedAlready = true; + listView->selectionModel()->setCurrentIndex(idx, QItemSelectionModel::SelectCurrent | QItemSelectionModel::Rows); + listView->scrollTo(idx, QAbstractItemView::PositionAtCenter); + } +} + +bool VersionSelectWidget::hasVersions() const +{ + return m_proxyModel->rowCount(QModelIndex()) != 0; +} + +BaseVersion::Ptr VersionSelectWidget::selectedVersion() const +{ + auto currentIndex = listView->selectionModel()->currentIndex(); + auto variant = m_proxyModel->data(currentIndex, BaseVersionList::VersionPointerRole); + return variant.value(); +} + +void VersionSelectWidget::setFuzzyFilter(BaseVersionList::ModelRoles role, QString filter) +{ + m_proxyModel->setFilter(role, Filters::contains(filter)); +} + +void VersionSelectWidget::setExactFilter(BaseVersionList::ModelRoles role, QString filter) +{ + m_proxyModel->setFilter(role, Filters::equals(filter)); +} + +void VersionSelectWidget::setExactIfPresentFilter(BaseVersionList::ModelRoles role, QString filter) +{ + m_proxyModel->setFilter(role, Filters::equalsOrEmpty(filter)); +} + +void VersionSelectWidget::setFilter(BaseVersionList::ModelRoles role, Filter filter) +{ + m_proxyModel->setFilter(role, filter); +} diff --git a/launcher/ui/widgets/VersionSelectWidget.h b/launcher/ui/widgets/VersionSelectWidget.h new file mode 100644 index 0000000..c66d7e9 --- /dev/null +++ b/launcher/ui/widgets/VersionSelectWidget.h @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include "BaseVersionList.h" +#include "Filter.h" +#include "VersionListView.h" + +class VersionProxyModel; +class VersionListView; +class QVBoxLayout; +class QProgressBar; + +class VersionSelectWidget : public QWidget { + Q_OBJECT + public: + explicit VersionSelectWidget(QWidget* parent); + ~VersionSelectWidget(); + + //! loads the list if needed. + void initialize(BaseVersionList* vlist, bool forceLoad = false); + + //! Starts a task that loads the list. + void loadList(); + + bool hasVersions() const; + BaseVersion::Ptr selectedVersion() const; + void selectRecommended(); + void selectCurrent(); + void selectSearch(); + VersionListView* view(); + + void setCurrentVersion(const QString& version); + void setFuzzyFilter(BaseVersionList::ModelRoles role, QString filter); + void setExactFilter(BaseVersionList::ModelRoles role, QString filter); + void setExactIfPresentFilter(BaseVersionList::ModelRoles role, QString filter); + void setFilter(BaseVersionList::ModelRoles role, Filter filter); + void setEmptyString(QString emptyString); + void setEmptyErrorString(QString emptyErrorString); + void setEmptyMode(VersionListView::EmptyMode mode); + void setResizeOn(int column); + + bool eventFilter(QObject* watched, QEvent* event) override; + + signals: + void selectedVersionChanged(BaseVersion::Ptr version); + + protected: + virtual void closeEvent(QCloseEvent*) override; + + private slots: + void onTaskSucceeded(); + void onTaskFailed(const QString& reason); + void changeProgress(qint64 current, qint64 total); + void currentRowChanged(const QModelIndex& current, const QModelIndex&); + + private: + void preselect(); + + private: + QString m_currentVersion; + BaseVersionList* m_vlist = nullptr; + VersionProxyModel* m_proxyModel = nullptr; + int resizeOnColumn = 0; + Task::Ptr m_load_task; + bool preselectedAlready = false; + + QVBoxLayout* verticalLayout = nullptr; + VersionListView* listView = nullptr; + QLineEdit* search; + QProgressBar* sneakyProgressBar = nullptr; +}; diff --git a/launcher/ui/widgets/WideBar.cpp b/launcher/ui/widgets/WideBar.cpp new file mode 100644 index 0000000..e87c8b4 --- /dev/null +++ b/launcher/ui/widgets/WideBar.cpp @@ -0,0 +1,323 @@ +#include "WideBar.h" + +#include +#include +#include + +class ActionButton : public QToolButton { + Q_OBJECT + public: + ActionButton(QAction* action, QWidget* parent = nullptr, bool use_default_action = false) + : QToolButton(parent), m_action(action), m_use_default_action(use_default_action) + { + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); + setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + // workaround for breeze and breeze forks + setProperty("_kde_toolButton_alignment", Qt::AlignLeft); + + if (m_use_default_action) { + setDefaultAction(action); + } else { + connect(this, &ActionButton::clicked, action, &QAction::trigger); + } + connect(action, &QAction::changed, this, &ActionButton::actionChanged); + + actionChanged(); + }; + public slots: + void actionChanged() + { + setEnabled(m_action->isEnabled()); + // better pop up mode + if (m_action->menu()) { + setPopupMode(QToolButton::MenuButtonPopup); + } + if (!m_use_default_action) { + setChecked(m_action->isChecked()); + setCheckable(m_action->isCheckable()); + setText(m_action->text()); + setIcon(m_action->icon()); + setToolTip(m_action->toolTip()); + setHidden(!m_action->isVisible()); + } + setFocusPolicy(Qt::NoFocus); + } + + private: + QAction* m_action; + bool m_use_default_action; +}; + +WideBar::WideBar(const QString& title, QWidget* parent) : QToolBar(title, parent) +{ + setFloatable(false); + setMovable(false); + + setContextMenuPolicy(Qt::ContextMenuPolicy::CustomContextMenu); + connect(this, &QToolBar::customContextMenuRequested, this, &WideBar::showVisibilityMenu); +} + +WideBar::WideBar(QWidget* parent) : QToolBar(parent) +{ + setFloatable(false); + setMovable(false); + + setContextMenuPolicy(Qt::ContextMenuPolicy::CustomContextMenu); + connect(this, &QToolBar::customContextMenuRequested, this, &WideBar::showVisibilityMenu); +} + +void WideBar::addAction(QAction* action) +{ + BarEntry entry; + entry.bar_action = addWidget(new ActionButton(action, this, m_use_default_action)); + entry.menu_action = action; + entry.type = BarEntry::Type::Action; + + m_entries.push_back(entry); + + m_menu_state = MenuState::Dirty; +} + +void WideBar::addSeparator() +{ + BarEntry entry; + entry.bar_action = QToolBar::addSeparator(); + entry.type = BarEntry::Type::Separator; + + m_entries.push_back(entry); +} + +auto WideBar::getMatching(QAction* act) -> QList::iterator +{ + auto iter = std::find_if(m_entries.begin(), m_entries.end(), [act](BarEntry const& entry) { return entry.menu_action == act; }); + + return iter; +} + +void WideBar::insertActionBefore(QAction* before, QAction* action) +{ + auto iter = getMatching(before); + if (iter == m_entries.end()) + return; + + BarEntry entry; + entry.bar_action = insertWidget(iter->bar_action, new ActionButton(action, this, m_use_default_action)); + entry.menu_action = action; + entry.type = BarEntry::Type::Action; + + m_entries.insert(iter, entry); + + m_menu_state = MenuState::Dirty; +} + +void WideBar::insertActionAfter(QAction* after, QAction* action) +{ + auto iter = getMatching(after); + if (iter == m_entries.end()) + return; + + iter++; + // the action to insert after is present + // however, the element after it isn't valid + if (iter == m_entries.end()) { + // append the action instead of inserting it + addAction(action); + return; + } + + BarEntry entry; + entry.bar_action = insertWidget(iter->bar_action, new ActionButton(action, this, m_use_default_action)); + entry.menu_action = action; + entry.type = BarEntry::Type::Action; + + m_entries.insert(iter, entry); + + m_menu_state = MenuState::Dirty; +} + +void WideBar::insertWidgetBefore(QAction* before, QWidget* widget) +{ + auto iter = getMatching(before); + if (iter == m_entries.end()) + return; + + insertWidget(iter->bar_action, widget); +} + +void WideBar::insertSpacer(QAction* action) +{ + auto iter = getMatching(action); + if (iter == m_entries.end()) + return; + + auto* spacer = new QWidget(); + spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + + BarEntry entry; + entry.bar_action = insertWidget(iter->bar_action, spacer); + entry.type = BarEntry::Type::Spacer; + m_entries.insert(iter, entry); +} + +void WideBar::insertSeparator(QAction* before) +{ + auto iter = getMatching(before); + if (iter == m_entries.end()) + return; + + BarEntry entry; + entry.bar_action = QToolBar::insertSeparator(iter->bar_action); + entry.type = BarEntry::Type::Separator; + + m_entries.insert(iter, entry); +} + +QMenu* WideBar::createContextMenu(QWidget* parent, const QString& title) +{ + auto* contextMenu = new QMenu(title, parent); + for (auto& item : m_entries) { + switch (item.type) { + default: + case BarEntry::Type::None: + break; + case BarEntry::Type::Separator: + case BarEntry::Type::Spacer: + contextMenu->addSeparator(); + break; + case BarEntry::Type::Action: + contextMenu->addAction(item.menu_action); + break; + } + } + return contextMenu; +} + +static void copyAction(QAction* from, QAction* to) +{ + Q_ASSERT(from); + Q_ASSERT(to); + + to->setText(from->text()); + to->setIcon(from->icon()); + to->setToolTip(from->toolTip()); +} + +void WideBar::showVisibilityMenu(QPoint const& position) +{ + if (!m_bar_menu) { + m_bar_menu = std::make_unique(this); + m_bar_menu->setTearOffEnabled(true); + } + + if (m_menu_state == MenuState::Dirty) { + for (auto* old_action : m_bar_menu->actions()) + old_action->deleteLater(); + + m_bar_menu->clear(); + + m_bar_menu->addActions(m_context_menu_actions); + + m_bar_menu->addSeparator()->setText(tr("Customize toolbar actions")); + + for (auto& entry : m_entries) { + if (entry.type != BarEntry::Type::Action) + continue; + + auto act = new QAction(); + copyAction(entry.menu_action, act); + + act->setCheckable(true); + act->setChecked(entry.bar_action->isVisible()); + + connect(act, &QAction::toggled, entry.bar_action, [this, &entry](bool toggled) { + entry.bar_action->setVisible(toggled); + + // NOTE: This is needed so that disabled actions get reflected on the button when it is made visible. + static_cast(widgetForAction(entry.bar_action))->actionChanged(); + }); + + m_bar_menu->addAction(act); + } + + m_menu_state = MenuState::Fresh; + } + + m_bar_menu->popup(mapToGlobal(position)); +} + +void WideBar::addContextMenuAction(QAction* action) +{ + m_context_menu_actions.append(action); +} + +QByteArray WideBar::getVisibilityState() const +{ + QByteArray state; + + for (auto const& entry : m_entries) { + if (entry.type != BarEntry::Type::Action) + continue; + + state.append(entry.bar_action->isVisible() ? '1' : '0'); + } + + state.append(','); + state.append(getHash()); + + return state; +} + +void WideBar::setVisibilityState(QByteArray&& state) +{ + auto split = state.split(','); + + auto bits = split.first(); + auto hash = split.last(); + + // If the actions changed, we better not try to load the old one to avoid unwanted hiding + if (!checkHash(hash)) + return; + + qsizetype i = 0; + for (auto& entry : m_entries) { + if (entry.type != BarEntry::Type::Action) + continue; + if (i == bits.size()) + break; + + entry.bar_action->setVisible(bits.at(i++) == '1'); + + // NOTE: This is needed so that disabled actions get reflected on the button when it is made visible. + static_cast(widgetForAction(entry.bar_action))->actionChanged(); + } +} + +QByteArray WideBar::getHash() const +{ + QCryptographicHash hash(QCryptographicHash::Sha1); + for (auto const& entry : m_entries) { + if (entry.type != BarEntry::Type::Action) + continue; + hash.addData(entry.menu_action->text().toLatin1()); + } + + return hash.result().toBase64(); +} + +bool WideBar::checkHash(QByteArray const& old_hash) const +{ + return old_hash == getHash(); +} + +void WideBar::removeAction(QAction* action) +{ + auto iter = getMatching(action); + if (iter == m_entries.end()) + return; + + iter->bar_action->setVisible(false); + removeAction(iter->bar_action); + m_entries.erase(iter); +} + +#include "WideBar.moc" diff --git a/launcher/ui/widgets/WideBar.h b/launcher/ui/widgets/WideBar.h new file mode 100644 index 0000000..68a052a --- /dev/null +++ b/launcher/ui/widgets/WideBar.h @@ -0,0 +1,66 @@ +#pragma once + +#include +#include +#include +#include + +#include + +class WideBar : public QToolBar { + Q_OBJECT + // Why: so we can enable / disable alt shortcuts in toolbuttons + // with toolbuttons using setDefaultAction, theres no alt shortcuts + Q_PROPERTY(bool useDefaultAction MEMBER m_use_default_action) + + public: + explicit WideBar(const QString& title, QWidget* parent = nullptr); + explicit WideBar(QWidget* parent = nullptr); + ~WideBar() override = default; + + void addAction(QAction* action); + void addSeparator(); + + void insertSpacer(QAction* action); + void insertSeparator(QAction* before); + void insertActionBefore(QAction* before, QAction* action); + void insertActionAfter(QAction* after, QAction* action); + void insertWidgetBefore(QAction* before, QWidget* widget); + + QMenu* createContextMenu(QWidget* parent = nullptr, const QString& title = QString()); + void showVisibilityMenu(const QPoint&); + + void addContextMenuAction(QAction* action); + + // Ideally we would use a QBitArray for this, but it doesn't support string conversion, + // so using it in settings is very messy. + + QByteArray getVisibilityState() const; + void setVisibilityState(QByteArray&&); + + void removeAction(QAction* action); + + private: + struct BarEntry { + enum class Type { None, Action, Separator, Spacer } type = Type::None; + QAction* bar_action = nullptr; + QAction* menu_action = nullptr; + }; + + auto getMatching(QAction* act) -> QList::iterator; + + /** Used to distinguish between versions of the WideBar with different actions */ + QByteArray getHash() const; + bool checkHash(QByteArray const&) const; + + private: + QList m_entries; + + QList m_context_menu_actions; + + bool m_use_default_action = false; + + // Menu to toggle visibility from buttons in the bar + std::unique_ptr m_bar_menu = nullptr; + enum class MenuState { Fresh, Dirty } m_menu_state = MenuState::Dirty; +}; diff --git a/launcher/updater/ExternalUpdater.h b/launcher/updater/ExternalUpdater.h new file mode 100644 index 0000000..daa5b10 --- /dev/null +++ b/launcher/updater/ExternalUpdater.h @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Kenneth Chew + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef LAUNCHER_EXTERNALUPDATER_H +#define LAUNCHER_EXTERNALUPDATER_H + +#include + +/*! + * A base class for an updater that uses an external library. + * This class contains basic functions to control the updater. + * + * To implement the updater on a new platform, create a new class that inherits from this class and + * implement the pure virtual functions. + * + * The initializer of the new class should have the side effect of starting the automatic updater. That is, + * once the class is initialized, the program should automatically check for updates if necessary. + */ +class ExternalUpdater : public QObject { + Q_OBJECT + + public: + /*! + * Check for updates manually, showing the user a progress bar and an alert if no updates are found. + */ + virtual void checkForUpdates() = 0; + + /*! + * Indicates whether or not to check for updates automatically. + */ + virtual bool getAutomaticallyChecksForUpdates() = 0; + + /*! + * Indicates the current automatic update check interval in seconds. + */ + virtual double getUpdateCheckInterval() = 0; + + /*! + * Indicates whether or not beta updates should be checked for in addition to regular releases. + */ + virtual bool getBetaAllowed() = 0; + + /*! + * Set whether or not to check for updates automatically. + */ + virtual void setAutomaticallyChecksForUpdates(bool check) = 0; + + /*! + * Set the current automatic update check interval in seconds. + */ + virtual void setUpdateCheckInterval(double seconds) = 0; + + /*! + * Set whether or not beta updates should be checked for in addition to regular releases. + */ + virtual void setBetaAllowed(bool allowed) = 0; + + signals: + /*! + * Emits whenever the user's ability to check for updates changes. + * + * As per Sparkle documentation, "An update check can be made by the user when an update session isn’t in progress, + * or when an update or its progress is being shown to the user. A user cannot check for updates when data (such + * as the feed or an update) is still being downloaded automatically in the background. + * + * This property is suitable to use for menu item validation for seeing if checkForUpdates can be invoked." + */ + void canCheckForUpdatesChanged(bool canCheck); +}; + +#endif // LAUNCHER_EXTERNALUPDATER_H diff --git a/launcher/updater/MacSparkleUpdater.h b/launcher/updater/MacSparkleUpdater.h new file mode 100644 index 0000000..5f9b1a2 --- /dev/null +++ b/launcher/updater/MacSparkleUpdater.h @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Kenneth Chew + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef LAUNCHER_MACSPARKLEUPDATER_H +#define LAUNCHER_MACSPARKLEUPDATER_H + +#include +#include +#include "ExternalUpdater.h" + +/*! + * An implementation for the updater on macOS that uses the Sparkle framework. + */ +class MacSparkleUpdater : public ExternalUpdater { + Q_OBJECT + + public: + /*! + * Start the Sparkle updater, which automatically checks for updates if necessary. + */ + MacSparkleUpdater(); + ~MacSparkleUpdater() override; + + /*! + * Check for updates manually, showing the user a progress bar and an alert if no updates are found. + */ + void checkForUpdates() override; + + /*! + * Indicates whether or not to check for updates automatically. + */ + bool getAutomaticallyChecksForUpdates() override; + + /*! + * Indicates the current automatic update check interval in seconds. + */ + double getUpdateCheckInterval() override; + + /*! + * Indicates the set of Sparkle channels the updater is allowed to find new updates from. + */ + QSet getAllowedChannels(); + + /*! + * Indicates whether or not beta updates should be checked for in addition to regular releases. + */ + bool getBetaAllowed() override; + + /*! + * Set whether or not to check for updates automatically. + * + * As per Sparkle documentation, "By default, Sparkle asks users on second launch for permission if they want + * automatic update checks enabled and sets this property based on their response. If SUEnableAutomaticChecks is + * set in the Info.plist, this permission request is not performed however. + * + * Setting this property will persist in the host bundle’s user defaults. Only set this property if you need + * dynamic behavior (e.g. user preferences). + * + * The update schedule cycle will be reset in a short delay after the property’s new value is set. This is to allow + * reverting this property without kicking off a schedule change immediately." + */ + void setAutomaticallyChecksForUpdates(bool check) override; + + /*! + * Set the current automatic update check interval in seconds. + * + * As per Sparkle documentation, "Setting this property will persist in the host bundle’s user defaults. For this + * reason, only set this property if you need dynamic behavior (eg user preferences). Otherwise prefer to set + * SUScheduledCheckInterval directly in your Info.plist. + * + * The update schedule cycle will be reset in a short delay after the property’s new value is set. This is to allow + * reverting this property without kicking off a schedule change immediately." + */ + void setUpdateCheckInterval(double seconds) override; + + /*! + * Clears all allowed Sparkle channels, returning to the default updater channel behavior. + */ + void clearAllowedChannels(); + + /*! + * Set a single Sparkle channel the updater is allowed to find new updates from. + * + * Items in the default channel can always be found, regardless of this setting. If an empty string is passed, + * return to the default behavior. + */ + void setAllowedChannel(const QString& channel); + + /*! + * Set a set of Sparkle channels the updater is allowed to find new updates from. + * + * Items in the default channel can always be found, regardless of this setting. If an empty set is passed, + * return to the default behavior. + */ + void setAllowedChannels(const QSet& channels); + + /*! + * Set whether or not beta updates should be checked for in addition to regular releases. + */ + void setBetaAllowed(bool allowed) override; + + private: + class Private; + + Private* priv; +}; + +#endif // LAUNCHER_MACSPARKLEUPDATER_H diff --git a/launcher/updater/MacSparkleUpdater.mm b/launcher/updater/MacSparkleUpdater.mm new file mode 100644 index 0000000..c54708e --- /dev/null +++ b/launcher/updater/MacSparkleUpdater.mm @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Kenneth Chew + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "MacSparkleUpdater.h" + +#include "Application.h" + +#include +#include + +@interface UpdaterObserver : NSObject + +@property(nonatomic, readonly) SPUUpdater* updater; + +/// A callback to run when the state of `canCheckForUpdates` for the `updater` changes. +@property(nonatomic, copy) void (^callback)(bool); + +- (id)initWithUpdater:(SPUUpdater*)updater; + +@end + +@implementation UpdaterObserver + +- (id)initWithUpdater:(SPUUpdater*)updater { + self = [super init]; + _updater = updater; + [self addObserver:self forKeyPath:@"updater.canCheckForUpdates" options:NSKeyValueObservingOptionNew context:nil]; + + return self; +} + +- (void)observeValueForKeyPath:(NSString*)keyPath + ofObject:(id)object + change:(NSDictionary*)change + context:(void*)context { + if ([keyPath isEqualToString:@"updater.canCheckForUpdates"]) { + bool canCheck = [change[NSKeyValueChangeNewKey] boolValue]; + self.callback(canCheck); + } +} + +@end + +@interface UpdaterDelegate : NSObject + +@property(nonatomic, copy) NSSet* allowedChannels; + +@end + +@implementation UpdaterDelegate + +- (NSSet*)allowedChannelsForUpdater:(SPUUpdater*)updater { + return _allowedChannels; +} + +@end + +class MacSparkleUpdater::Private { + public: + SPUStandardUpdaterController* updaterController; + UpdaterObserver* updaterObserver; + UpdaterDelegate* updaterDelegate; + NSAutoreleasePool* autoReleasePool; +}; + +MacSparkleUpdater::MacSparkleUpdater() { + priv = new MacSparkleUpdater::Private(); + + // Enable Cocoa's memory management. + NSApplicationLoad(); + priv->autoReleasePool = [[NSAutoreleasePool alloc] init]; + + // Delegate is used for setting/getting allowed update channels. + priv->updaterDelegate = [[UpdaterDelegate alloc] init]; + + // Controller is the interface for actually doing the updates. + priv->updaterController = [[SPUStandardUpdaterController alloc] initWithStartingUpdater:true + updaterDelegate:priv->updaterDelegate + userDriverDelegate:nil]; + + priv->updaterObserver = [[UpdaterObserver alloc] initWithUpdater:priv->updaterController.updater]; + // Use KVO to run a callback that emits a Qt signal when `canCheckForUpdates` changes, so the UI can respond accordingly. + priv->updaterObserver.callback = ^(bool canCheck) { + emit canCheckForUpdatesChanged(canCheck); + }; +} + +MacSparkleUpdater::~MacSparkleUpdater() { + [priv->updaterObserver removeObserver:priv->updaterObserver forKeyPath:@"updater.canCheckForUpdates"]; + + [priv->updaterController release]; + [priv->updaterObserver release]; + [priv->updaterDelegate release]; + [priv->autoReleasePool release]; + delete priv; +} + +void MacSparkleUpdater::checkForUpdates() { + [priv->updaterController checkForUpdates:nil]; +} + +bool MacSparkleUpdater::getAutomaticallyChecksForUpdates() { + return priv->updaterController.updater.automaticallyChecksForUpdates; +} + +double MacSparkleUpdater::getUpdateCheckInterval() { + return priv->updaterController.updater.updateCheckInterval; +} + +QSet MacSparkleUpdater::getAllowedChannels() { + // Convert NSSet -> QSet + __block QSet channels; + [priv->updaterDelegate.allowedChannels enumerateObjectsUsingBlock:^(NSString* channel, BOOL* stop) { + channels.insert(QString::fromNSString(channel)); + }]; + return channels; +} + +bool MacSparkleUpdater::getBetaAllowed() { + return getAllowedChannels().contains("beta"); +} + +void MacSparkleUpdater::setAutomaticallyChecksForUpdates(bool check) { + priv->updaterController.updater.automaticallyChecksForUpdates = check ? YES : NO; // make clang-tidy happy +} + +void MacSparkleUpdater::setUpdateCheckInterval(double seconds) { + priv->updaterController.updater.updateCheckInterval = seconds; +} + +void MacSparkleUpdater::clearAllowedChannels() { + priv->updaterDelegate.allowedChannels = [NSSet set]; +} + +void MacSparkleUpdater::setAllowedChannel(const QString& channel) { + if (channel.isEmpty()) { + clearAllowedChannels(); + return; + } + + NSSet* nsChannels = [NSSet setWithObject:channel.toNSString()]; + priv->updaterDelegate.allowedChannels = nsChannels; +} + +void MacSparkleUpdater::setAllowedChannels(const QSet& channels) { + if (channels.isEmpty()) { + clearAllowedChannels(); + return; + } + + QString channelsConfig = ""; + // Convert QSet -> NSSet + NSMutableSet* nsChannels = [NSMutableSet setWithCapacity:channels.count()]; + for (const QString& channel : channels) { + [nsChannels addObject:channel.toNSString()]; + channelsConfig += channel + " "; + } + + priv->updaterDelegate.allowedChannels = nsChannels; +} + +void MacSparkleUpdater::setBetaAllowed(bool allowed) { + if (allowed) { + setAllowedChannel("beta"); + } else { + clearAllowedChannels(); + } +} diff --git a/launcher/updater/PrismExternalUpdater.cpp b/launcher/updater/PrismExternalUpdater.cpp new file mode 100644 index 0000000..a4e34e1 --- /dev/null +++ b/launcher/updater/PrismExternalUpdater.cpp @@ -0,0 +1,375 @@ +// SPDX-FileCopyrightText: 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> +// +// SPDX-License-Identifier: GPL-3.0-only + +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "PrismExternalUpdater.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "StringUtils.h" + +#include "BuildConfig.h" + +#include "ui/dialogs/UpdateAvailableDialog.h" + +class PrismExternalUpdater::Private { + public: + QDir appDir; + QDir dataDir; + QTimer updateTimer; + bool allowBeta{}; + bool autoCheck{}; + double updateInterval{}; + QDateTime lastCheck; + std::unique_ptr settings; + + QWidget* parent{}; +}; + +PrismExternalUpdater::PrismExternalUpdater(QWidget* parent, const QString& appDir, const QString& dataDir) + : priv(new PrismExternalUpdater::Private()) +{ + priv->appDir = QDir(appDir); + priv->dataDir = QDir(dataDir); + auto settings_file = priv->dataDir.absoluteFilePath("prismlauncher_update.cfg"); + priv->settings = std::make_unique(settings_file, QSettings::Format::IniFormat); + priv->allowBeta = priv->settings->value("allow_beta", false).toBool(); + priv->autoCheck = priv->settings->value("auto_check", true).toBool(); + bool interval_ok = false; + // default once per day + priv->updateInterval = priv->settings->value("update_interval", 86400).toInt(&interval_ok); + if (!interval_ok) { + priv->updateInterval = 86400; + } + auto last_check = priv->settings->value("last_check"); + if (!last_check.isNull() && last_check.isValid()) { + priv->lastCheck = QDateTime::fromString(last_check.toString(), Qt::ISODate); + } + priv->parent = parent; + connectTimer(); + resetAutoCheckTimer(); + if (priv->updateInterval == 0) { // "On Launch" + checkForUpdates(false); + } +} + +PrismExternalUpdater::~PrismExternalUpdater() +{ + if (priv->updateTimer.isActive()) { + priv->updateTimer.stop(); + } + disconnectTimer(); + priv->settings->sync(); + delete priv; +} + +void PrismExternalUpdater::checkForUpdates() +{ + checkForUpdates(true); +} + +void PrismExternalUpdater::checkForUpdates(bool triggeredByUser) +{ + QProgressDialog progress(tr("Checking for updates..."), "", 0, 0, priv->parent); + progress.setCancelButton(nullptr); + progress.adjustSize(); + if (triggeredByUser) { + progress.show(); + } + QCoreApplication::processEvents(); + + QProcess proc; + auto exe_name = QStringLiteral("%1_updater").arg(BuildConfig.LAUNCHER_APP_BINARY_NAME); +#if defined Q_OS_WIN32 + exe_name.append(".exe"); + + auto env = QProcessEnvironment::systemEnvironment(); + env.insert("__COMPAT_LAYER", "RUNASINVOKER"); + proc.setProcessEnvironment(env); +#else + exe_name = QString("bin/%1").arg(exe_name); +#endif + + QStringList args = { "--check-only", "--dir", priv->dataDir.absolutePath(), "--debug" }; + if (priv->allowBeta) { + args.append("--pre-release"); + } + + proc.start(priv->appDir.absoluteFilePath(exe_name), args); + auto result_start = proc.waitForStarted(5000); + if (!result_start) { + auto err = proc.error(); + qDebug() << "Failed to start updater after 5 seconds." + << "reason:" << err << proc.errorString(); + auto msgBox = + QMessageBox(QMessageBox::Information, tr("Update Check Failed"), + tr("Failed to start after 5 seconds\nReason: %1.").arg(proc.errorString()), QMessageBox::Ok, priv->parent); + msgBox.setMinimumWidth(460); + msgBox.adjustSize(); + msgBox.exec(); + priv->lastCheck = QDateTime::currentDateTime(); + priv->settings->setValue("last_check", priv->lastCheck.toString(Qt::ISODate)); + priv->settings->sync(); + resetAutoCheckTimer(); + return; + } + QCoreApplication::processEvents(); + + auto result_finished = proc.waitForFinished(60000); + if (!result_finished) { + proc.kill(); + auto err = proc.error(); + auto output = proc.readAll(); + qDebug() << "Updater failed to close after 60 seconds." + << "reason:" << err << proc.errorString(); + auto msgBox = + QMessageBox(QMessageBox::Information, tr("Update Check Failed"), + tr("Updater failed to close 60 seconds\nReason: %1.").arg(proc.errorString()), QMessageBox::Ok, priv->parent); + msgBox.setDetailedText(output); + msgBox.setMinimumWidth(460); + msgBox.adjustSize(); + msgBox.exec(); + priv->lastCheck = QDateTime::currentDateTime(); + priv->settings->setValue("last_check", priv->lastCheck.toString(Qt::ISODate)); + priv->settings->sync(); + resetAutoCheckTimer(); + return; + } + + auto exit_code = proc.exitCode(); + + auto std_output = proc.readAllStandardOutput(); + auto std_error = proc.readAllStandardError(); + + progress.hide(); + QCoreApplication::processEvents(); + + switch (exit_code) { + case 0: + // no update available + if (triggeredByUser) { + qDebug() << "No update available"; + auto msgBox = QMessageBox(QMessageBox::Information, tr("No Update Available"), tr("You are running the latest version."), + QMessageBox::Ok, priv->parent); + msgBox.setMinimumWidth(460); + msgBox.adjustSize(); + msgBox.exec(); + } + break; + case 1: + // there was an error + { + qDebug() << "Updater subprocess error" << qPrintable(std_error); + auto msgBox = QMessageBox(QMessageBox::Warning, tr("Update Check Error"), + tr("There was an error running the update check."), QMessageBox::Ok, priv->parent); + msgBox.setDetailedText(QString(std_error)); + msgBox.setMinimumWidth(460); + msgBox.adjustSize(); + msgBox.exec(); + } + break; + case 100: + // update available + { + auto [first_line, remainder1] = StringUtils::splitFirst(std_output, '\n'); + auto [second_line, remainder2] = StringUtils::splitFirst(remainder1, '\n'); + auto [third_line, release_notes] = StringUtils::splitFirst(remainder2, '\n'); + auto version_name = StringUtils::splitFirst(first_line, ": ").second.trimmed(); + auto version_tag = StringUtils::splitFirst(second_line, ": ").second.trimmed(); + auto release_timestamp = QDateTime::fromString(StringUtils::splitFirst(third_line, ": ").second.trimmed(), Qt::ISODate); + qDebug() << "Update available:" << version_name << version_tag << release_timestamp; + qDebug() << "Update release notes:" << release_notes; + + offerUpdate(version_name, version_tag, release_notes); + } + break; + default: + // unknown error code + { + qDebug() << "Updater exited with unknown code" << exit_code; + auto msgBox = + QMessageBox(QMessageBox::Information, tr("Unknown Update Error"), + tr("The updater exited with an unknown condition.\nExit Code: %1").arg(QString::number(exit_code)), + QMessageBox::Ok, priv->parent); + auto detail_txt = tr("StdOut: %1\nStdErr: %2").arg(QString(std_output)).arg(QString(std_error)); + msgBox.setDetailedText(detail_txt); + msgBox.setMinimumWidth(460); + msgBox.adjustSize(); + msgBox.exec(); + } + } + priv->lastCheck = QDateTime::currentDateTime(); + priv->settings->setValue("last_check", priv->lastCheck.toString(Qt::ISODate)); + priv->settings->sync(); + resetAutoCheckTimer(); +} + +bool PrismExternalUpdater::getAutomaticallyChecksForUpdates() +{ + return priv->autoCheck; +} + +double PrismExternalUpdater::getUpdateCheckInterval() +{ + return priv->updateInterval; +} + +bool PrismExternalUpdater::getBetaAllowed() +{ + return priv->allowBeta; +} + +void PrismExternalUpdater::setAutomaticallyChecksForUpdates(bool check) +{ + priv->autoCheck = check; + priv->settings->setValue("auto_check", check); + priv->settings->sync(); + resetAutoCheckTimer(); +} + +void PrismExternalUpdater::setUpdateCheckInterval(double seconds) +{ + priv->updateInterval = seconds; + priv->settings->setValue("update_interval", seconds); + priv->settings->sync(); + resetAutoCheckTimer(); +} + +void PrismExternalUpdater::setBetaAllowed(bool allowed) +{ + priv->allowBeta = allowed; + priv->settings->setValue("auto_beta", allowed); + priv->settings->sync(); +} + +void PrismExternalUpdater::resetAutoCheckTimer() +{ + if (priv->autoCheck && priv->updateInterval > 0) { + auto now = QDateTime::currentDateTime(); + + qint64 timeoutMs = 0; + + if (priv->lastCheck.isValid()) { + qint64 diff = priv->lastCheck.secsTo(now); + qint64 secs_left = std::max(priv->updateInterval - diff, 0); + timeoutMs = secs_left * 1000; + } + + timeoutMs = std::min(timeoutMs, static_cast(INT_MAX)); + + qDebug() << "Auto update timer starting," << timeoutMs / 1000 << "seconds left"; + priv->updateTimer.start(static_cast(timeoutMs)); + } else { + if (priv->updateTimer.isActive()) { + priv->updateTimer.stop(); + } + } +} + +void PrismExternalUpdater::connectTimer() +{ + connect(&priv->updateTimer, &QTimer::timeout, this, &PrismExternalUpdater::autoCheckTimerFired); +} + +void PrismExternalUpdater::disconnectTimer() +{ + disconnect(&priv->updateTimer, &QTimer::timeout, this, &PrismExternalUpdater::autoCheckTimerFired); +} + +void PrismExternalUpdater::autoCheckTimerFired() +{ + qDebug() << "Auto update Timer fired"; + checkForUpdates(false); +} + +void PrismExternalUpdater::offerUpdate(const QString& version_name, const QString& version_tag, const QString& release_notes) +{ + priv->settings->beginGroup("skip"); + auto should_skip = priv->settings->value(version_tag, false).toBool(); + priv->settings->endGroup(); + + if (should_skip) { + auto msgBox = QMessageBox(QMessageBox::Information, tr("No Update Available"), tr("There are no new updates available."), + QMessageBox::Ok, priv->parent); + msgBox.setMinimumWidth(460); + msgBox.adjustSize(); + msgBox.exec(); + return; + } + + UpdateAvailableDialog dlg(BuildConfig.printableVersionString(), version_name, release_notes); + + auto result = dlg.exec(); + qDebug() << "offer dlg result" << result; + switch (result) { + case UpdateAvailableDialog::Install: { + performUpdate(version_tag); + return; + } + case UpdateAvailableDialog::Skip: { + priv->settings->beginGroup("skip"); + priv->settings->setValue(version_tag, true); + priv->settings->endGroup(); + priv->settings->sync(); + return; + } + default: { + return; + } + } +} + +void PrismExternalUpdater::performUpdate(const QString& version_tag) +{ + QProcess proc; + auto exe_name = QStringLiteral("%1_updater").arg(BuildConfig.LAUNCHER_APP_BINARY_NAME); +#if defined Q_OS_WIN32 + exe_name.append(".exe"); + + auto env = QProcessEnvironment::systemEnvironment(); + env.insert("__COMPAT_LAYER", "RUNASINVOKER"); + proc.setProcessEnvironment(env); +#else + exe_name = QString("bin/%1").arg(exe_name); +#endif + + QStringList args = { "--dir", priv->dataDir.absolutePath(), "--install-version", version_tag }; + if (priv->allowBeta) { + args.append("--pre-release"); + } + + proc.setProgram(priv->appDir.absoluteFilePath(exe_name)); + proc.setArguments(args); + auto result = proc.startDetached(); + if (!result) { + qDebug() << "Failed to start updater:" << proc.error() << proc.errorString(); + } + QCoreApplication::exit(); +} diff --git a/launcher/updater/PrismExternalUpdater.h b/launcher/updater/PrismExternalUpdater.h new file mode 100644 index 0000000..b886760 --- /dev/null +++ b/launcher/updater/PrismExternalUpdater.h @@ -0,0 +1,96 @@ +// SPDX-FileCopyrightText: 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> +// +// SPDX-License-Identifier: GPL-3.0-only + +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#pragma once + +#include + +#include "ExternalUpdater.h" + +/*! + * An implementation for the updater on windows and linux that uses out external updater. + */ + +class PrismExternalUpdater : public ExternalUpdater { + Q_OBJECT + + public: + PrismExternalUpdater(QWidget* parent, const QString& appDir, const QString& dataDir); + ~PrismExternalUpdater() override; + + /*! + * Check for updates manually, showing the user a progress bar and an alert if no updates are found. + */ + void checkForUpdates() override; + void checkForUpdates(bool triggeredByUser); + + /*! + * Indicates whether or not to check for updates automatically. + */ + bool getAutomaticallyChecksForUpdates() override; + + /*! + * Indicates the current automatic update check interval in seconds. + */ + double getUpdateCheckInterval() override; + + /*! + * Indicates whether or not beta updates should be checked for in addition to regular releases. + */ + bool getBetaAllowed() override; + + /*! + * Set whether or not to check for updates automatically. + * + * The update schedule cycle will be reset in a short delay after the property’s new value is set. This is to allow + * reverting this property without kicking off a schedule change immediately." + */ + void setAutomaticallyChecksForUpdates(bool check) override; + + /*! + * Set the current automatic update check interval in seconds. + * + * The update schedule cycle will be reset in a short delay after the property’s new value is set. This is to allow + * reverting this property without kicking off a schedule change immediately." + */ + void setUpdateCheckInterval(double seconds) override; + + /*! + * Set whether or not beta updates should be checked for in addition to regular releases. + */ + void setBetaAllowed(bool allowed) override; + + void resetAutoCheckTimer(); + void disconnectTimer(); + void connectTimer(); + + void offerUpdate(const QString& version_name, const QString& version_tag, const QString& release_notes); + void performUpdate(const QString& version_tag); + + public slots: + void autoCheckTimerFired(); + + private: + class Private; + + Private* priv; +}; diff --git a/launcher/updater/prismupdater/GitHubRelease.cpp b/launcher/updater/prismupdater/GitHubRelease.cpp new file mode 100644 index 0000000..3beae31 --- /dev/null +++ b/launcher/updater/prismupdater/GitHubRelease.cpp @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> +// +// SPDX-License-Identifier: GPL-3.0-only + +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "GitHubRelease.h" + +QDebug operator<<(QDebug debug, const GitHubReleaseAsset& asset) +{ + QDebugStateSaver saver(debug); + debug.nospace() << "GitHubReleaseAsset( " + "id: " + << asset.id + << ", " + "name " + << asset.name + << ", " + "label: " + << asset.label + << ", " + "content_type: " + << asset.content_type + << ", " + "size: " + << asset.size + << ", " + "created_at: " + << asset.created_at + << ", " + "updated_at: " + << asset.updated_at + << ", " + "browser_download_url: " + << asset.browser_download_url + << " " + ")"; + return debug; +} + +QDebug operator<<(QDebug debug, const GitHubRelease& rls) +{ + QDebugStateSaver saver(debug); + debug.nospace() << "GitHubRelease( " + "id: " + << rls.id + << ", " + "name " + << rls.name + << ", " + "tag_name: " + << rls.tag_name + << ", " + "created_at: " + << rls.created_at + << ", " + "published_at: " + << rls.published_at + << ", " + "prerelease: " + << rls.prerelease + << ", " + "draft: " + << rls.draft + << ", " + "version" + << rls.version + << ", " + "body: " + << rls.body + << ", " + "assets: " + << rls.assets + << " " + ")"; + return debug; +} diff --git a/launcher/updater/prismupdater/GitHubRelease.h b/launcher/updater/prismupdater/GitHubRelease.h new file mode 100644 index 0000000..798c6b7 --- /dev/null +++ b/launcher/updater/prismupdater/GitHubRelease.h @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> +// +// SPDX-License-Identifier: GPL-3.0-only + +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#pragma once +#include +#include +#include + +#include + +#include "Version.h" + +struct GitHubReleaseAsset { + int id = -1; + QString name; + QString label; + QString content_type; + int size; + QDateTime created_at; + QDateTime updated_at; + QString browser_download_url; + + bool isValid() { return id > 0; } +}; + +struct GitHubRelease { + int id = -1; + QString name; + QString tag_name; + QDateTime created_at; + QDateTime published_at; + bool prerelease; + bool draft; + QString body; + QList assets; + Version version; + + bool isValid() const { return id > 0; } +}; + +QDebug operator<<(QDebug debug, const GitHubReleaseAsset& rls); +QDebug operator<<(QDebug debug, const GitHubRelease& rls); diff --git a/launcher/updater/prismupdater/PrismUpdater.cpp b/launcher/updater/prismupdater/PrismUpdater.cpp new file mode 100644 index 0000000..11e92ef --- /dev/null +++ b/launcher/updater/prismupdater/PrismUpdater.cpp @@ -0,0 +1,1243 @@ +// SPDX-FileCopyrightText: 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> +// +// SPDX-License-Identifier: GPL-3.0-only + +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "PrismUpdater.h" +#include "BuildConfig.h" +#include "ui/dialogs/ProgressDialog.h" + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +namespace fs = std::filesystem; + +#include "DesktopServices.h" + +#include "updater/prismupdater/UpdaterDialogs.h" + +#include "FileSystem.h" +#include "Json.h" +#include "StringUtils.h" + +#include "net/Download.h" +#include "net/RawHeaderProxy.h" + +#include "MMCZip.h" + +/** output to the log file */ +void appDebugOutput(QtMsgType type, const QMessageLogContext& context, const QString& msg) +{ + static std::mutex loggerMutex; + const std::lock_guard lock(loggerMutex); // synchronized, QFile logFile is not thread-safe + + QString out = qFormatLogMessage(type, context, msg); + out += QChar::LineFeed; + + PrismUpdaterApp* app = static_cast(QCoreApplication::instance()); + app->logFile->write(out.toUtf8()); + app->logFile->flush(); + if (app->logToConsole) { + QTextStream(stderr) << out.toLocal8Bit(); + fflush(stderr); + } +} + +PrismUpdaterApp::PrismUpdaterApp(int& argc, char** argv) : QApplication(argc, argv) +{ + setOrganizationName(BuildConfig.LAUNCHER_NAME); + setOrganizationDomain(BuildConfig.LAUNCHER_DOMAIN); + setApplicationName(BuildConfig.LAUNCHER_NAME + "Updater"); + setApplicationVersion(BuildConfig.printableVersionString() + "\n" + BuildConfig.GIT_COMMIT); + + // Command line parsing + QCommandLineParser parser; + parser.setApplicationDescription(QObject::tr("An auto-updater for Prism Launcher")); + + parser.addOptions( + { { { "d", "dir" }, tr("Use a custom path as application root (use '.' for current directory)."), tr("directory") }, + { { "V", "prism-version" }, + tr("Use this version as the installed launcher version. (provided because stdout can not be reliably captured on windows)"), + tr("installed launcher version") }, + { { "I", "install-version" }, "Install a specific version.", tr("version name") }, + { { "U", "update-url" }, tr("Update from the specified repo."), tr("github repo url") }, + { { "c", "check-only" }, + tr("Only check if an update is needed. Exit status 100 if true, 0 if false (or non 0 if there was an error).") }, + { { "p", "pre-release" }, tr("Allow updating to pre-release releases") }, + { { "F", "force" }, tr("Force an update, even if one is not needed.") }, + { { "l", "list" }, tr("List available releases.") }, + { "debug", tr("Log debug to console.") }, + { { "S", "select-ui" }, tr("Select the version to install with a GUI.") }, + { { "D", "allow-downgrade" }, tr("Allow the updater to downgrade to previous versions.") } }); + + parser.addHelpOption(); + parser.addVersionOption(); + parser.process(arguments()); + + logToConsole = parser.isSet("debug"); + + QString origCwdPath = QDir::currentPath(); + QString binPath = applicationDirPath(); + + { // find data director + // Root path is used for updates and portable data +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) || defined(Q_OS_OPENBSD) + QDir foo(FS::PathCombine(binPath, "..")); // typically portable-root or /usr + m_rootPath = foo.absolutePath(); +#elif defined(Q_OS_WIN32) + m_rootPath = binPath; +#elif defined(Q_OS_MAC) + QDir foo(FS::PathCombine(binPath, "../..")); + m_rootPath = foo.absolutePath(); + // on macOS, touch the root to force Finder to reload the .app metadata (and fix any icon change issues) + FS::updateTimestamp(m_rootPath); +#endif + } + + QString adjustedBy; + // change folder + QString dirParam = parser.value("dir"); + if (!dirParam.isEmpty()) { + // the dir param. it makes prism launcher data path point to whatever the user specified + // on command line + adjustedBy = "Command line"; + m_dataPath = dirParam; +#ifndef Q_OS_MACOS + if (QDir(FS::PathCombine(m_rootPath, "UserData")).exists()) { + m_isPortable = true; + } + if (QFile::exists(FS::PathCombine(m_rootPath, "portable.txt"))) { + m_isPortable = true; + } +#endif + } else if (auto dataDirEnv = + QProcessEnvironment::systemEnvironment().value(QString("%1_DATA_DIR").arg(BuildConfig.LAUNCHER_NAME.toUpper())); + !dataDirEnv.isEmpty()) { + adjustedBy = "System environment"; + m_dataPath = dataDirEnv; +#ifndef Q_OS_MACOS + if (QFile::exists(FS::PathCombine(m_rootPath, "portable.txt"))) { + m_isPortable = true; + } +#endif + } else { + QDir foo(FS::PathCombine(QStandardPaths::writableLocation(QStandardPaths::AppDataLocation), "..")); + m_dataPath = foo.absolutePath(); + adjustedBy = "Persistent data path"; + +#ifndef Q_OS_MACOS + if (auto portableUserData = FS::PathCombine(m_rootPath, "UserData"); QDir(portableUserData).exists()) { + m_dataPath = portableUserData; + adjustedBy = "Portable user data path"; + m_isPortable = true; + } else if (QFile::exists(FS::PathCombine(m_rootPath, "portable.txt"))) { + m_dataPath = m_rootPath; + adjustedBy = "Portable data path"; + m_isPortable = true; + } +#endif + } + + m_updateLogPath = FS::PathCombine(m_dataPath, "logs", "prism_launcher_update.log"); + + { // setup logging + FS::ensureFolderPathExists(FS::PathCombine(m_dataPath, "logs")); + static const QString baseLogFile = BuildConfig.LAUNCHER_NAME + "Updater" + (m_checkOnly ? "-CheckOnly" : "") + "-%0.log"; + static const QString logBase = FS::PathCombine(m_dataPath, "logs", baseLogFile); + + if (FS::ensureFolderPathExists("logs")) { // enough history to track both launches of the updater during a portable install + FS::move(logBase.arg(1), logBase.arg(2)); + FS::move(logBase.arg(0), logBase.arg(1)); + } + + logFile = std::unique_ptr(new QFile(logBase.arg(0))); + if (!logFile->open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate)) { + showFatalErrorMessage(tr("The launcher data folder is not writable!"), + tr("The updater couldn't create a log file - %1.\n" + "\n" + "Make sure you have write permissions to the data folder.\n" + "(%2)\n" + "\n" + "The updater cannot continue until you fix this problem.") + .arg(logFile->errorString()) + .arg(m_dataPath)); + return; + } + qInstallMessageHandler(appDebugOutput); + + qSetMessagePattern( + "%{time process}" + " " + "%{if-debug}D%{endif}" + "%{if-info}I%{endif}" + "%{if-warning}W%{endif}" + "%{if-critical}C%{endif}" + "%{if-fatal}F%{endif}" + " " + "|" + " " + "%{if-category}[%{category}]: %{endif}" + "%{message}"); + + bool foundLoggingRules = false; + + auto logRulesFile = QStringLiteral("qtlogging.ini"); + auto logRulesPath = FS::PathCombine(m_dataPath, logRulesFile); + + qDebug() << "Testing" << logRulesPath << "..."; + foundLoggingRules = QFile::exists(logRulesPath); + + // search the dataPath() + // seach app data standard path + if (!foundLoggingRules && !isPortable() && dirParam.isEmpty()) { + logRulesPath = QStandardPaths::locate(QStandardPaths::AppDataLocation, FS::PathCombine("..", logRulesFile)); + if (!logRulesPath.isEmpty()) { + qDebug() << "Found" << logRulesPath << "..."; + foundLoggingRules = true; + } + } + // seach root path + if (!foundLoggingRules) { + logRulesPath = FS::PathCombine(m_rootPath, logRulesFile); + qDebug() << "Testing" << logRulesPath << "..."; + foundLoggingRules = QFile::exists(logRulesPath); + } + + if (foundLoggingRules) { + // load and set logging rules + qDebug() << "Loading logging rules from:" << logRulesPath; + QSettings loggingRules(logRulesPath, QSettings::IniFormat); + loggingRules.beginGroup("Rules"); + QStringList rule_names = loggingRules.childKeys(); + QStringList rules; + qDebug() << "Setting log rules:"; + for (auto rule_name : rule_names) { + auto rule = QString("%1=%2").arg(rule_name).arg(loggingRules.value(rule_name).toString()); + rules.append(rule); + qDebug() << " " << rule; + } + auto rules_str = rules.join("\n"); + QLoggingCategory::setFilterRules(rules_str); + } + + qDebug() << "<> Log initialized."; + } + + { // log debug program info + qDebug() << qPrintable(BuildConfig.LAUNCHER_DISPLAYNAME + " Updater, " + + QString(BuildConfig.LAUNCHER_COPYRIGHT).replace("\n", ", ")); + qDebug() << "Version :" << BuildConfig.printableVersionString(); + qDebug() << "Git commit :" << BuildConfig.GIT_COMMIT; + qDebug() << "Git refspec :" << BuildConfig.GIT_REFSPEC; + qDebug() << "Compiled for :" << BuildConfig.systemID(); + qDebug() << "Compiled by :" << BuildConfig.compilerID(); + qDebug() << "Build Artifact :" << BuildConfig.BUILD_ARTIFACT; + if (adjustedBy.size()) { + qDebug() << "Data dir before adjustment :" << origCwdPath; + qDebug() << "Data dir after adjustment :" << m_dataPath; + qDebug() << "Adjusted by :" << adjustedBy; + } else { + qDebug() << "Data dir :" << QDir::currentPath(); + } + qDebug() << "Work dir :" << QDir::currentPath(); + qDebug() << "Binary path :" << binPath; + qDebug() << "Application root path :" << m_rootPath; + qDebug() << "Portable install :" << m_isPortable; + qDebug() << "<> Paths set."; + } + + { // network + m_network = std::make_unique(); + qDebug() << "Detecting proxy settings..."; + QNetworkProxy proxy = QNetworkProxy::applicationProxy(); + m_network->setProxy(proxy); + } + +#ifdef Q_OS_MACOS + showFatalErrorMessage(tr("MacOS Not Supported"), tr("The updater does not support installations on MacOS")); +#endif + + if (binPath.startsWith("/tmp/.mount_")) { + m_isAppimage = true; + m_appimagePath = QProcessEnvironment::systemEnvironment().value(QStringLiteral("APPIMAGE")); + if (m_appimagePath.isEmpty()) { + showFatalErrorMessage(tr("Unsupported Installation"), + tr("Updater is running as misconfigured AppImage? ($APPIMAGE environment variable is missing)")); + } + } + + m_isFlatpak = DesktopServices::isFlatpak(); + + QString prism_executable = FS::PathCombine(binPath, BuildConfig.LAUNCHER_APP_BINARY_NAME); +#if defined Q_OS_WIN32 + prism_executable.append(".exe"); +#endif + + if (!QFileInfo(prism_executable).isFile()) { + showFatalErrorMessage(tr("Unsupported Installation"), tr("The updater can not find the main executable.")); + } + + m_prismExecutable = prism_executable; + + auto prism_update_url = parser.value("update-url"); + if (prism_update_url.isEmpty()) + prism_update_url = BuildConfig.UPDATER_GITHUB_REPO; + + m_prismRepoUrl = QUrl::fromUserInput(prism_update_url); + + m_checkOnly = parser.isSet("check-only"); + m_forceUpdate = parser.isSet("force"); + m_printOnly = parser.isSet("list"); + auto user_version = parser.value("install-version"); + if (!user_version.isEmpty()) { + m_userSelectedVersion = Version(user_version); + } + m_selectUI = parser.isSet("select-ui"); + m_allowDowngrade = parser.isSet("allow-downgrade"); + + auto version = parser.value("prism-version"); + if (!version.isEmpty()) { + if (version.contains('-')) { + auto index = version.indexOf('-'); + m_prsimVersionChannel = version.mid(index + 1); + version = version.left(index); + } else { + m_prsimVersionChannel = "stable"; + } + auto version_parts = version.split('.'); + m_prismVersionMajor = version_parts.takeFirst().toInt(); + m_prismVersionMinor = version_parts.takeFirst().toInt(); + if (!version_parts.isEmpty()) + m_prismVersionPatch = version_parts.takeFirst().toInt(); + else + m_prismVersionPatch = 0; + } + + m_allowPreRelease = parser.isSet("pre-release"); + + auto marker_file_path = QDir(m_rootPath).absoluteFilePath(".prism_launcher_updater_unpack.marker"); + auto marker_file = QFileInfo(marker_file_path); + if (marker_file.exists()) { + auto target_dir = QString(FS::read(marker_file_path)).trimmed(); + if (target_dir.isEmpty()) { + qWarning() << "Empty updater marker file contains no install target. making best guess of parent dir"; + target_dir = QDir(m_rootPath).absoluteFilePath(".."); + } + + QMetaObject::invokeMethod(this, [this, target_dir]() { moveAndFinishUpdate(target_dir); }, Qt::QueuedConnection); + + } else { + QMetaObject::invokeMethod(this, &PrismUpdaterApp::loadReleaseList, Qt::QueuedConnection); + } +} + +PrismUpdaterApp::~PrismUpdaterApp() +{ + qDebug() << "updater shutting down"; + // Shut down logger by setting the logger function to nothing + qInstallMessageHandler(nullptr); +} + +void PrismUpdaterApp::fail(const QString& reason) +{ + qCritical() << qPrintable(reason); + m_status = Failed; + exit(1); +} + +void PrismUpdaterApp::abort(const QString& reason) +{ + qCritical() << qPrintable(reason); + m_status = Aborted; + exit(2); +} + +void PrismUpdaterApp::showFatalErrorMessage(const QString& title, const QString& content) +{ + m_status = Failed; + auto msgBox = new QMessageBox(); + msgBox->setWindowTitle(title); + msgBox->setText(content); + msgBox->setStandardButtons(QMessageBox::Ok); + msgBox->setDefaultButton(QMessageBox::Ok); + msgBox->setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::TextBrowserInteraction); + msgBox->setIcon(QMessageBox::Critical); + msgBox->setMinimumWidth(460); + msgBox->adjustSize(); + msgBox->exec(); + exit(1); +} + +void PrismUpdaterApp::run() +{ + qDebug() << "found" << m_releases.length() << "releases on github"; + qDebug() << "loading exe at" << m_prismExecutable; + + if (m_printOnly) { + printReleases(); + m_status = Succeeded; + return exit(0); + } + + if (!loadPrismVersionFromExe(m_prismExecutable)) { + m_prismVersion = BuildConfig.printableVersionString(); + m_prismVersionMajor = BuildConfig.VERSION_MAJOR; + m_prismVersionMinor = BuildConfig.VERSION_MINOR; + m_prismVersionPatch = BuildConfig.VERSION_PATCH; + m_prsimVersionChannel = BuildConfig.VERSION_CHANNEL; + m_prismGitCommit = BuildConfig.GIT_COMMIT; + } + m_status = Succeeded; + + qDebug() << "Executable reports as:" << m_prismBinaryName << "version:" << m_prismVersion; + qDebug() << "Version major:" << m_prismVersionMajor; + qDebug() << "Version minor:" << m_prismVersionMinor; + qDebug() << "Version minor:" << m_prismVersionPatch; + qDebug() << "Version channel:" << m_prsimVersionChannel; + qDebug() << "Git Commit:" << m_prismGitCommit; + + auto latest = getLatestRelease(); + qDebug() << "Latest release" << latest.version; + auto need_update = needUpdate(latest); + + if (m_checkOnly) { + if (need_update) { + QTextStream stdOutStream(stdout); + stdOutStream << "Name: " << latest.name << "\n"; + stdOutStream << "Version: " << latest.tag_name << "\n"; + stdOutStream << "TimeStamp: " << latest.created_at.toString(Qt::ISODate) << "\n"; + stdOutStream << latest.body << "\n"; + stdOutStream.flush(); + + return exit(100); + } else { + return exit(0); + } + } + + if (m_isFlatpak) { + showFatalErrorMessage(tr("Updating flatpack not supported"), tr("Actions outside of checking if an update is available are not " + "supported when running the flatpak version of Prism Launcher.")); + return; + } + if (m_isAppimage) { + bool result = true; + if (need_update) + result = callAppImageUpdate(); + return exit(result ? 0 : 1); + } + + if (need_update || m_forceUpdate || !m_userSelectedVersion.isEmpty()) { + GitHubRelease update_release = latest; + if (!m_userSelectedVersion.isEmpty()) { + bool found = false; + for (auto rls : m_releases) { + if (rls.version == m_userSelectedVersion) { + found = true; + update_release = rls; + break; + } + } + if (!found) { + showFatalErrorMessage( + "No release for version!", + QString("Can not find a github release for specified version %1").arg(m_userSelectedVersion.toString())); + return; + } + } else if (m_selectUI) { + update_release = selectRelease(); + if (!update_release.isValid()) { + showFatalErrorMessage("No version selected.", "No version was selected."); + return; + } + } + + performUpdate(update_release); + } + + exit(0); +} + +void PrismUpdaterApp::moveAndFinishUpdate(QDir target) +{ + logUpdate("Finishing update process"); + + logUpdate("Waiting 2 seconds for resources to free"); + this->thread()->sleep(2); + + auto manifest_path = FS::PathCombine(m_rootPath, "manifest.txt"); + QFileInfo manifest(manifest_path); + + auto app_dir = QDir(m_rootPath); + + QStringList file_list; + if (manifest.isFile()) { + // load manifest from file + logUpdate(tr("Reading manifest from %1").arg(manifest.absoluteFilePath())); + try { + auto contents = QString::fromUtf8(FS::read(manifest.absoluteFilePath())); + auto files = contents.split('\n'); + for (auto file : files) { + file_list.append(file.trimmed()); + } + } catch (FS::FileSystemException&) { + } + } + + if (file_list.isEmpty()) { + logUpdate(tr("Manifest empty, making best guess of the directory contents of %1").arg(m_rootPath)); + auto entries = target.entryInfoList(QDir::NoDotAndDotDot | QDir::Files | QDir::Dirs); + for (auto entry : entries) { + file_list.append(entry.fileName()); + } + } + logUpdate(tr("Installing the following to %1 :\n %2").arg(target.absolutePath()).arg(file_list.join(",\n "))); + + bool error = false; + + QProgressDialog progress(tr("Installing from %1").arg(m_rootPath), "", 0, file_list.length()); + progress.setCancelButton(nullptr); + progress.setMinimumWidth(400); + progress.adjustSize(); + progress.show(); + QCoreApplication::processEvents(); + + logUpdate(tr("Installing from %1").arg(m_rootPath)); + + auto copy = [this, app_dir, target](QString to_install_file) { + auto rel_path = app_dir.relativeFilePath(to_install_file); + auto install_path = FS::PathCombine(target.absolutePath(), rel_path); + logUpdate(tr("Installing %1 from %2").arg(install_path).arg(to_install_file)); + FS::ensureFilePathExists(install_path); + auto result = FS::copy(to_install_file, install_path).overwrite(true)(); + if (!result) { + logUpdate(tr("Failed copy %1 to %2").arg(to_install_file).arg(install_path)); + return true; + } + return false; + }; + + int i = 0; + for (auto glob : file_list) { + QDirIterator iter(m_rootPath, QStringList({ glob }), QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot); + progress.setValue(i); + QCoreApplication::processEvents(); + if (!iter.hasNext() && !glob.isEmpty()) { + if (auto file_info = QFileInfo(FS::PathCombine(m_rootPath, glob)); file_info.exists()) { + error |= copy(file_info.absoluteFilePath()); + } else { + logUpdate(tr("File doesn't exist, ignoring: %1").arg(FS::PathCombine(m_rootPath, glob))); + } + } else { + while (iter.hasNext()) { + error |= copy(iter.next()); + } + } + i++; + } + progress.setValue(i); + QCoreApplication::processEvents(); + + if (error) { + logUpdate(tr("There were errors installing the update.")); + auto fail_marker = FS::PathCombine(m_dataPath, ".prism_launcher_update.fail"); + FS::copy(m_updateLogPath, fail_marker).overwrite(true)(); + } else { + logUpdate(tr("Update succeed.")); + auto success_marker = FS::PathCombine(m_dataPath, ".prism_launcher_update.success"); + FS::copy(m_updateLogPath, success_marker).overwrite(true)(); + } + auto update_lock_path = FS::PathCombine(m_dataPath, ".prism_launcher_update.lock"); + FS::deletePath(update_lock_path); + + QProcess proc; + auto app_exe_name = BuildConfig.LAUNCHER_APP_BINARY_NAME; +#if defined Q_OS_WIN32 + app_exe_name.append(".exe"); + + auto env = QProcessEnvironment::systemEnvironment(); + env.insert("__COMPAT_LAYER", "RUNASINVOKER"); + proc.setProcessEnvironment(env); +#else + app_exe_name.prepend("bin/"); +#endif + + auto app_exe_path = target.absoluteFilePath(app_exe_name); + proc.startDetached(app_exe_path); + + exit(error ? 1 : 0); +} + +void PrismUpdaterApp::printReleases() +{ + for (auto release : m_releases) { + std::cout << release.name.toStdString() << " Version: " << release.tag_name.toStdString() << std::endl; + } +} + +QList PrismUpdaterApp::nonDraftReleases() +{ + QList nonDraft; + for (auto rls : m_releases) { + if (rls.isValid() && !rls.draft) + nonDraft.append(rls); + } + return nonDraft; +} + +QList PrismUpdaterApp::newerReleases() +{ + QList newer; + for (auto rls : nonDraftReleases()) { + if (rls.version > m_prismVersion) + newer.append(rls); + } + return newer; +} + +GitHubRelease PrismUpdaterApp::selectRelease() +{ + QList releases; + + if (m_allowDowngrade) { + releases = nonDraftReleases(); + } else { + releases = newerReleases(); + } + + if (releases.isEmpty()) + return {}; + + SelectReleaseDialog dlg(Version(m_prismVersion), releases); + auto result = dlg.exec(); + + if (result == QDialog::Rejected) { + return {}; + } + GitHubRelease release = dlg.selectedRelease(); + + return release; +} + +QList PrismUpdaterApp::validReleaseArtifacts(const GitHubRelease& release) +{ + QList valid; + + qDebug() << "Selecting best asset from" << release.tag_name << "for platform" << BuildConfig.BUILD_ARTIFACT + << "portable:" << m_isPortable; + if (BuildConfig.BUILD_ARTIFACT.isEmpty()) + qWarning() << "Build platform is not set!"; + for (auto asset : release.assets) { + if (asset.name.endsWith(".zsync")) { + qDebug() << "Rejecting zsync file" << asset.name; + continue; + } + if (!m_isAppimage && asset.name.toLower().endsWith("appimage")) { + qDebug() << "Rejecting" << asset.name << "because it is an AppImage"; + continue; + } else if (m_isAppimage && !asset.name.toLower().endsWith("appimage")) { + qDebug() << "Rejecting" << asset.name << "because it is not an AppImage"; + continue; + } + auto asset_name = asset.name.toLower(); + auto [platform, platform_qt_ver] = StringUtils::splitFirst(BuildConfig.BUILD_ARTIFACT.toLower(), "-qt"); + auto system_is_arm = QSysInfo::buildCpuArchitecture().contains("arm64"); + auto asset_is_arm = asset_name.contains("arm64"); + auto asset_is_archive = asset_name.endsWith(".zip") || asset_name.endsWith(".tar.gz"); + + bool for_platform = !platform.isEmpty() && asset_name.contains(platform); + if (!for_platform) { + qDebug() << "Rejecting" << asset.name << "because platforms do not match"; + } + bool for_portable = asset_name.contains("portable"); + if (for_platform && asset_name.contains("legacy") && !platform.contains("legacy")) { + qDebug() << "Rejecting" << asset.name << "because platforms do not match"; + for_platform = false; + } + if (for_platform && ((asset_is_arm && !system_is_arm) || (!asset_is_arm && system_is_arm))) { + qDebug() << "Rejecting" << asset.name << "because architecture does not match"; + for_platform = false; + } + if (for_platform && platform.contains("windows") && !m_isPortable && asset_is_archive) { + qDebug() << "Rejecting" << asset.name << "because it is not an installer"; + for_platform = false; + } + + static const QRegularExpression s_qtPattern("-qt(\\d+)"); + auto qt_match = s_qtPattern.match(asset_name); + if (for_platform && qt_match.hasMatch()) { + if (platform_qt_ver.isEmpty() || platform_qt_ver.toInt() != qt_match.captured(1).toInt()) { + qDebug() << "Rejecting" << asset.name << "because it is not for the correct qt version" << platform_qt_ver.toInt() << "vs" + << qt_match.captured(1).toInt(); + for_platform = false; + } + } + + if (((m_isPortable && for_portable) || (!m_isPortable && !for_portable)) && for_platform) { + qDebug() << "Accepting" << asset.name; + valid.append(asset); + } + } + return valid; +} + +GitHubReleaseAsset PrismUpdaterApp::selectAsset(const QList& assets) +{ + SelectReleaseAssetDialog dlg(assets); + auto result = dlg.exec(); + + if (result == QDialog::Rejected) { + return {}; + } + + GitHubReleaseAsset asset = dlg.selectedAsset(); + return asset; +} + +void PrismUpdaterApp::performUpdate(const GitHubRelease& release) +{ + m_install_release = release; + qDebug() << "Updating to" << release.tag_name; + auto valid_assets = validReleaseArtifacts(release); + qDebug() << "valid release assets:" << valid_assets; + + GitHubReleaseAsset selected_asset; + if (valid_assets.isEmpty()) { + return showFatalErrorMessage( + tr("No Valid Release Assets"), + tr("Github release %1 has no valid assets for this platform: %2") + .arg(release.tag_name) + .arg(tr("%1 portable: %2").arg(BuildConfig.BUILD_ARTIFACT).arg(m_isPortable ? tr("yes") : tr("no")))); + } else if (valid_assets.length() > 1) { + selected_asset = selectAsset(valid_assets); + } else { + selected_asset = valid_assets.takeFirst(); + } + + if (!selected_asset.isValid()) { + return showFatalErrorMessage(tr("No version selected."), tr("No version was selected.")); + } + + qDebug() << "will install" << selected_asset; + auto file = downloadAsset(selected_asset); + + if (!file.exists()) { + return showFatalErrorMessage(tr("Failed to Download"), tr("Failed to download the selected asset.")); + } + + performInstall(file); +} + +QFileInfo PrismUpdaterApp::downloadAsset(const GitHubReleaseAsset& asset) +{ + auto temp_dir = QDir::tempPath(); + auto file_url = QUrl(asset.browser_download_url); + auto out_file_path = FS::PathCombine(temp_dir, file_url.fileName()); + + qDebug() << "downloading" << file_url << "to" << out_file_path; + auto download = Net::Download::makeFile(file_url, out_file_path); + download->setNetwork(m_network.get()); + auto progress_dialog = ProgressDialog(); + progress_dialog.adjustSize(); + + progress_dialog.execWithTask(download.get()); + + qDebug() << "download complete"; + + QFileInfo out_file(out_file_path); + return out_file; +} + +bool PrismUpdaterApp::callAppImageUpdate() +{ + auto appimage_path = QProcessEnvironment::systemEnvironment().value(QStringLiteral("APPIMAGE")); + QProcess proc = QProcess(); + qDebug() << "Calling: AppImageUpdate" << appimage_path; + proc.setProgram(FS::PathCombine(m_rootPath, "bin", "AppImageUpdate.AppImage")); + proc.setArguments({ appimage_path }); + auto result = proc.startDetached(); + if (!result) + qDebug() << "Failed to start AppImageUpdate reason:" << proc.errorString(); + return result; +} + +void PrismUpdaterApp::clearUpdateLog() +{ + FS::deletePath(m_updateLogPath); +} + +void PrismUpdaterApp::logUpdate(const QString& msg) +{ + qDebug() << qUtf8Printable(msg); + FS::append(m_updateLogPath, QStringLiteral("%1\n").arg(msg).toUtf8()); +} + +std::tuple read_lock_File(const QString& path) +{ + auto contents = QString(FS::read(path)); + auto lines = contents.split('\n'); + + QDateTime timestamp; + QString from, to, target, data_path; + for (auto line : lines) { + auto index = line.indexOf("="); + if (index < 0) + continue; + auto left = line.left(index); + auto right = line.mid(index + 1); + if (left.toLower() == "timestamp") { + timestamp = QDateTime::fromString(right, Qt::ISODate); + } else if (left.toLower() == "from") { + from = right; + } else if (left.toLower() == "to") { + to = right; + } else if (left.toLower() == "target") { + target = right; + } else if (left.toLower() == "data_path") { + data_path = right; + } + } + return std::make_tuple(timestamp, from, to, target, data_path); +} + +bool write_lock_file(const QString& path, QDateTime timestamp, QString from, QString to, QString target, QString data_path) +{ + try { + FS::write(path, QStringLiteral("TIMESTAMP=%1\nFROM=%2\nTO=%3\nTARGET=%4\nDATA_PATH=%5\n") + .arg(timestamp.toString(Qt::ISODate)) + .arg(from) + .arg(to) + .arg(target) + .arg(data_path) + .toUtf8()); + } catch (FS::FileSystemException& err) { + qWarning() << "Error writing lockfile:" << err.what() << "\n" << err.cause(); + return false; + } + return true; +} + +void PrismUpdaterApp::performInstall(QFileInfo file) +{ + qDebug() << "starting install"; + auto update_lock_path = FS::PathCombine(m_dataPath, ".prism_launcher_update.lock"); + QFileInfo update_lock(update_lock_path); + if (update_lock.exists()) { + auto [timestamp, from, to, target, data_path] = read_lock_File(update_lock_path); + auto msg = tr("Update already in progress\n"); + auto infoMsg = + tr("This installation has a update lock file present at: %1\n" + "\n" + "Timestamp: %2\n" + "Updating from version %3 to %4\n" + "Target install path: %5\n" + "Data Path: %6" + "\n" + "This likely means that a previous update attempt failed. Please ensure your installation is in working order before " + "proceeding.\n" + "Check the Prism Launcher updater log at: \n" + "%7\n" + "for details on the last update attempt.\n" + "\n" + "To overwrite this lock and proceed with this update anyway, select \"Ignore\" below.") + .arg(update_lock_path) + .arg(timestamp.toString(Qt::ISODate), from, to, target, data_path) + .arg(m_updateLogPath); + QMessageBox msgBox; + msgBox.setText(msg); + msgBox.setInformativeText(infoMsg); + msgBox.setStandardButtons(QMessageBox::Ignore | QMessageBox::Cancel); + msgBox.setDefaultButton(QMessageBox::Cancel); + msgBox.setMinimumWidth(460); + msgBox.adjustSize(); + switch (msgBox.exec()) { + case QMessageBox::AcceptRole: + break; + case QMessageBox::RejectRole: + [[fallthrough]]; + default: + return showFatalErrorMessage(tr("Update Aborted"), tr("The update attempt was aborted")); + } + } + clearUpdateLog(); + + auto changelog_path = FS::PathCombine(m_dataPath, ".prism_launcher_update.changelog"); + FS::write(changelog_path, m_install_release.body.toUtf8()); + + logUpdate(tr("Updating from %1 to %2").arg(m_prismVersion).arg(m_install_release.tag_name)); + if (m_isPortable || file.fileName().endsWith(".zip") || file.fileName().endsWith(".tar.gz")) { + write_lock_file(update_lock_path, QDateTime::currentDateTime(), m_prismVersion, m_install_release.tag_name, m_rootPath, m_dataPath); + logUpdate(tr("Updating portable install at %1").arg(m_rootPath)); + unpackAndInstall(file); + } else { + logUpdate(tr("Running installer file at %1").arg(file.absoluteFilePath())); + QProcess proc = QProcess(); +#if defined Q_OS_WIN + auto env = QProcessEnvironment::systemEnvironment(); + env.insert("__COMPAT_LAYER", "RUNASINVOKER"); + proc.setProcessEnvironment(env); +#endif + proc.setProgram(file.absoluteFilePath()); + bool result = proc.startDetached(); + logUpdate(tr("Process start result: %1").arg(result ? tr("yes") : tr("no"))); + exit(result ? 0 : 1); + } +} + +void PrismUpdaterApp::unpackAndInstall(QFileInfo archive) +{ + logUpdate(tr("Backing up install")); + backupAppDir(); + + if (auto loc = unpackArchive(archive)) { + auto marker_file_path = loc.value().absoluteFilePath(".prism_launcher_updater_unpack.marker"); + FS::write(marker_file_path, m_rootPath.toUtf8()); + + QProcess proc = QProcess(); + + auto exe_name = QStringLiteral("%1_updater").arg(BuildConfig.LAUNCHER_APP_BINARY_NAME); +#if defined Q_OS_WIN32 + exe_name.append(".exe"); + + auto env = QProcessEnvironment::systemEnvironment(); + env.insert("__COMPAT_LAYER", "RUNASINVOKER"); + proc.setProcessEnvironment(env); +#else + exe_name.prepend("bin/"); +#endif + + auto new_updater_path = loc.value().absoluteFilePath(exe_name); + logUpdate(tr("Starting new updater at '%1'").arg(new_updater_path)); + if (!proc.startDetached(new_updater_path, { "-d", m_dataPath }, loc.value().absolutePath())) { + logUpdate(tr("Failed to launch '%1' %2").arg(new_updater_path).arg(proc.errorString())); + return exit(10); + } + return exit(); // up to the new updater now + } + return exit(1); // unpack failure +} + +void PrismUpdaterApp::backupAppDir() +{ + auto manifest_path = FS::PathCombine(m_rootPath, "manifest.txt"); + QFileInfo manifest(manifest_path); + + QStringList file_list; + if (manifest.isFile()) { + // load manifest from file + + logUpdate(tr("Reading manifest from %1").arg(manifest.absoluteFilePath())); + try { + auto contents = QString::fromUtf8(FS::read(manifest.absoluteFilePath())); + auto files = contents.split('\n'); + for (auto file : files) { + file_list.append(file.trimmed()); + } + } catch (FS::FileSystemException&) { + } + } + + if (file_list.isEmpty()) { + // best guess + if (BuildConfig.BUILD_ARTIFACT.toLower().contains("linux")) { + file_list.append({ "PrismLauncher", "bin", "share", "lib" }); + } else { // windows by process of elimination + file_list.append({ + "jars", + "prismlauncher.exe", + "prismlauncher_filelink.exe", + "prismlauncher_updater.exe", + "qtlogging.ini", + "imageformats", + "iconengines", + "platforms", + "styles", + "tls", + "qt.conf", + "Qt*.dll", + }); + } + logUpdate("manifest.txt empty or missing. making best guess at files to back up."); + } + logUpdate(tr("Backing up:\n %1").arg(file_list.join(",\n "))); + static const QRegularExpression s_replaceRegex("[" + QRegularExpression::escape("\\/:*?\"<>|") + "]"); + auto app_dir = QDir(m_rootPath); + auto backup_dir = + FS::PathCombine(app_dir.absolutePath(), + QStringLiteral("backup_") + QString(m_prismVersion).replace(s_replaceRegex, QString("_")) + "-" + m_prismGitCommit); + FS::ensureFolderPathExists(backup_dir); + auto backup_marker_path = FS::PathCombine(m_dataPath, ".prism_launcher_update_backup_path.txt"); + FS::write(backup_marker_path, backup_dir.toUtf8()); + + QProgressDialog progress(tr("Backing up install at %1").arg(m_rootPath), "", 0, file_list.length()); + progress.setCancelButton(nullptr); + progress.setMinimumWidth(400); + progress.adjustSize(); + progress.show(); + QCoreApplication::processEvents(); + + logUpdate(tr("Backing up install at %1").arg(m_rootPath)); + + auto copy = [this, app_dir, backup_dir](QString to_bak_file) { + auto rel_path = app_dir.relativeFilePath(to_bak_file); + auto bak_path = FS::PathCombine(backup_dir, rel_path); + logUpdate(tr("Backing up and then removing %1").arg(to_bak_file)); + FS::ensureFilePathExists(bak_path); + auto result = FS::copy(to_bak_file, bak_path).overwrite(true)(); + if (!result) { + logUpdate(tr("Failed to backup %1 to %2").arg(to_bak_file).arg(bak_path)); + } else { + if (!FS::deletePath(to_bak_file)) + logUpdate(tr("Failed to remove %1").arg(to_bak_file)); + } + }; + + int i = 0; + for (auto glob : file_list) { + QDirIterator iter(app_dir.absolutePath(), QStringList({ glob }), QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot); + progress.setValue(i); + QCoreApplication::processEvents(); + if (!iter.hasNext() && !glob.isEmpty()) { + if (auto file_info = QFileInfo(FS::PathCombine(app_dir.absolutePath(), glob)); file_info.exists()) { + copy(file_info.absoluteFilePath()); + } else { + logUpdate(tr("File doesn't exist, ignoring: %1").arg(FS::PathCombine(app_dir.absolutePath(), glob))); + } + } else { + while (iter.hasNext()) { + copy(iter.next()); + } + } + i++; + } + progress.setValue(i); + QCoreApplication::processEvents(); +} + +std::optional PrismUpdaterApp::unpackArchive(QFileInfo archive) +{ + auto temp_extract_path = FS::PathCombine(m_dataPath, "prism_launcher_update_release"); + FS::ensureFolderPathExists(temp_extract_path); + auto tmp_extract_dir = QDir(temp_extract_path); + + auto result = MMCZip::extractDir(archive.absoluteFilePath(), tmp_extract_dir.absolutePath()); + if (result) { + logUpdate(tr("Extracted the following to \"%1\":\n %2").arg(tmp_extract_dir.absolutePath()).arg(result->join("\n "))); + } else { + logUpdate(tr("Failed to extract %1 to %2").arg(archive.absoluteFilePath()).arg(tmp_extract_dir.absolutePath())); + showFatalErrorMessage("Failed to extract archive", + tr("Failed to extract %1 to %2").arg(archive.absoluteFilePath()).arg(tmp_extract_dir.absolutePath())); + return std::nullopt; + } + + return tmp_extract_dir; +} + +bool PrismUpdaterApp::loadPrismVersionFromExe(const QString& exe_path) +{ + QProcess proc = QProcess(); + proc.setProcessChannelMode(QProcess::MergedChannels); + proc.setReadChannel(QProcess::StandardOutput); + proc.start(exe_path, { "--version" }); + if (!proc.waitForStarted(5000)) { + showFatalErrorMessage(tr("Failed to Check Version"), tr("Failed to launch child process to read version.")); + return false; + } // wait 5 seconds to start + if (!proc.waitForFinished(5000)) { + showFatalErrorMessage(tr("Failed to Check Version"), tr("Child launcher process failed.")); + return false; + } + auto out = proc.readAllStandardOutput(); + auto lines = out.split('\n'); + lines.removeAll(""); + if (lines.length() < 2) + return false; + else if (lines.length() > 2) { + auto line1 = lines.takeLast(); + auto line2 = lines.takeLast(); + lines = { line2, line1 }; + } + auto first = lines.takeFirst(); + auto first_parts = first.split(' '); + if (first_parts.length() < 2) + return false; + m_prismBinaryName = first_parts.takeFirst(); + auto version = first_parts.takeFirst().trimmed(); + m_prismVersion = version; + if (version.contains('-')) { + auto index = version.indexOf('-'); + m_prsimVersionChannel = version.mid(index + 1); + version = version.left(index); + } else { + m_prsimVersionChannel = "stable"; + } + auto version_parts = version.split('.'); + if (version_parts.length() < 2) + return false; + m_prismVersionMajor = version_parts.takeFirst().toInt(); + m_prismVersionMinor = version_parts.takeFirst().toInt(); + if (!version_parts.isEmpty()) + m_prismVersionPatch = version_parts.takeFirst().toInt(); + else + m_prismVersionPatch = 0; + m_prismGitCommit = lines.takeFirst().simplified(); + return true; +} + +void PrismUpdaterApp::loadReleaseList() +{ + auto github_repo = m_prismRepoUrl; + if (github_repo.host() != "github.com") + return fail("updating from a non github url is not supported"); + + auto path_parts = github_repo.path().split('/'); + path_parts.removeFirst(); // empty segment from leading / + auto repo_owner = path_parts.takeFirst(); + auto repo_name = path_parts.takeFirst(); + auto api_url = QString("https://api.github.com/repos/%1/%2/releases").arg(repo_owner, repo_name); + + qDebug() << "Fetching release list from" << api_url; + + downloadReleasePage(api_url, 1); +} + +void PrismUpdaterApp::downloadReleasePage(const QString& api_url, int page) +{ + int per_page = 30; + auto page_url = QString("%1?per_page=%2&page=%3").arg(api_url).arg(QString::number(per_page)).arg(QString::number(page)); + auto [download, response] = Net::Download::makeByteArray(page_url); + download->setNetwork(m_network.get()); + m_current_url = page_url; + + auto github_api_headers = std::make_unique(); + github_api_headers->addHeaders({ + { "Accept", "application/vnd.github+json" }, + { "X-GitHub-Api-Version", "2022-11-28" }, + }); + download->addHeaderProxy(std::move(github_api_headers)); + + connect(download.get(), &Net::Download::succeeded, this, [this, response, per_page, api_url, page]() { + int num_found = parseReleasePage(response); + if (!(num_found < per_page)) { // there may be more, fetch next page + downloadReleasePage(api_url, page + 1); + } else { + run(); + } + }); + connect(download.get(), &Net::Download::failed, this, &PrismUpdaterApp::downloadError); + + m_current_task.reset(download); + connect(download.get(), &Net::Download::finished, this, [this]() { + qDebug() << "Download" << m_current_task->getUid().toString() << "finished"; + m_current_task.reset(); + m_current_url = ""; + }); + + QCoreApplication::processEvents(); + + QMetaObject::invokeMethod(download.get(), &Task::start, Qt::QueuedConnection); +} + +int PrismUpdaterApp::parseReleasePage(const QByteArray* response) +{ + if (response->isEmpty()) // empty page + return 0; + int num_releases = 0; + try { + auto doc = Json::requireDocument(*response); + auto release_list = Json::requireArray(doc); + for (auto release_json : release_list) { + auto release_obj = Json::requireObject(release_json); + + GitHubRelease release = {}; + release.id = Json::requireInteger(release_obj, "id"); + release.name = release_obj["name"].toString(); + release.tag_name = Json::requireString(release_obj, "tag_name"); + release.created_at = QDateTime::fromString(Json::requireString(release_obj, "created_at"), Qt::ISODate); + release.published_at = QDateTime::fromString(release_obj["published_at"].toString(), Qt::ISODate); + release.draft = Json::requireBoolean(release_obj, "draft"); + release.prerelease = Json::requireBoolean(release_obj, "prerelease"); + release.body = release_obj["body"].toString(); + release.version = Version(release.tag_name); + + auto release_assets_obj = Json::requireArray(release_obj, "assets"); + for (auto asset_json : release_assets_obj) { + auto asset_obj = Json::requireObject(asset_json); + GitHubReleaseAsset asset = {}; + asset.id = Json::requireInteger(asset_obj, "id"); + asset.name = Json::requireString(asset_obj, "name"); + asset.label = asset_obj["label"].toString(); + asset.content_type = Json::requireString(asset_obj, "content_type"); + asset.size = Json::requireInteger(asset_obj, "size"); + asset.created_at = QDateTime::fromString(Json::requireString(asset_obj, "created_at"), Qt::ISODate); + asset.updated_at = QDateTime::fromString(Json::requireString(asset_obj, "updated_at"), Qt::ISODate); + asset.browser_download_url = Json::requireString(asset_obj, "browser_download_url"); + release.assets.append(asset); + } + m_releases.append(release); + num_releases++; + } + } catch (Json::JsonException& e) { + auto err_msg = + QString("Failed to parse releases from github: %1\n%2").arg(e.what()).arg(QString::fromStdString(response->toStdString())); + fail(err_msg); + } + return num_releases; +} + +GitHubRelease PrismUpdaterApp::getLatestRelease() +{ + GitHubRelease latest; + for (auto release : m_releases) { + if (release.draft) + continue; + if (release.prerelease && !m_allowPreRelease) + continue; + if (!latest.isValid() || (release.version > latest.version)) { + latest = release; + } + } + return latest; +} + +bool PrismUpdaterApp::needUpdate(const GitHubRelease& release) +{ + auto current_ver = Version(QString("%1.%2.%3").arg(m_prismVersionMajor).arg(m_prismVersionMinor).arg(m_prismVersionPatch)); + return current_ver < release.version; +} + +void PrismUpdaterApp::downloadError(QString reason) +{ + fail(QString("Network request Failed: %1 with reason %2").arg(m_current_url).arg(reason)); +} diff --git a/launcher/updater/prismupdater/PrismUpdater.h b/launcher/updater/prismupdater/PrismUpdater.h new file mode 100644 index 0000000..5f4baec --- /dev/null +++ b/launcher/updater/prismupdater/PrismUpdater.h @@ -0,0 +1,143 @@ +// SPDX-FileCopyrightText: 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> +// +// SPDX-License-Identifier: GPL-3.0-only + +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "QObjectPtr.h" +#include "net/Download.h" + +#define PRISM_EXTERNAL_EXE +#include "FileSystem.h" + +#include "GitHubRelease.h" + +class PrismUpdaterApp : public QApplication { + Q_OBJECT + public: + enum Status { Starting, Failed, Succeeded, Initialized, Aborted }; + PrismUpdaterApp(int& argc, char** argv); + virtual ~PrismUpdaterApp(); + void loadReleaseList(); + void run(); + Status status() const { return m_status; } + + private: + void fail(const QString& reason); + void abort(const QString& reason); + void showFatalErrorMessage(const QString& title, const QString& content); + + bool loadPrismVersionFromExe(const QString& exe_path); + + void downloadReleasePage(const QString& api_url, int page); + int parseReleasePage(const QByteArray* response); + + bool needUpdate(const GitHubRelease& release); + + GitHubRelease getLatestRelease(); + GitHubRelease selectRelease(); + QList newerReleases(); + QList nonDraftReleases(); + + void printReleases(); + + QList validReleaseArtifacts(const GitHubRelease& release); + GitHubReleaseAsset selectAsset(const QList& assets); + void performUpdate(const GitHubRelease& release); + void performInstall(QFileInfo file); + void unpackAndInstall(QFileInfo file); + void backupAppDir(); + std::optional unpackArchive(QFileInfo file); + + QFileInfo downloadAsset(const GitHubReleaseAsset& asset); + bool callAppImageUpdate(); + + void moveAndFinishUpdate(QDir target); + + public slots: + void downloadError(QString reason); + + private: + const QString& root() { return m_rootPath; } + + bool isPortable() { return m_isPortable; } + + void clearUpdateLog(); + void logUpdate(const QString& msg); + + QString m_rootPath; + QString m_dataPath; + bool m_isPortable = false; + bool m_isAppimage = false; + bool m_isFlatpak = false; + QString m_appimagePath; + QString m_prismExecutable; + QUrl m_prismRepoUrl; + Version m_userSelectedVersion; + bool m_checkOnly; + bool m_forceUpdate; + bool m_printOnly; + bool m_selectUI; + bool m_allowDowngrade; + bool m_allowPreRelease; + + QString m_updateLogPath; + + QString m_prismBinaryName; + QString m_prismVersion; + int m_prismVersionMajor = -1; + int m_prismVersionMinor = -1; + int m_prismVersionPatch = -1; + QString m_prsimVersionChannel; + QString m_prismGitCommit; + + GitHubRelease m_install_release; + + Status m_status = Status::Starting; + std::unique_ptr m_network; + QString m_current_url; + Task::Ptr m_current_task; + QList m_releases; + + public: + std::unique_ptr logFile; + bool logToConsole = false; + +#if defined Q_OS_WIN32 + // used on Windows to attach the standard IO streams + bool consoleAttached = false; +#endif +}; diff --git a/launcher/updater/prismupdater/SelectReleaseDialog.ui b/launcher/updater/prismupdater/SelectReleaseDialog.ui new file mode 100644 index 0000000..a1aa383 --- /dev/null +++ b/launcher/updater/prismupdater/SelectReleaseDialog.ui @@ -0,0 +1,89 @@ + + + SelectReleaseDialog + + + + 0 + 0 + 468 + 385 + + + + Select Release to Install + + + true + + + + + + Please select the release you wish to update to. + + + + + + + true + + + + 1 + + + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + buttonBox + accepted() + SelectReleaseDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + SelectReleaseDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/launcher/updater/prismupdater/UpdaterDialogs.cpp b/launcher/updater/prismupdater/UpdaterDialogs.cpp new file mode 100644 index 0000000..31e1b10 --- /dev/null +++ b/launcher/updater/prismupdater/UpdaterDialogs.cpp @@ -0,0 +1,173 @@ +// SPDX-FileCopyrightText: 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> +// +// SPDX-License-Identifier: GPL-3.0-only + +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "UpdaterDialogs.h" + +#include "ui_SelectReleaseDialog.h" + +#include +#include +#include "Markdown.h" +#include "StringUtils.h" + +SelectReleaseDialog::SelectReleaseDialog(const Version& current_version, const QList& releases, QWidget* parent) + : QDialog(parent), m_releases(releases), m_currentVersion(current_version), ui(new Ui::SelectReleaseDialog) +{ + ui->setupUi(this); + + ui->changelogTextBrowser->setOpenExternalLinks(true); + ui->changelogTextBrowser->setLineWrapMode(QTextBrowser::LineWrapMode::WidgetWidth); + ui->changelogTextBrowser->setVerticalScrollBarPolicy(Qt::ScrollBarPolicy::ScrollBarAsNeeded); + + ui->versionsTree->setColumnCount(2); + + ui->versionsTree->header()->setSectionResizeMode(0, QHeaderView::Stretch); + ui->versionsTree->header()->setSectionResizeMode(1, QHeaderView::ResizeToContents); + ui->versionsTree->setHeaderLabels({ tr("Version"), tr("Published Date") }); + ui->versionsTree->header()->setStretchLastSection(false); + + ui->eplainLabel->setText(tr("Select a version to install.\n" + "\n" + "Currently installed version: %1") + .arg(m_currentVersion.toString())); + + loadReleases(); + + connect(ui->versionsTree, &QTreeWidget::currentItemChanged, this, &SelectReleaseDialog::selectionChanged); + + connect(ui->buttonBox, &QDialogButtonBox::accepted, this, &SelectReleaseDialog::accept); + connect(ui->buttonBox, &QDialogButtonBox::rejected, this, &SelectReleaseDialog::reject); + + ui->buttonBox->button(QDialogButtonBox::Cancel)->setText(tr("Cancel")); + ui->buttonBox->button(QDialogButtonBox::Ok)->setText(tr("OK")); +} + +SelectReleaseDialog::~SelectReleaseDialog() +{ + delete ui; +} + +void SelectReleaseDialog::loadReleases() +{ + for (auto rls : m_releases) { + appendRelease(rls); + } +} + +void SelectReleaseDialog::appendRelease(GitHubRelease const& release) +{ + auto rls_item = new QTreeWidgetItem(ui->versionsTree); + rls_item->setText(0, release.tag_name); + rls_item->setExpanded(true); + rls_item->setText(1, release.published_at.toString()); + rls_item->setData(0, Qt::UserRole, QVariant(release.id)); + + ui->versionsTree->addTopLevelItem(rls_item); +} + +GitHubRelease SelectReleaseDialog::getRelease(QTreeWidgetItem* item) +{ + int id = item->data(0, Qt::UserRole).toInt(); + GitHubRelease release; + for (auto rls : m_releases) { + if (rls.id == id) + release = rls; + } + return release; +} + +void SelectReleaseDialog::selectionChanged(QTreeWidgetItem* current, QTreeWidgetItem* /*previous*/) +{ + GitHubRelease release = getRelease(current); + QString body = markdownToHTML(release.body.toUtf8()); + m_selectedRelease = release; + + ui->changelogTextBrowser->setHtml(StringUtils::htmlListPatch(body)); +} + +SelectReleaseAssetDialog::SelectReleaseAssetDialog(const QList& assets, QWidget* parent) + : QDialog(parent), m_assets(assets), ui(new Ui::SelectReleaseDialog) +{ + ui->setupUi(this); + + ui->changelogTextBrowser->setOpenExternalLinks(true); + ui->changelogTextBrowser->setLineWrapMode(QTextBrowser::LineWrapMode::WidgetWidth); + ui->changelogTextBrowser->setVerticalScrollBarPolicy(Qt::ScrollBarPolicy::ScrollBarAsNeeded); + + ui->versionsTree->setColumnCount(2); + + ui->versionsTree->header()->setSectionResizeMode(0, QHeaderView::Stretch); + ui->versionsTree->header()->setSectionResizeMode(1, QHeaderView::ResizeToContents); + ui->versionsTree->setHeaderLabels({ tr("Version"), tr("Published Date") }); + ui->versionsTree->header()->setStretchLastSection(false); + + ui->eplainLabel->setText(tr("Select a version to install.")); + + ui->changelogTextBrowser->setHidden(true); + + loadAssets(); + + connect(ui->versionsTree, &QTreeWidget::currentItemChanged, this, &SelectReleaseAssetDialog::selectionChanged); + + connect(ui->buttonBox, &QDialogButtonBox::accepted, this, &SelectReleaseAssetDialog::accept); + connect(ui->buttonBox, &QDialogButtonBox::rejected, this, &SelectReleaseAssetDialog::reject); +} + +SelectReleaseAssetDialog::~SelectReleaseAssetDialog() +{ + delete ui; +} + +void SelectReleaseAssetDialog::loadAssets() +{ + for (auto rls : m_assets) { + appendAsset(rls); + } +} + +void SelectReleaseAssetDialog::appendAsset(GitHubReleaseAsset const& asset) +{ + auto rls_item = new QTreeWidgetItem(ui->versionsTree); + rls_item->setText(0, asset.name); + rls_item->setExpanded(true); + rls_item->setText(1, asset.updated_at.toString()); + rls_item->setData(0, Qt::UserRole, QVariant(asset.id)); + + ui->versionsTree->addTopLevelItem(rls_item); +} + +GitHubReleaseAsset SelectReleaseAssetDialog::getAsset(QTreeWidgetItem* item) +{ + int id = item->data(0, Qt::UserRole).toInt(); + GitHubReleaseAsset selected_asset; + for (auto asset : m_assets) { + if (asset.id == id) + selected_asset = asset; + } + return selected_asset; +} + +void SelectReleaseAssetDialog::selectionChanged(QTreeWidgetItem* current, QTreeWidgetItem* /*previous*/) +{ + GitHubReleaseAsset asset = getAsset(current); + m_selectedAsset = asset; +} diff --git a/launcher/updater/prismupdater/UpdaterDialogs.h b/launcher/updater/prismupdater/UpdaterDialogs.h new file mode 100644 index 0000000..e336c0e --- /dev/null +++ b/launcher/updater/prismupdater/UpdaterDialogs.h @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> +// +// SPDX-License-Identifier: GPL-3.0-only + +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#pragma once + +#include +#include + +#include "GitHubRelease.h" +#include "Version.h" + +namespace Ui { +class SelectReleaseDialog; +} + +class SelectReleaseDialog : public QDialog { + Q_OBJECT + + public: + explicit SelectReleaseDialog(const Version& cur_version, const QList& releases, QWidget* parent = 0); + ~SelectReleaseDialog(); + + void loadReleases(); + void appendRelease(GitHubRelease const& release); + GitHubRelease selectedRelease() { return m_selectedRelease; } + private slots: + GitHubRelease getRelease(QTreeWidgetItem* item); + void selectionChanged(QTreeWidgetItem* current, QTreeWidgetItem* previous); + + protected: + QList m_releases; + GitHubRelease m_selectedRelease; + Version m_currentVersion; + + Ui::SelectReleaseDialog* ui; +}; + +class SelectReleaseAssetDialog : public QDialog { + Q_OBJECT + public: + explicit SelectReleaseAssetDialog(const QList& assets, QWidget* parent = 0); + ~SelectReleaseAssetDialog(); + + void loadAssets(); + void appendAsset(GitHubReleaseAsset const& asset); + GitHubReleaseAsset selectedAsset() { return m_selectedAsset; } + private slots: + GitHubReleaseAsset getAsset(QTreeWidgetItem* item); + void selectionChanged(QTreeWidgetItem* current, QTreeWidgetItem* previous); + + protected: + QList m_assets; + GitHubReleaseAsset m_selectedAsset; + + Ui::SelectReleaseDialog* ui; +}; diff --git a/launcher/updater/prismupdater/updater.exe.manifest b/launcher/updater/prismupdater/updater.exe.manifest new file mode 100644 index 0000000..2bce76b --- /dev/null +++ b/launcher/updater/prismupdater/updater.exe.manifest @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/updater/prismupdater/updater_main.cpp b/launcher/updater/prismupdater/updater_main.cpp new file mode 100644 index 0000000..ddc38d5 --- /dev/null +++ b/launcher/updater/prismupdater/updater_main.cpp @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> +// +// SPDX-License-Identifier: GPL-3.0-only + +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "PrismUpdater.h" + +#if defined Q_OS_WIN32 +#include "console/WindowsConsole.h" +#endif + +int main(int argc, char* argv[]) +{ +#if defined Q_OS_WIN32 + // attach the parent console if stdout not already captured + console::WindowsConsoleGuard _consoleGuard; +#endif + + PrismUpdaterApp wUpApp(argc, argv); + + switch (wUpApp.status()) { + case PrismUpdaterApp::Starting: + case PrismUpdaterApp::Initialized: { + return wUpApp.exec(); + } + case PrismUpdaterApp::Failed: + return 1; + case PrismUpdaterApp::Succeeded: + return 0; + default: + return -1; + } +} diff --git a/libraries/.clang-tidy b/libraries/.clang-tidy new file mode 100644 index 0000000..358b093 --- /dev/null +++ b/libraries/.clang-tidy @@ -0,0 +1,2 @@ +# We don't care about linting third-party code. +Checks: -* diff --git a/libraries/LocalPeer/CMakeLists.txt b/libraries/LocalPeer/CMakeLists.txt new file mode 100644 index 0000000..539cea1 --- /dev/null +++ b/libraries/LocalPeer/CMakeLists.txt @@ -0,0 +1,30 @@ +cmake_minimum_required(VERSION 3.15) +project(LocalPeer) + +if(Launcher_QT_VERSION_MAJOR EQUAL 6) + find_package(Qt6 COMPONENTS Core Network REQUIRED) +endif() + +set(SINGLE_SOURCES +src/LocalPeer.cpp +src/LockedFile.cpp +src/LockedFile.h +include/LocalPeer.h +) + +if(UNIX) + list(APPEND SINGLE_SOURCES + src/LockedFile_unix.cpp + ) +endif() + +if(WIN32) + list(APPEND SINGLE_SOURCES + src/LockedFile_win.cpp + ) +endif() + +add_library(LocalPeer STATIC ${SINGLE_SOURCES}) +target_include_directories(LocalPeer PUBLIC include) + +target_link_libraries(LocalPeer Qt${QT_VERSION_MAJOR}::Core Qt${QT_VERSION_MAJOR}::Network ${LocalPeer_LIBS}) diff --git a/libraries/LocalPeer/include/LocalPeer.h b/libraries/LocalPeer/include/LocalPeer.h new file mode 100644 index 0000000..c370102 --- /dev/null +++ b/libraries/LocalPeer/include/LocalPeer.h @@ -0,0 +1,90 @@ +/**************************************************************************** +** +** Copyright (C) 2013 Digia Plc and/or its subsidiary(-ies). +** Contact: http://www.qt-project.org/legal +** +** This file is part of the Qt Solutions component. +** +** $QT_BEGIN_LICENSE:BSD$ +** You may use this file under the terms of the BSD license as follows: +** +** "Redistribution and use in source and binary forms, with or without +** modification, are permitted provided that the following conditions are +** met: +** * Redistributions of source code must retain the above copyright +** notice, this list of conditions and the following disclaimer. +** * Redistributions in binary form must reproduce the above copyright +** notice, this list of conditions and the following disclaimer in +** the documentation and/or other materials provided with the +** distribution. +** * Neither the name of Digia Plc and its Subsidiary(-ies) nor the names +** of its contributors may be used to endorse or promote products derived +** from this software without specific prior written permission. +** +** +** THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +** "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +** LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +** A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +** OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +** SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +** LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +** DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +** THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +** (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +** OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE." +** +** $QT_END_LICENSE$ +** +****************************************************************************/ + +#pragma once +#include +#include +#include + +class QLocalServer; +class LockedFile; + +class ApplicationId { + public: /* methods */ + // traditional app = installed system wide and used in a multi-user environment + static ApplicationId fromTraditionalApp(); + // ID based on a path with all the application data (no two instances with the same data path should run) + static ApplicationId fromPathAndVersion(const QString& dataPath, const QString& version); + // custom ID + static ApplicationId fromCustomId(const QString& id); + // custom ID, based on a raw string previously acquired from 'toString' + static ApplicationId fromRawString(const QString& id); + + QString toString() { return m_id; } + + private: /* methods */ + ApplicationId(const QString& value) { m_id = value; } + + private: /* data */ + QString m_id; +}; + +class LocalPeer : public QObject { + Q_OBJECT + + public: + LocalPeer(QObject* parent, const ApplicationId& appId); + ~LocalPeer(); + bool isClient(); + bool sendMessage(const QByteArray& message, int timeout); + ApplicationId applicationId() const; + + Q_SIGNALS: + void messageReceived(const QByteArray& message); + + protected Q_SLOTS: + void receiveConnection(); + + protected: + ApplicationId id; + QString socketName; + std::unique_ptr server; + std::unique_ptr lockFile; +}; diff --git a/libraries/LocalPeer/src/LocalPeer.cpp b/libraries/LocalPeer/src/LocalPeer.cpp new file mode 100644 index 0000000..7c97579 --- /dev/null +++ b/libraries/LocalPeer/src/LocalPeer.cpp @@ -0,0 +1,225 @@ +/**************************************************************************** +** +** Copyright (C) 2013 Digia Plc and/or its subsidiary(-ies). +** Contact: http://www.qt-project.org/legal +** +** This file is part of the Qt Solutions component. +** +** $QT_BEGIN_LICENSE:BSD$ +** You may use this file under the terms of the BSD license as follows: +** +** "Redistribution and use in source and binary forms, with or without +** modification, are permitted provided that the following conditions are +** met: +** * Redistributions of source code must retain the above copyright +** notice, this list of conditions and the following disclaimer. +** * Redistributions in binary form must reproduce the above copyright +** notice, this list of conditions and the following disclaimer in +** the documentation and/or other materials provided with the +** distribution. +** * Neither the name of Digia Plc and its Subsidiary(-ies) nor the names +** of its contributors may be used to endorse or promote products derived +** from this software without specific prior written permission. +** +** +** THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +** "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +** LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +** A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +** OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +** SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +** LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +** DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +** THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +** (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +** OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE." +** +** $QT_END_LICENSE$ +** +****************************************************************************/ + +#include "LocalPeer.h" +#include +#include +#include +#include +#include +#include +#include +#include "LockedFile.h" + +#if defined(Q_OS_WIN) +#include +#include +typedef BOOL(WINAPI* PProcessIdToSessionId)(DWORD, DWORD*); +static PProcessIdToSessionId pProcessIdToSessionId = 0; +#endif +#if defined(Q_OS_UNIX) +#include +#include +#endif + +#include +#include +#include + +static const char* ack = "ack"; + +ApplicationId ApplicationId::fromTraditionalApp() +{ + QString protoId = QCoreApplication::applicationFilePath(); +#if defined(Q_OS_WIN) + protoId = protoId.toLower(); +#endif + auto prefix = protoId.section(QLatin1Char('/'), -1); + static const QRegularExpression s_removeChars("[^a-zA-Z]"); + prefix.remove(s_removeChars); + prefix.truncate(6); + QByteArray idc = protoId.toUtf8(); + quint16 idNum = qChecksum(idc); + auto socketName = QLatin1String("pl") + prefix + QLatin1Char('-') + QString::number(idNum, 16).left(12); +#if defined(Q_OS_WIN) + if (!pProcessIdToSessionId) { + QLibrary lib("kernel32"); + pProcessIdToSessionId = (PProcessIdToSessionId)lib.resolve("ProcessIdToSessionId"); + } + if (pProcessIdToSessionId) { + DWORD sessionId = 0; + pProcessIdToSessionId(GetCurrentProcessId(), &sessionId); + socketName += QLatin1Char('-') + QString::number(sessionId, 16); + } +#else + socketName += QLatin1Char('-') + QString::number(::getuid(), 16); +#endif + return ApplicationId(socketName); +} + +ApplicationId ApplicationId::fromPathAndVersion(const QString& dataPath, const QString& version) +{ + QCryptographicHash shasum(QCryptographicHash::Algorithm::Sha1); + QString result = dataPath + QLatin1Char('-') + version; + shasum.addData(result.toUtf8()); + return ApplicationId(QLatin1String("pl") + QString::fromLatin1(shasum.result().toHex()).left(12)); +} + +ApplicationId ApplicationId::fromCustomId(const QString& id) +{ + return ApplicationId(QLatin1String("pl") + id); +} + +ApplicationId ApplicationId::fromRawString(const QString& id) +{ + return ApplicationId(id); +} + +LocalPeer::LocalPeer(QObject* parent, const ApplicationId& appId) : QObject(parent), id(appId) +{ + socketName = id.toString(); + server.reset(new QLocalServer()); + QString lockName = QDir(QDir::tempPath()).absolutePath() + QLatin1Char('/') + socketName + QLatin1String("-lockfile"); + lockFile.reset(new LockedFile(lockName)); + lockFile->open(QIODevice::ReadWrite); +} + +LocalPeer::~LocalPeer() {} + +ApplicationId LocalPeer::applicationId() const +{ + return id; +} + +bool LocalPeer::isClient() +{ + if (lockFile->isLocked()) + return false; + + if (!lockFile->lock(LockedFile::WriteLock, false)) + return true; + + bool res = server->listen(socketName); +#if defined(Q_OS_UNIX) + // ### Workaround + if (!res && server->serverError() == QAbstractSocket::AddressInUseError) { + QLocalServer::removeServer(socketName); + res = server->listen(socketName); + } +#endif + if (!res) + qWarning("QtSingleCoreApplication: listen on local socket failed, %s", qPrintable(server->errorString())); + connect(server.get(), &QLocalServer::newConnection, this, &LocalPeer::receiveConnection); + return false; +} + +bool LocalPeer::sendMessage(const QByteArray& message, int timeout) +{ + if (!isClient()) + return false; + + QLocalSocket socket; + bool connOk = false; + int tries = 2; + for (int i = 0; i < tries; i++) { + // Try twice, in case the other instance is just starting up + socket.connectToServer(socketName); + connOk = socket.waitForConnected(timeout / 2); + if (!connOk && i < (tries - 1)) { + std::this_thread::sleep_for(std::chrono::milliseconds(250)); + } + } + if (!connOk) { + return false; + } + + QByteArray uMsg(message); + QDataStream ds(&socket); + + ds.writeBytes(uMsg.constData(), uMsg.size()); + if (!socket.waitForBytesWritten(timeout)) { + return false; + } + + // wait for 'ack' + if (!socket.waitForReadyRead(timeout)) { + return false; + } + + // make sure we got 'ack' + if (!(socket.read(qstrlen(ack)) == ack)) { + return false; + } + return true; +} + +void LocalPeer::receiveConnection() +{ + QLocalSocket* socket = server->nextPendingConnection(); + if (!socket) { + return; + } + + while (socket->bytesAvailable() < static_cast(sizeof(quint32))) { + socket->waitForReadyRead(); + } + QDataStream ds(socket); + QByteArray uMsg; + quint32 remaining; + ds >> remaining; + uMsg.resize(remaining); + int got = 0; + char* uMsgBuf = uMsg.data(); + do { + got = ds.readRawData(uMsgBuf, remaining); + remaining -= got; + uMsgBuf += got; + } while (remaining && got >= 0 && socket->waitForReadyRead(2000)); + if (got < 0) { + qWarning("QtLocalPeer: Message reception failed %s", socket->errorString().toLatin1().constData()); + delete socket; + return; + } + socket->write(ack, qstrlen(ack)); + socket->waitForBytesWritten(1000); + socket->waitForDisconnected(1000); // make sure client reads ack + delete socket; + emit messageReceived(uMsg); // ### (might take a long time to return) +} diff --git a/libraries/LocalPeer/src/LockedFile.cpp b/libraries/LocalPeer/src/LockedFile.cpp new file mode 100644 index 0000000..e93eabc --- /dev/null +++ b/libraries/LocalPeer/src/LockedFile.cpp @@ -0,0 +1,191 @@ +/**************************************************************************** +** +** Copyright (C) 2013 Digia Plc and/or its subsidiary(-ies). +** Contact: http://www.qt-project.org/legal +** +** This file is part of the Qt Solutions component. +** +** $QT_BEGIN_LICENSE:BSD$ +** You may use this file under the terms of the BSD license as follows: +** +** "Redistribution and use in source and binary forms, with or without +** modification, are permitted provided that the following conditions are +** met: +** * Redistributions of source code must retain the above copyright +** notice, this list of conditions and the following disclaimer. +** * Redistributions in binary form must reproduce the above copyright +** notice, this list of conditions and the following disclaimer in +** the documentation and/or other materials provided with the +** distribution. +** * Neither the name of Digia Plc and its Subsidiary(-ies) nor the names +** of its contributors may be used to endorse or promote products derived +** from this software without specific prior written permission. +** +** +** THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +** "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +** LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +** A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +** OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +** SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +** LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +** DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +** THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +** (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +** OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE." +** +** $QT_END_LICENSE$ +** +****************************************************************************/ + +#include "LockedFile.h" + +/*! + \class QtLockedFile + + \brief The QtLockedFile class extends QFile with advisory locking + functions. + + A file may be locked in read or write mode. Multiple instances of + \e QtLockedFile, created in multiple processes running on the same + machine, may have a file locked in read mode. Exactly one instance + may have it locked in write mode. A read and a write lock cannot + exist simultaneously on the same file. + + The file locks are advisory. This means that nothing prevents + another process from manipulating a locked file using QFile or + file system functions offered by the OS. Serialization is only + guaranteed if all processes that access the file use + QLockedFile. Also, while holding a lock on a file, a process + must not open the same file again (through any API), or locks + can be unexpectedly lost. + + The lock provided by an instance of \e QtLockedFile is released + whenever the program terminates. This is true even when the + program crashes and no destructors are called. +*/ + +/*! \enum QtLockedFile::LockMode + + This enum describes the available lock modes. + + \value ReadLock A read lock. + \value WriteLock A write lock. + \value NoLock Neither a read lock nor a write lock. +*/ + +/*! + Constructs an unlocked \e QtLockedFile object. This constructor + behaves in the same way as \e QFile::QFile(). + + \sa QFile::QFile() +*/ +LockedFile::LockedFile() : QFile() +{ +#ifdef Q_OS_WIN + wmutex = 0; + rmutex = 0; +#endif + m_lock_mode = NoLock; +} + +/*! + Constructs an unlocked QtLockedFile object with file \a name. This + constructor behaves in the same way as \e QFile::QFile(const + QString&). + + \sa QFile::QFile() +*/ +LockedFile::LockedFile(const QString& name) : QFile(name) +{ +#ifdef Q_OS_WIN + wmutex = 0; + rmutex = 0; +#endif + m_lock_mode = NoLock; +} + +/*! +Opens the file in OpenMode \a mode. + +This is identical to QFile::open(), with the one exception that the +Truncate mode flag is disallowed. Truncation would conflict with the +advisory file locking, since the file would be modified before the +write lock is obtained. If truncation is required, use resize(0) +after obtaining the write lock. + +Returns true if successful; otherwise false. + +\sa QFile::open(), QFile::resize() +*/ +bool LockedFile::open(OpenMode mode) +{ + if (mode & QIODevice::Truncate) { + qWarning("QtLockedFile::open(): Truncate mode not allowed."); + return false; + } + return QFile::open(mode); +} + +/*! + Returns \e true if this object has a in read or write lock; + otherwise returns \e false. + + \sa lockMode() +*/ +bool LockedFile::isLocked() const +{ + return m_lock_mode != NoLock; +} + +/*! + Returns the type of lock currently held by this object, or \e + QtLockedFile::NoLock. + + \sa isLocked() +*/ +LockedFile::LockMode LockedFile::lockMode() const +{ + return m_lock_mode; +} + +/*! + \fn bool QtLockedFile::lock(LockMode mode, bool block = true) + + Obtains a lock of type \a mode. The file must be opened before it + can be locked. + + If \a block is true, this function will block until the lock is + aquired. If \a block is false, this function returns \e false + immediately if the lock cannot be aquired. + + If this object already has a lock of type \a mode, this function + returns \e true immediately. If this object has a lock of a + different type than \a mode, the lock is first released and then a + new lock is obtained. + + This function returns \e true if, after it executes, the file is + locked by this object, and \e false otherwise. + + \sa unlock(), isLocked(), lockMode() +*/ + +/*! + \fn bool QtLockedFile::unlock() + + Releases a lock. + + If the object has no lock, this function returns immediately. + + This function returns \e true if, after it executes, the file is + not locked by this object, and \e false otherwise. + + \sa lock(), isLocked(), lockMode() +*/ + +/*! + \fn QtLockedFile::~QtLockedFile() + + Destroys the \e QtLockedFile object. If any locks were held, they + are released. +*/ diff --git a/libraries/LocalPeer/src/LockedFile.h b/libraries/LocalPeer/src/LockedFile.h new file mode 100644 index 0000000..0d35397 --- /dev/null +++ b/libraries/LocalPeer/src/LockedFile.h @@ -0,0 +1,75 @@ +/**************************************************************************** +** +** Copyright (C) 2013 Digia Plc and/or its subsidiary(-ies). +** Contact: http://www.qt-project.org/legal +** +** This file is part of the Qt Solutions component. +** +** $QT_BEGIN_LICENSE:BSD$ +** You may use this file under the terms of the BSD license as follows: +** +** "Redistribution and use in source and binary forms, with or without +** modification, are permitted provided that the following conditions are +** met: +** * Redistributions of source code must retain the above copyright +** notice, this list of conditions and the following disclaimer. +** * Redistributions in binary form must reproduce the above copyright +** notice, this list of conditions and the following disclaimer in +** the documentation and/or other materials provided with the +** distribution. +** * Neither the name of Digia Plc and its Subsidiary(-ies) nor the names +** of its contributors may be used to endorse or promote products derived +** from this software without specific prior written permission. +** +** +** THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +** "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +** LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +** A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +** OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +** SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +** LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +** DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +** THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +** (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +** OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE." +** +** $QT_END_LICENSE$ +** +****************************************************************************/ + +#pragma once + +#include +#ifdef Q_OS_WIN +#include +#endif + +class LockedFile : public QFile { + public: + enum LockMode { NoLock = 0, ReadLock, WriteLock }; + + LockedFile(); + LockedFile(const QString& name); + ~LockedFile(); + + bool open(OpenMode mode); + + bool lock(LockMode mode, bool block = true); + bool unlock(); + bool isLocked() const; + LockMode lockMode() const; + + private: +#ifdef Q_OS_WIN + Qt::HANDLE wmutex; + Qt::HANDLE rmutex; + QList rmutexes; + QString mutexname; + + Qt::HANDLE getMutexHandle(int idx, bool doCreate); + bool waitMutex(Qt::HANDLE mutex, bool doBlock); +#endif + + LockMode m_lock_mode; +}; diff --git a/libraries/LocalPeer/src/LockedFile_unix.cpp b/libraries/LocalPeer/src/LockedFile_unix.cpp new file mode 100644 index 0000000..d17e625 --- /dev/null +++ b/libraries/LocalPeer/src/LockedFile_unix.cpp @@ -0,0 +1,112 @@ +/**************************************************************************** +** +** Copyright (C) 2013 Digia Plc and/or its subsidiary(-ies). +** Contact: http://www.qt-project.org/legal +** +** This file is part of the Qt Solutions component. +** +** $QT_BEGIN_LICENSE:BSD$ +** You may use this file under the terms of the BSD license as follows: +** +** "Redistribution and use in source and binary forms, with or without +** modification, are permitted provided that the following conditions are +** met: +** * Redistributions of source code must retain the above copyright +** notice, this list of conditions and the following disclaimer. +** * Redistributions in binary form must reproduce the above copyright +** notice, this list of conditions and the following disclaimer in +** the documentation and/or other materials provided with the +** distribution. +** * Neither the name of Digia Plc and its Subsidiary(-ies) nor the names +** of its contributors may be used to endorse or promote products derived +** from this software without specific prior written permission. +** +** +** THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +** "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +** LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +** A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +** OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +** SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +** LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +** DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +** THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +** (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +** OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE." +** +** $QT_END_LICENSE$ +** +****************************************************************************/ + +#include +#include +#include +#include + +#include "LockedFile.h" + +bool LockedFile::lock(LockMode mode, bool block) +{ + if (!isOpen()) { + qWarning("QtLockedFile::lock(): file is not opened"); + return false; + } + + if (mode == NoLock) + return unlock(); + + if (mode == m_lock_mode) + return true; + + if (m_lock_mode != NoLock) + unlock(); + + struct flock fl; + fl.l_whence = SEEK_SET; + fl.l_start = 0; + fl.l_len = 0; + fl.l_type = (mode == ReadLock) ? F_RDLCK : F_WRLCK; + int cmd = block ? F_SETLKW : F_SETLK; + int ret = fcntl(handle(), cmd, &fl); + + if (ret == -1) { + if (errno != EINTR && errno != EAGAIN) + qWarning("QtLockedFile::lock(): fcntl: %s", strerror(errno)); + return false; + } + + m_lock_mode = mode; + return true; +} + +bool LockedFile::unlock() +{ + if (!isOpen()) { + qWarning("QtLockedFile::unlock(): file is not opened"); + return false; + } + + if (!isLocked()) + return true; + + struct flock fl; + fl.l_whence = SEEK_SET; + fl.l_start = 0; + fl.l_len = 0; + fl.l_type = F_UNLCK; + int ret = fcntl(handle(), F_SETLKW, &fl); + + if (ret == -1) { + qWarning("QtLockedFile::lock(): fcntl: %s", strerror(errno)); + return false; + } + + m_lock_mode = NoLock; + return true; +} + +LockedFile::~LockedFile() +{ + if (isOpen()) + unlock(); +} diff --git a/libraries/LocalPeer/src/LockedFile_win.cpp b/libraries/LocalPeer/src/LockedFile_win.cpp new file mode 100644 index 0000000..a8dbead --- /dev/null +++ b/libraries/LocalPeer/src/LockedFile_win.cpp @@ -0,0 +1,197 @@ +/**************************************************************************** +** +** Copyright (C) 2013 Digia Plc and/or its subsidiary(-ies). +** Contact: http://www.qt-project.org/legal +** +** This file is part of the Qt Solutions component. +** +** $QT_BEGIN_LICENSE:BSD$ +** You may use this file under the terms of the BSD license as follows: +** +** "Redistribution and use in source and binary forms, with or without +** modification, are permitted provided that the following conditions are +** met: +** * Redistributions of source code must retain the above copyright +** notice, this list of conditions and the following disclaimer. +** * Redistributions in binary form must reproduce the above copyright +** notice, this list of conditions and the following disclaimer in +** the documentation and/or other materials provided with the +** distribution. +** * Neither the name of Digia Plc and its Subsidiary(-ies) nor the names +** of its contributors may be used to endorse or promote products derived +** from this software without specific prior written permission. +** +** +** THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +** "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +** LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +** A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +** OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +** SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +** LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +** DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +** THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +** (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +** OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE." +** +** $QT_END_LICENSE$ +** +****************************************************************************/ + +#include +#include +#include "LockedFile.h" + +#define MUTEX_PREFIX "QtLockedFile mutex " +// Maximum number of concurrent read locks. Must not be greater than MAXIMUM_WAIT_OBJECTS +#define MAX_READERS MAXIMUM_WAIT_OBJECTS + +Qt::HANDLE LockedFile::getMutexHandle(int idx, bool doCreate) +{ + if (mutexname.isEmpty()) { + QFileInfo fi(*this); + mutexname = QString::fromLatin1(MUTEX_PREFIX) + fi.absoluteFilePath().toLower(); + } + QString mname(mutexname); + if (idx >= 0) + mname += QString::number(idx); + + Qt::HANDLE mutex; + if (doCreate) { + mutex = CreateMutexW(NULL, FALSE, (LPCWSTR)mname.utf16()); + if (!mutex) { + qErrnoWarning("QtLockedFile::lock(): CreateMutex failed"); + return 0; + } + } else { + mutex = OpenMutexW(SYNCHRONIZE | MUTEX_MODIFY_STATE, FALSE, (LPCWSTR)mname.utf16()); + if (!mutex) { + if (GetLastError() != ERROR_FILE_NOT_FOUND) + qErrnoWarning("QtLockedFile::lock(): OpenMutex failed"); + return 0; + } + } + return mutex; +} + +bool LockedFile::waitMutex(Qt::HANDLE mutex, bool doBlock) +{ + Q_ASSERT(mutex); + DWORD res = WaitForSingleObject(mutex, doBlock ? INFINITE : 0); + switch (res) { + case WAIT_OBJECT_0: + case WAIT_ABANDONED: + return true; + break; + case WAIT_TIMEOUT: + break; + default: + qErrnoWarning("QtLockedFile::lock(): WaitForSingleObject failed"); + } + return false; +} + +bool LockedFile::lock(LockMode mode, bool block) +{ + if (!isOpen()) { + qWarning("QtLockedFile::lock(): file is not opened"); + return false; + } + + if (mode == NoLock) + return unlock(); + + if (mode == m_lock_mode) + return true; + + if (m_lock_mode != NoLock) + unlock(); + + if (!wmutex && !(wmutex = getMutexHandle(-1, true))) + return false; + + if (!waitMutex(wmutex, block)) + return false; + + if (mode == ReadLock) { + int idx = 0; + for (; idx < MAX_READERS; idx++) { + rmutex = getMutexHandle(idx, false); + if (!rmutex || waitMutex(rmutex, false)) + break; + CloseHandle(rmutex); + } + bool ok = true; + if (idx >= MAX_READERS) { + qWarning("QtLockedFile::lock(): too many readers"); + rmutex = 0; + ok = false; + } else if (!rmutex) { + rmutex = getMutexHandle(idx, true); + if (!rmutex || !waitMutex(rmutex, false)) + ok = false; + } + if (!ok && rmutex) { + CloseHandle(rmutex); + rmutex = 0; + } + ReleaseMutex(wmutex); + if (!ok) + return false; + } else { + Q_ASSERT(rmutexes.isEmpty()); + for (int i = 0; i < MAX_READERS; i++) { + Qt::HANDLE mutex = getMutexHandle(i, false); + if (mutex) + rmutexes.append(mutex); + } + if (rmutexes.size()) { + DWORD res = WaitForMultipleObjects(rmutexes.size(), rmutexes.constData(), TRUE, block ? INFINITE : 0); + if (res != WAIT_OBJECT_0 && res != WAIT_ABANDONED) { + if (res != WAIT_TIMEOUT) + qErrnoWarning("QtLockedFile::lock(): WaitForMultipleObjects failed"); + m_lock_mode = WriteLock; // trick unlock() to clean up - semiyucky + unlock(); + return false; + } + } + } + + m_lock_mode = mode; + return true; +} + +bool LockedFile::unlock() +{ + if (!isOpen()) { + qWarning("QtLockedFile::unlock(): file is not opened"); + return false; + } + + if (!isLocked()) + return true; + + if (m_lock_mode == ReadLock) { + ReleaseMutex(rmutex); + CloseHandle(rmutex); + rmutex = 0; + } else { + foreach (Qt::HANDLE mutex, rmutexes) { + ReleaseMutex(mutex); + CloseHandle(mutex); + } + rmutexes.clear(); + ReleaseMutex(wmutex); + } + + m_lock_mode = LockedFile::NoLock; + return true; +} + +LockedFile::~LockedFile() +{ + if (isOpen()) + unlock(); + if (wmutex) + CloseHandle(wmutex); +} diff --git a/libraries/README.md b/libraries/README.md new file mode 100644 index 0000000..e15d80e --- /dev/null +++ b/libraries/README.md @@ -0,0 +1,106 @@ +# Third-party libraries + +This folder has third-party or otherwise external libraries needed for other parts to work. + +## javacheck + +Simple Java tool that prints the JVM details - version and platform bitness. + +Do what you want with it. It is so trivial that noone cares. + +## launcher + +Java launcher part for Minecraft. + +It does the following: + +- Waits for a launch script on stdin. +- Consumes the launch script you feed it. +- Proceeds with launch when it gets the `launcher` command. + +If "abort" is sent, the process will exit. + +This means the process is essentially idle until the final command is sent. You can, for example, attach a profiler before you send it. + +The `standard` and `legacy` launchers are available. + +- `standard` can handle launching any Minecraft version, at the cost of some extra features `legacy` enables (custom window icon and title). +- `legacy` is intended for use with Minecraft versions < 1.6 and is deprecated. + +Example (some parts have been censored): + +```text +mod legacyjavafixer-1.0 +mainClass net.minecraft.launchwrapper.Launch +param --username +param CENSORED +param --version +param Prism Launcher +param --gameDir +param /home/peterix/minecraft/FTB/17ForgeTest/minecraft +param --assetsDir +param /home/peterix/minecraft/mmc5/assets +param --assetIndex +param 1.7.10 +param --uuid +param CENSORED +param --accessToken +param CENSORED +param --userProperties +param {} +param --userType +param mojang +param --tweakClass +param cpw.mods.fml.common.launcher.FMLTweaker +windowTitle Prism Launcher: 172ForgeTest +windowParams 854x480 +userName CENSORED +sessionId token:CENSORED:CENSORED +launcher standard +``` + +Available under `GPL-3.0-only` (with classpath exception), sublicensed from its original `Apache-2.0` codebase + +## libnbtplusplus + +libnbt++ is a free C++ library for Minecraft's file format Named Binary Tag (NBT). It can read and write compressed and uncompressed NBT files and provides a code interface for working with NBT data. + +See [github repo](https://github.com/ljfa-ag/libnbtplusplus). + +Available either under LGPL version 3 or later. + +## LocalPeer + +Library for making only one instance of the application run at all times. + +BSD licensed, derived from [QtSingleApplication](https://github.com/qtproject/qt-solutions/tree/master/qtsingleapplication). + +Changes are made to make the code more generic and useful in less usual conditions. + +## murmur2 + +Canonical implementation of the murmur2 hash, taken from [SMHasher](https://github.com/aappleby/smhasher). + +Public domain (the author disclaimed the copyright). + +## rainbow + +Color functions extracted from [KGuiAddons](https://inqlude.org/libraries/kguiaddons.html). Used for adaptive text coloring. + +Available either under LGPL version 2.1 or later. + +## tomlplusplus + +A TOML language parser. Used by Forge 1.14+ to store mod metadata. + +See [github repo](https://github.com/marzer/tomlplusplus). + +Licenced under the MIT licence. + +## qdcss + +A quick and dirty css parser, used by NilLoader to store mod metadata. + +Translated (and heavily trimmed down) from [the original Java code](https://github.com/unascribed/NilLoader/blob/trunk/src/main/java/nilloader/api/lib/qdcss/QDCSS.java) from NilLoader + +Licensed under LGPL version 3. diff --git a/libraries/javacheck/.gitignore b/libraries/javacheck/.gitignore new file mode 100644 index 0000000..cc1c52b --- /dev/null +++ b/libraries/javacheck/.gitignore @@ -0,0 +1,6 @@ +.idea +*.iml +out +.classpath +.idea +.project diff --git a/libraries/javacheck/CMakeLists.txt b/libraries/javacheck/CMakeLists.txt new file mode 100644 index 0000000..84c7579 --- /dev/null +++ b/libraries/javacheck/CMakeLists.txt @@ -0,0 +1,14 @@ +cmake_minimum_required(VERSION 3.15) +project(launcher Java) +find_package(Java 1.8 REQUIRED COMPONENTS Development) + +include(UseJava) +set(CMAKE_JAVA_JAR_ENTRY_POINT JavaCheck) +set(CMAKE_JAVA_COMPILE_FLAGS -target 8 -source 8 -Xlint:deprecation -Xlint:unchecked) + +set(SRC + JavaCheck.java +) + +add_jar(JavaCheck ${SRC}) +install_jar(JavaCheck "${JARS_DEST_DIR}") diff --git a/libraries/javacheck/JavaCheck.java b/libraries/javacheck/JavaCheck.java new file mode 100644 index 0000000..7cde86c --- /dev/null +++ b/libraries/javacheck/JavaCheck.java @@ -0,0 +1,19 @@ +public final class JavaCheck { + private static final String[] CHECKED_PROPERTIES = new String[] {"os.arch", "java.version", "java.vendor"}; + + public static void main(String[] args) { + int returnCode = 0; + + for (String key : CHECKED_PROPERTIES) { + String property = System.getProperty(key); + + if (property != null) { + System.out.println(key + "=" + property); + } else { + returnCode = 1; + } + } + + System.exit(returnCode); + } +} diff --git a/libraries/launcher/.gitignore b/libraries/launcher/.gitignore new file mode 100644 index 0000000..dda456e --- /dev/null +++ b/libraries/launcher/.gitignore @@ -0,0 +1,7 @@ +.idea +*.iml +out +.classpath +.idea +.project +bin/ diff --git a/libraries/launcher/CMakeLists.txt b/libraries/launcher/CMakeLists.txt new file mode 100644 index 0000000..35ba671 --- /dev/null +++ b/libraries/launcher/CMakeLists.txt @@ -0,0 +1,29 @@ +cmake_minimum_required(VERSION 3.15) +project(launcher Java) +find_package(Java 1.8 REQUIRED COMPONENTS Development) + +include(UseJava) +set(CMAKE_JAVA_JAR_ENTRY_POINT org.prismlauncher.EntryPoint) +set(CMAKE_JAVA_COMPILE_FLAGS -target 8 -source 8) + +set(SRC + org/prismlauncher/EntryPoint.java + org/prismlauncher/launcher/Launcher.java + org/prismlauncher/launcher/impl/AbstractLauncher.java + org/prismlauncher/launcher/impl/StandardLauncher.java + org/prismlauncher/exception/ParameterNotFoundException.java + org/prismlauncher/exception/ParseException.java + org/prismlauncher/utils/Parameters.java + org/prismlauncher/utils/ReflectionUtils.java + org/prismlauncher/utils/logging/Level.java + org/prismlauncher/utils/logging/Log.java + org/prismlauncher/legacy/LegacyProxy.java +) + +# Legacy code disabled - requires Java Applets which were removed in Java 11+ +# set(LEGACY_SRC ...) +# add_jar(NewLaunchLegacy ${LEGACY_SRC} INCLUDE_JARS NewLaunch) +# install_jar(NewLaunchLegacy "${JARS_DEST_DIR}") + +add_jar(NewLaunch ${SRC}) +install_jar(NewLaunch "${JARS_DEST_DIR}") diff --git a/libraries/launcher/LICENSE b/libraries/launcher/LICENSE new file mode 120000 index 0000000..30cff74 --- /dev/null +++ b/libraries/launcher/LICENSE @@ -0,0 +1 @@ +../../LICENSE \ No newline at end of file diff --git a/libraries/launcher/legacy/net/minecraft/Launcher.java b/libraries/launcher/legacy/net/minecraft/Launcher.java new file mode 100644 index 0000000..933a814 --- /dev/null +++ b/libraries/launcher/legacy/net/minecraft/Launcher.java @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 icelimetea + * Copyright (C) 2022 TheKodeToad + * Copyright (C) 2022 solonovamax + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * Linking this library statically or dynamically with other modules is + * making a combined work based on this library. Thus, the terms and + * conditions of the GNU General Public License cover the whole + * combination. + * + * As a special exception, the copyright holders of this library give + * you permission to link this library with independent modules to + * produce an executable, regardless of the license terms of these + * independent modules, and to copy and distribute the resulting + * executable under terms of your choice, provided that you also meet, + * for each linked independent module, the terms and conditions of the + * license of that module. An independent module is a module which is + * not derived from or based on this library. If you modify this + * library, you may extend this exception to your version of the + * library, but you are not obliged to do so. If you do not wish to do + * so, delete this exception statement from your version. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.minecraft; + +import java.applet.Applet; +import java.applet.AppletStub; +import java.awt.BorderLayout; +import java.awt.Dimension; +import java.awt.Graphics; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.HashMap; +import java.util.Map; + +/** + * WARNING: This class is reflectively accessed by legacy Forge versions. + *

    + * Changing field and method declarations without further testing is not + * recommended. + */ +public final class Launcher extends Applet implements AppletStub { + private static final long serialVersionUID = 1L; + + private final Map params = new HashMap<>(); + + private Applet wrappedApplet; + private final URL documentBase; + private boolean active = false; + + public Launcher(Applet applet) { + this(applet, null); + } + + public Launcher(Applet applet, URL documentBase) { + setLayout(new BorderLayout()); + + add(applet, "Center"); + + wrappedApplet = applet; + + try { + if (documentBase == null) { + if (applet.getClass().getPackage().getName().startsWith("com.mojang")) + // Special case only for Classic versions + documentBase = new URL("http://www.minecraft.net:80/game/"); + else + documentBase = new URL("http://www.minecraft.net/game/"); + } + } catch (MalformedURLException e) { + throw new AssertionError(e); + } + + this.documentBase = documentBase; + } + + public void replace(Applet applet) { + wrappedApplet = applet; + + applet.setStub(this); + applet.setSize(getWidth(), getHeight()); + + setLayout(new BorderLayout()); + add(applet, "Center"); + + applet.init(); + + active = true; + + applet.start(); + + validate(); + } + + @Override + public boolean isActive() { + return active; + } + + @Override + public URL getDocumentBase() { + return documentBase; + } + + @Override + public URL getCodeBase() { + try { + return new URL("http://www.minecraft.net/game/"); + } catch (MalformedURLException e) { + throw new AssertionError(e); + } + } + + @Override + public String getParameter(String key) { + String param = params.get(key); + + if (param != null) + return param; + + try { + return super.getParameter(key); + } catch (Throwable ignored) { + } + + return null; + } + + @Override + public void resize(int width, int height) { + wrappedApplet.resize(width, height); + } + + @Override + public void resize(Dimension size) { + wrappedApplet.resize(size); + } + + @Override + public void init() { + if (wrappedApplet != null) + wrappedApplet.init(); + } + + @Override + public void start() { + wrappedApplet.start(); + + active = true; + } + + @Override + public void stop() { + wrappedApplet.stop(); + + active = false; + } + + @Override + public void destroy() { + wrappedApplet.destroy(); + } + + @Override + public void appletResize(int width, int height) { + wrappedApplet.resize(width, height); + } + + @Override + public void setVisible(boolean visible) { + super.setVisible(visible); + + wrappedApplet.setVisible(visible); + } + + @Override + public void paint(Graphics graphics) {} + + @Override + public void update(Graphics graphics) {} + + public void setParameter(String key, String value) { + params.put(key, value); + } + + public void setParameter(String key, boolean value) { + setParameter(key, value ? "true" : "false"); + } +} diff --git a/libraries/launcher/legacy/org/prismlauncher/legacy/LegacyFrame.java b/libraries/launcher/legacy/org/prismlauncher/legacy/LegacyFrame.java new file mode 100644 index 0000000..8276c23 --- /dev/null +++ b/libraries/launcher/legacy/org/prismlauncher/legacy/LegacyFrame.java @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 icelimetea + * Copyright (C) 2022 flow + * Copyright (C) 2022 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * Linking this library statically or dynamically with other modules is + * making a combined work based on this library. Thus, the terms and + * conditions of the GNU General Public License cover the whole + * combination. + * + * As a special exception, the copyright holders of this library give + * you permission to link this library with independent modules to + * produce an executable, regardless of the license terms of these + * independent modules, and to copy and distribute the resulting + * executable under terms of your choice, provided that you also meet, + * for each linked independent module, the terms and conditions of the + * license of that module. An independent module is a module which is + * not derived from or based on this library. If you modify this + * library, you may extend this exception to your version of the + * library, but you are not obliged to do so. If you do not wish to do + * so, delete this exception statement from your version. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.prismlauncher.legacy; + +import org.prismlauncher.utils.logging.Log; + +import java.applet.Applet; +import java.awt.Dimension; +import java.awt.event.WindowAdapter; +import java.awt.event.WindowEvent; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.List; + +import javax.imageio.ImageIO; +import javax.swing.JFrame; + +import net.minecraft.Launcher; + +final class LegacyFrame extends JFrame { + private static final long serialVersionUID = 1L; + + private final Launcher launcher; + + public LegacyFrame(String title, Applet applet) { + super(title); + + launcher = new Launcher(applet); + + applet.setStub(launcher); + + try { + setIconImage(ImageIO.read(new File("icon.png"))); + } catch (IOException e) { + Log.error("Failed to read window icon", e); + } + + addWindowListener(new ForceExitHandler()); + } + + public void start( + String user, String session, int width, int height, boolean maximize, String serverAddress, String serverPort, boolean demo) { + // Implements support for launching in to multiplayer on classic servers using a + // mpticket file generated by an external program and stored in the instance's + // root folder. + Path instanceFolder = Paths.get(".."); + Path mpticket = instanceFolder.resolve("mpticket"); + Path mpticketCorrupt = instanceFolder.resolve("mpticket.corrupt"); + + if (Files.exists(mpticket)) { + try { + List lines = Files.readAllLines(mpticket, StandardCharsets.UTF_8); + + if (lines.size() < 3) { + Files.move(mpticket, mpticketCorrupt, StandardCopyOption.REPLACE_EXISTING); + + Log.warning("mpticket file is corrupted"); + } else { + // Assumes parameters are valid and in the correct order + launcher.setParameter("server", lines.get(0)); + launcher.setParameter("port", lines.get(1)); + launcher.setParameter("mppass", lines.get(2)); + } + } catch (IOException e) { + Log.error("Failed to read mpticket file", e); + } + } + + if (serverAddress != null) { + launcher.setParameter("server", serverAddress); + launcher.setParameter("port", serverPort); + } + + launcher.setParameter("username", user); + launcher.setParameter("sessionid", session); + launcher.setParameter("stand-alone", true); // Show the quit button. This often doesn't seem to work. + launcher.setParameter("haspaid", true); // Some old versions need this for world saves to work. + launcher.setParameter("demo", demo); + launcher.setParameter("fullscreen", false); + + add(launcher); + + launcher.setPreferredSize(new Dimension(width, height)); + + pack(); + + setLocationRelativeTo(null); + setResizable(true); + + if (maximize) + setExtendedState(MAXIMIZED_BOTH); + + validate(); + + launcher.init(); + launcher.start(); + + setVisible(true); + } + + private final class ForceExitHandler extends WindowAdapter { + @Override + public void windowClosing(WindowEvent event) { + // FIXME better solution + + new Thread(new Runnable() { + @Override + public void run() { + try { + Thread.sleep(30000L); + } catch (InterruptedException e) { + Log.error("Thread interrupted", e); + } + + Log.warning("Forcing exit"); + System.exit(0); + } + }).start(); + + if (launcher != null) { + launcher.stop(); + launcher.destroy(); + } + + // old minecraft versions can hang without this >_< + System.exit(0); + } + } +} diff --git a/libraries/launcher/legacy/org/prismlauncher/legacy/LegacyLauncher.java b/libraries/launcher/legacy/org/prismlauncher/legacy/LegacyLauncher.java new file mode 100644 index 0000000..02f77e0 --- /dev/null +++ b/libraries/launcher/legacy/org/prismlauncher/legacy/LegacyLauncher.java @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 icelimetea + * Copyright (C) 2022 flow + * Copyright (C) 2022 TheKodeToad + * Copyright (C) 2022 solonovamax + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * Linking this library statically or dynamically with other modules is + * making a combined work based on this library. Thus, the terms and + * conditions of the GNU General Public License cover the whole + * combination. + * + * As a special exception, the copyright holders of this library give + * you permission to link this library with independent modules to + * produce an executable, regardless of the license terms of these + * independent modules, and to copy and distribute the resulting + * executable under terms of your choice, provided that you also meet, + * for each linked independent module, the terms and conditions of the + * license of that module. An independent module is a module which is + * not derived from or based on this library. If you modify this + * library, you may extend this exception to your version of the + * library, but you are not obliged to do so. If you do not wish to do + * so, delete this exception statement from your version. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.prismlauncher.legacy; + +import org.prismlauncher.launcher.impl.AbstractLauncher; +import org.prismlauncher.utils.Parameters; +import org.prismlauncher.utils.ReflectionUtils; +import org.prismlauncher.utils.logging.Log; + +import java.applet.Applet; +import java.io.File; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.Collections; +import java.util.List; + +/** + * Used to launch old versions which support applets. + */ +final class LegacyLauncher extends AbstractLauncher { + private final String user, session; + private final String title; + private final String appletClass; + private final boolean useApplet; + private final String gameDir; + + public LegacyLauncher(Parameters params) { + super(params); + + user = params.getString("userName"); + session = params.getString("sessionId"); + title = params.getString("windowTitle", "Minecraft"); + appletClass = params.getString("appletClass", "net.minecraft.client.MinecraftApplet"); + + List traits = params.getList("traits", Collections.emptyList()); + useApplet = !traits.contains("noapplet"); + + gameDir = System.getProperty("user.dir"); + } + + @Override + public void launch() throws Throwable { + Class main = ClassLoader.getSystemClassLoader().loadClass(mainClassName); + Field gameDirField = findMinecraftGameDirField(main); + + if (gameDirField != null) { + gameDirField.setAccessible(true); + gameDirField.set(null, new File(gameDir)); + } + + if (useApplet) { + System.setProperty("minecraft.applet.TargetDirectory", gameDir); + + try { + LegacyFrame window = new LegacyFrame(title, createAppletClass(appletClass)); + + window.start(user, session, width, height, maximize, serverAddress, serverPort, gameArgs.contains("--demo")); + return; + } catch (Throwable e) { + Log.error("Running applet wrapper failed with exception; falling back to main class", e); + } + } + + // find and invoke the main method, this time without size parameters - in all + // versions that support applets, these are ignored + MethodHandle method = ReflectionUtils.findMainMethod(main); + method.invokeExact(gameArgs.toArray(new String[0])); + } + + private static Applet createAppletClass(String clazz) throws Throwable { + Class appletClass = ClassLoader.getSystemClassLoader().loadClass(clazz); + + MethodHandle appletConstructor = MethodHandles.lookup().findConstructor(appletClass, MethodType.methodType(void.class)); + return (Applet) appletConstructor.invoke(); + } + + private static Field findMinecraftGameDirField(Class clazz) { + // search for private static File + for (Field field : clazz.getDeclaredFields()) { + if (field.getType() != File.class) + continue; + + int fieldModifiers = field.getModifiers(); + + if (!Modifier.isStatic(fieldModifiers)) + continue; + + if (!Modifier.isPrivate(fieldModifiers)) + continue; + + if (Modifier.isFinal(fieldModifiers)) + continue; + + return field; + } + + return null; + } +} diff --git a/libraries/launcher/legacy/org/prismlauncher/legacy/LegacyProxy.java b/libraries/launcher/legacy/org/prismlauncher/legacy/LegacyProxy.java new file mode 100644 index 0000000..4c5c28c --- /dev/null +++ b/libraries/launcher/legacy/org/prismlauncher/legacy/LegacyProxy.java @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * Linking this library statically or dynamically with other modules is + * making a combined work based on this library. Thus, the terms and + * conditions of the GNU General Public License cover the whole + * combination. + * + * As a special exception, the copyright holders of this library give + * you permission to link this library with independent modules to + * produce an executable, regardless of the license terms of these + * independent modules, and to copy and distribute the resulting + * executable under terms of your choice, provided that you also meet, + * for each linked independent module, the terms and conditions of the + * license of that module. An independent module is a module which is + * not derived from or based on this library. If you modify this + * library, you may extend this exception to your version of the + * library, but you are not obliged to do so. If you do not wish to do + * so, delete this exception statement from your version. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.prismlauncher.legacy; + +import org.prismlauncher.launcher.Launcher; +import org.prismlauncher.legacy.fix.online.OnlineFixes; +import org.prismlauncher.utils.Parameters; + +// implementation of LegacyProxy +public final class LegacyProxy { + public static Launcher createLauncher(Parameters params) { + return new LegacyLauncher(params); + } + + public static void applyOnlineFixes(Parameters parameters) { + OnlineFixes.apply(parameters); + } +} diff --git a/libraries/launcher/legacy/org/prismlauncher/legacy/fix/online/Handler.java b/libraries/launcher/legacy/org/prismlauncher/legacy/fix/online/Handler.java new file mode 100644 index 0000000..5ef3d7a --- /dev/null +++ b/libraries/launcher/legacy/org/prismlauncher/legacy/fix/online/Handler.java @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * Linking this library statically or dynamically with other modules is + * making a combined work based on this library. Thus, the terms and + * conditions of the GNU General Public License cover the whole + * combination. + * + * As a special exception, the copyright holders of this library give + * you permission to link this library with independent modules to + * produce an executable, regardless of the license terms of these + * independent modules, and to copy and distribute the resulting + * executable under terms of your choice, provided that you also meet, + * for each linked independent module, the terms and conditions of the + * license of that module. An independent module is a module which is + * not derived from or based on this library. If you modify this + * library, you may extend this exception to your version of the + * library, but you are not obliged to do so. If you do not wish to do + * so, delete this exception statement from your version. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.prismlauncher.legacy.fix.online; + +import org.prismlauncher.legacy.utils.url.UrlUtils; + +import java.io.IOException; +import java.net.Proxy; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLStreamHandler; + +final class Handler extends URLStreamHandler { + @Override + protected URLConnection openConnection(URL address) throws IOException { + return openConnection(address, null); + } + + @Override + protected URLConnection openConnection(URL address, Proxy proxy) throws IOException { + URLConnection result; + + // try various fixes... + result = SkinFix.openConnection(address, proxy); + if (result != null) + return result; + + result = OnlineModeFix.openConnection(address, proxy); + if (result != null) + return result; + + // ...then give up and make the request directly + return UrlUtils.openConnection(address, proxy); + } +} diff --git a/libraries/launcher/legacy/org/prismlauncher/legacy/fix/online/OnlineFixes.java b/libraries/launcher/legacy/org/prismlauncher/legacy/fix/online/OnlineFixes.java new file mode 100644 index 0000000..9ba57ff --- /dev/null +++ b/libraries/launcher/legacy/org/prismlauncher/legacy/fix/online/OnlineFixes.java @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * Linking this library statically or dynamically with other modules is + * making a combined work based on this library. Thus, the terms and + * conditions of the GNU General Public License cover the whole + * combination. + * + * As a special exception, the copyright holders of this library give + * you permission to link this library with independent modules to + * produce an executable, regardless of the license terms of these + * independent modules, and to copy and distribute the resulting + * executable under terms of your choice, provided that you also meet, + * for each linked independent module, the terms and conditions of the + * license of that module. An independent module is a module which is + * not derived from or based on this library. If you modify this + * library, you may extend this exception to your version of the + * library, but you are not obliged to do so. If you do not wish to do + * so, delete this exception statement from your version. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.prismlauncher.legacy.fix.online; + +import org.prismlauncher.legacy.utils.Base64; +import org.prismlauncher.legacy.utils.url.UrlUtils; +import org.prismlauncher.utils.Parameters; +import org.prismlauncher.utils.logging.Log; + +import java.net.URL; +import java.net.URLStreamHandler; +import java.net.URLStreamHandlerFactory; + +/** + * Fixes skins by redirecting to other URLs. + * Thanks to MineOnline for the implementation from which this was inspired! + * See https://github.com/ahnewark/MineOnline/tree/main/src/main/java/gg/codie/mineonline/protocol. + * + * @see {@link Handler} + * @see {@link UrlUtils} + */ +public final class OnlineFixes implements URLStreamHandlerFactory { + public static void apply(Parameters params) { + if (!"true".equals(params.getString("onlineFixes", null))) + return; + + if (!UrlUtils.isSupported() || !Base64.isSupported()) { + Log.warning("Cannot access the necessary Java internals for skin fix"); + Log.warning("Turning off online fixes in the settings will silence the warnings"); + return; + } + + try { + URL.setURLStreamHandlerFactory(new OnlineFixes()); + } catch (Error e) { + Log.warning("Cannot apply skin fix: URLStreamHandlerFactory is already set"); + Log.warning("Turning off online fixes in the settings will silence the warnings"); + } + } + + @Override + public URLStreamHandler createURLStreamHandler(String protocol) { + if ("http".equals(protocol)) + return new Handler(); + + return null; + } +} diff --git a/libraries/launcher/legacy/org/prismlauncher/legacy/fix/online/OnlineModeFix.java b/libraries/launcher/legacy/org/prismlauncher/legacy/fix/online/OnlineModeFix.java new file mode 100644 index 0000000..1bab76d --- /dev/null +++ b/libraries/launcher/legacy/org/prismlauncher/legacy/fix/online/OnlineModeFix.java @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * Linking this library statically or dynamically with other modules is + * making a combined work based on this library. Thus, the terms and + * conditions of the GNU General Public License cover the whole + * combination. + * + * As a special exception, the copyright holders of this library give + * you permission to link this library with independent modules to + * produce an executable, regardless of the license terms of these + * independent modules, and to copy and distribute the resulting + * executable under terms of your choice, provided that you also meet, + * for each linked independent module, the terms and conditions of the + * license of that module. An independent module is a module which is + * not derived from or based on this library. If you modify this + * library, you may extend this exception to your version of the + * library, but you are not obliged to do so. If you do not wish to do + * so, delete this exception statement from your version. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.prismlauncher.legacy.fix.online; + +import org.prismlauncher.legacy.utils.url.UrlUtils; + +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.Proxy; +import java.net.URL; +import java.net.URLConnection; + +public final class OnlineModeFix { + public static URLConnection openConnection(URL address, Proxy proxy) throws IOException { + // we start with "http://www.minecraft.net/game/joinserver.jsp?user=..." + if (!(address.getHost().equals("www.minecraft.net") && address.getPath().equals("/game/joinserver.jsp"))) + return null; + + // change it to "https://session.minecraft.net/game/joinserver.jsp?user=..." + // this seems to be the modern version of the same endpoint... + // maybe Mojang planned to patch old versions of the game to use it + // if it ever disappears this should be changed to use sessionserver.mojang.com/session/minecraft/join + // which of course has a different usage requiring JSON serialisation... + URL url; + try { + url = new URL("https", "session.minecraft.net", address.getPort(), address.getFile()); + } catch (MalformedURLException e) { + throw new AssertionError("url should be valid", e); + } + + return UrlUtils.openConnection(url, proxy); + } +} diff --git a/libraries/launcher/legacy/org/prismlauncher/legacy/fix/online/SkinFix.java b/libraries/launcher/legacy/org/prismlauncher/legacy/fix/online/SkinFix.java new file mode 100644 index 0000000..d5b1854 --- /dev/null +++ b/libraries/launcher/legacy/org/prismlauncher/legacy/fix/online/SkinFix.java @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2023 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * Linking this library statically or dynamically with other modules is + * making a combined work based on this library. Thus, the terms and + * conditions of the GNU General Public License cover the whole + * combination. + * + * As a special exception, the copyright holders of this library give + * you permission to link this library with independent modules to + * produce an executable, regardless of the license terms of these + * independent modules, and to copy and distribute the resulting + * executable under terms of your choice, provided that you also meet, + * for each linked independent module, the terms and conditions of the + * license of that module. An independent module is a module which is + * not derived from or based on this library. If you modify this + * library, you may extend this exception to your version of the + * library, but you are not obliged to do so. If you do not wish to do + * so, delete this exception statement from your version. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.prismlauncher.legacy.fix.online; + +import org.prismlauncher.legacy.utils.api.MojangApi; +import org.prismlauncher.legacy.utils.api.Texture; +import org.prismlauncher.legacy.utils.url.ByteArrayUrlConnection; +import org.prismlauncher.legacy.utils.url.UrlUtils; + +import java.awt.AlphaComposite; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.Proxy; +import java.net.URL; +import java.net.URLConnection; + +import javax.imageio.ImageIO; + +final class SkinFix { + static URLConnection openConnection(URL address, Proxy proxy) throws IOException { + String skinOwner = findSkinOwner(address); + if (skinOwner != null) + // we need to correct the skin + return getSkinConnection(skinOwner, proxy); + + String capeOwner = findCapeOwner(address); + if (capeOwner != null) { + // since we do not need to process the image, open a direct connection bypassing + // Handler + Texture texture = MojangApi.getTexture(MojangApi.getUuid(capeOwner), "CAPE"); + if (texture == null) + return null; + + return UrlUtils.openConnection(texture.getUrl(), proxy); + } + + return null; + } + + private static URLConnection getSkinConnection(String owner, Proxy proxy) throws IOException { + Texture texture = MojangApi.getTexture(MojangApi.getUuid(owner), "SKIN"); + if (texture == null) + return null; + + URLConnection connection = UrlUtils.openConnection(texture.getUrl(), proxy); + try (InputStream in = connection.getInputStream()) { + // thank you ahnewark! + // this is heavily based on + // https://github.com/ahnewark/MineOnline/blob/4f4f86f9d051e0a6fd7ff0b95b2a05f7437683d7/src/main/java/gg/codie/mineonline/gui/textures/TextureHelper.java#L17 + BufferedImage image = ImageIO.read(in); + Graphics2D graphics = image.createGraphics(); + graphics.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER)); + + BufferedImage subimage; + + if (image.getHeight() > 32) { + // flatten second layers + subimage = image.getSubimage(0, 32, 56, 16); + graphics.drawImage(subimage, 0, 16, null); + } + + if (texture.isSlim()) { + // convert slim to classic + subimage = image.getSubimage(45, 16, 9, 16); + graphics.drawImage(subimage, 46, 16, null); + + subimage = image.getSubimage(49, 16, 2, 4); + graphics.drawImage(subimage, 50, 16, null); + + subimage = image.getSubimage(53, 20, 2, 12); + graphics.drawImage(subimage, 54, 20, null); + } + + graphics.dispose(); + + // crop the image - old versions disregard all secondary layers besides the hat + ByteArrayOutputStream out = new ByteArrayOutputStream(); + image = image.getSubimage(0, 0, 64, 32); + ImageIO.write(image, "png", out); + + return new ByteArrayUrlConnection(out.toByteArray()); + } + } + + private static String findSkinOwner(URL address) { + switch (address.getHost()) { + case "www.minecraft.net": + return stripIfPrefixed(address.getPath(), "/skin/"); + + case "s3.amazonaws.com": + case "skins.minecraft.net": + return stripIfPrefixed(address.getPath(), "/MinecraftSkins/"); + } + + return null; + } + + private static String findCapeOwner(URL address) { + switch (address.getHost()) { + case "www.minecraft.net": + if (!address.getPath().equals("/cloak/get.jsp")) + return null; + + return stripIfPrefixed(address.getQuery(), "user="); + + case "s3.amazonaws.com": + case "skins.minecraft.net": + return stripIfPrefixed(address.getPath(), "/MinecraftCloaks/"); + } + + return null; + } + + private static String stripIfPrefixed(String string, String prefix) { + if (string != null && string.startsWith(prefix)) { + string = string.substring(prefix.length()); + + if (string.endsWith(".png")) + string = string.substring(0, string.lastIndexOf('.')); + + return string; + } + + return null; + } +} diff --git a/libraries/launcher/legacy/org/prismlauncher/legacy/utils/Base64.java b/libraries/launcher/legacy/org/prismlauncher/legacy/utils/Base64.java new file mode 100644 index 0000000..a7076f2 --- /dev/null +++ b/libraries/launcher/legacy/org/prismlauncher/legacy/utils/Base64.java @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * Linking this library statically or dynamically with other modules is + * making a combined work based on this library. Thus, the terms and + * conditions of the GNU General Public License cover the whole + * combination. + * + * As a special exception, the copyright holders of this library give + * you permission to link this library with independent modules to + * produce an executable, regardless of the license terms of these + * independent modules, and to copy and distribute the resulting + * executable under terms of your choice, provided that you also meet, + * for each linked independent module, the terms and conditions of the + * license of that module. An independent module is a module which is + * not derived from or based on this library. If you modify this + * library, you may extend this exception to your version of the + * library, but you are not obliged to do so. If you do not wish to do + * so, delete this exception statement from your version. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.prismlauncher.legacy.utils; + +import org.prismlauncher.utils.logging.Log; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.nio.charset.StandardCharsets; + +/** + * Uses Base64 with Java 8 or later, otherwise DatatypeConverter. In the latter + * case, reflection is used to allow using newer compilers. + */ +public final class Base64 { + private static boolean supported = true; + private static MethodHandle legacy; + + static { + try { + Class.forName("java.util.Base64"); + } catch (ClassNotFoundException e) { + try { + Class datatypeConverter = Class.forName("javax.xml.bind.DatatypeConverter"); + legacy = MethodHandles.lookup().findStatic( + datatypeConverter, "parseBase64Binary", MethodType.methodType(byte[].class, String.class)); + } catch (ClassNotFoundException | NoSuchMethodException | IllegalAccessException e1) { + Log.error("Base64 not supported", e1); + supported = false; + } + } + } + + /** + * Determines whether base64 is supported. + * + * @return true if base64 can be parsed + */ + public static boolean isSupported() { + return supported; + } + + public static byte[] decode(String input) { + if (!isSupported()) + throw new UnsupportedOperationException(); + + if (legacy == null) + return java.util.Base64.getDecoder().decode(input.getBytes(StandardCharsets.UTF_8)); + + try { + return (byte[]) legacy.invokeExact(input); + } catch (Error | RuntimeException e) { + throw e; + } catch (Throwable e) { + throw new Error(e); + } + } +} diff --git a/libraries/launcher/legacy/org/prismlauncher/legacy/utils/api/MojangApi.java b/libraries/launcher/legacy/org/prismlauncher/legacy/utils/api/MojangApi.java new file mode 100644 index 0000000..34313e9 --- /dev/null +++ b/libraries/launcher/legacy/org/prismlauncher/legacy/utils/api/MojangApi.java @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * Linking this library statically or dynamically with other modules is + * making a combined work based on this library. Thus, the terms and + * conditions of the GNU General Public License cover the whole + * combination. + * + * As a special exception, the copyright holders of this library give + * you permission to link this library with independent modules to + * produce an executable, regardless of the license terms of these + * independent modules, and to copy and distribute the resulting + * executable under terms of your choice, provided that you also meet, + * for each linked independent module, the terms and conditions of the + * license of that module. An independent module is a module which is + * not derived from or based on this library. If you modify this + * library, you may extend this exception to your version of the + * library, but you are not obliged to do so. If you do not wish to do + * so, delete this exception statement from your version. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.prismlauncher.legacy.utils.api; + +import org.prismlauncher.legacy.utils.Base64; +import org.prismlauncher.legacy.utils.json.JsonParser; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.util.Map; + +/** + * Basic wrapper for Mojang's Minecraft API. + */ +@SuppressWarnings("unchecked") +public final class MojangApi { + public static String getUuid(String username) throws IOException { + try (InputStream in = new URL("https://api.minecraftservices.com/minecraft/profile/lookup/name/" + username).openStream()) { + Map map = (Map) JsonParser.parse(in); + return (String) map.get("id"); + } + } + + public static Texture getTexture(String player, String id) throws IOException { + Map map = getTextures(player); + + if (map != null) { + map = (Map) map.get(id); + if (map == null) + return null; + + URL url = new URL((String) map.get("url")); + boolean slim = false; + + if (id.equals("SKIN")) { + map = (Map) map.get("metadata"); + if (map != null && "slim".equals(map.get("model"))) + slim = true; + } + + return new Texture(url, slim); + } + + return null; + } + + public static Map getTextures(String player) throws IOException { + try (InputStream profileIn = new URL("https://sessionserver.mojang.com/session/minecraft/profile/" + player).openStream()) { + Map profile = (Map) JsonParser.parse(profileIn); + + for (Map property : (Iterable>) profile.get("properties")) { + if (property.get("name").equals("textures")) { + Map result = + (Map) JsonParser.parse(new String(Base64.decode((String) property.get("value")))); + result = (Map) result.get("textures"); + + return result; + } + } + + return null; + } + } +} diff --git a/libraries/launcher/legacy/org/prismlauncher/legacy/utils/api/Texture.java b/libraries/launcher/legacy/org/prismlauncher/legacy/utils/api/Texture.java new file mode 100644 index 0000000..094b08b --- /dev/null +++ b/libraries/launcher/legacy/org/prismlauncher/legacy/utils/api/Texture.java @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * Linking this library statically or dynamically with other modules is + * making a combined work based on this library. Thus, the terms and + * conditions of the GNU General Public License cover the whole + * combination. + * + * As a special exception, the copyright holders of this library give + * you permission to link this library with independent modules to + * produce an executable, regardless of the license terms of these + * independent modules, and to copy and distribute the resulting + * executable under terms of your choice, provided that you also meet, + * for each linked independent module, the terms and conditions of the + * license of that module. An independent module is a module which is + * not derived from or based on this library. If you modify this + * library, you may extend this exception to your version of the + * library, but you are not obliged to do so. If you do not wish to do + * so, delete this exception statement from your version. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.prismlauncher.legacy.utils.api; + +import java.net.URL; + +/** + * Represents a texture from the Mojang API. + */ +public final class Texture { + private final URL url; + private final boolean slim; + + public Texture(URL url, boolean slim) { + this.url = url; + this.slim = slim; + } + + public URL getUrl() { + return url; + } + + public boolean isSlim() { + return slim; + } +} diff --git a/libraries/launcher/legacy/org/prismlauncher/legacy/utils/json/JsonParseException.java b/libraries/launcher/legacy/org/prismlauncher/legacy/utils/json/JsonParseException.java new file mode 100644 index 0000000..a43876c --- /dev/null +++ b/libraries/launcher/legacy/org/prismlauncher/legacy/utils/json/JsonParseException.java @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * Linking this library statically or dynamically with other modules is + * making a combined work based on this library. Thus, the terms and + * conditions of the GNU General Public License cover the whole + * combination. + * + * As a special exception, the copyright holders of this library give + * you permission to link this library with independent modules to + * produce an executable, regardless of the license terms of these + * independent modules, and to copy and distribute the resulting + * executable under terms of your choice, provided that you also meet, + * for each linked independent module, the terms and conditions of the + * license of that module. An independent module is a module which is + * not derived from or based on this library. If you modify this + * library, you may extend this exception to your version of the + * library, but you are not obliged to do so. If you do not wish to do + * so, delete this exception statement from your version. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.prismlauncher.legacy.utils.json; + +import java.io.IOException; + +public final class JsonParseException extends IOException { + private static final long serialVersionUID = 1L; + + public JsonParseException(String message) { + super(message); + } +} diff --git a/libraries/launcher/legacy/org/prismlauncher/legacy/utils/json/JsonParser.java b/libraries/launcher/legacy/org/prismlauncher/legacy/utils/json/JsonParser.java new file mode 100644 index 0000000..9ce24de --- /dev/null +++ b/libraries/launcher/legacy/org/prismlauncher/legacy/utils/json/JsonParser.java @@ -0,0 +1,408 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * Linking this library statically or dynamically with other modules is + * making a combined work based on this library. Thus, the terms and + * conditions of the GNU General Public License cover the whole + * combination. + * + * As a special exception, the copyright holders of this library give + * you permission to link this library with independent modules to + * produce an executable, regardless of the license terms of these + * independent modules, and to copy and distribute the resulting + * executable under terms of your choice, provided that you also meet, + * for each linked independent module, the terms and conditions of the + * license of that module. An independent module is a module which is + * not derived from or based on this library. If you modify this + * library, you may extend this exception to your version of the + * library, but you are not obliged to do so. If you do not wish to do + * so, delete this exception statement from your version. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.prismlauncher.legacy.utils.json; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * A lightweight portable JSON parser used instead of GSON since it is not + * available in a lot of versions. + */ +public final class JsonParser { + private final Reader in; + private char[] buffer; + private int pos, length; + + public static Object parse(String in) throws IOException { + return parse(new StringReader(in)); + } + + public static Object parse(InputStream in) throws IOException { + return parse(new InputStreamReader(in, StandardCharsets.UTF_8)); + } + + public static Object parse(Reader in) throws IOException { + return new JsonParser(in).readSingleValue(); + } + + private JsonParser(Reader in) throws IOException { + this.in = in; + pos = length = 0; + read(); + } + + private int character() { + if (length == -1) + return -1; + + return buffer[pos]; + } + + private int read() throws IOException { + if (length == -1) + return -1; + + if (buffer == null || pos++ == length - 1) { + pos = 0; + buffer = new char[8192]; + length = in.read(buffer); + } + + return character(); + } + + private void assertCharacter(char character) throws JsonParseException { + if (character() != character) + throw new JsonParseException( + "Expected '" + character + "' but got " + (character() != -1 ? ("'" + (char) character() + "'") : "EOF")); + } + + private void assertNoEOF(String expected) throws JsonParseException { + if (character() == -1) + throw new JsonParseException("Expected " + expected + " but got EOF"); + } + + private void skipWhitespace() throws IOException { + while (isWhitespace()) read(); + } + + private boolean isWhitespace() { + return character() == ' ' || character() == '\n' || character() == '\r' || character() == '\t'; + } + + private Object readSingleValue() throws IOException { + skipWhitespace(); + Object result = readValue(); + + if (!(result instanceof Double)) + read(); + + skipWhitespace(); + + if (character() != -1) + throw new JsonParseException("Found trailing non-whitespace characters"); + + return result; + } + + private Object readValue() throws IOException { + assertNoEOF("a value"); + + int character = character(); + + switch (character) { + case '{': + return readObject(); + + case '[': + return readArray(); + + case '"': + return readString(); + + case 't': + case 'f': + // probably boolean + Boolean bool = readBoolean(); + if (bool != null) + return bool; + + break; + + case 'n': + // probably null + if (readNull()) + return null; + + break; + } + + if (character == '-' || isDigit()) + // probably a number + return readNumber(); + + throw new JsonParseException("Expected a JSON value but got '" + (char) character + "'"); + } + + private Map readObject() throws IOException { + assertCharacter('{'); + Map obj = new HashMap<>(); + boolean comma = false; + + read(); + skipWhitespace(); + + while (character() != '}') { + if (comma) { + assertCharacter(','); + read(); + skipWhitespace(); + } + + String key = readString(); + read(); + skipWhitespace(); + assertCharacter(':'); + read(); + skipWhitespace(); + + Object value = readValue(); + obj.put(key, value); + + if (!(value instanceof Double)) + read(); + + skipWhitespace(); + comma = true; + } + + return obj; + } + + private List readArray() throws IOException { + assertCharacter('['); + List array = new ArrayList<>(); + boolean comma = false; + + read(); + skipWhitespace(); + + while (character() != ']') { + if (comma) { + assertCharacter(','); + read(); + skipWhitespace(); + } + + Object value = readValue(); + array.add(value); + + if (!(value instanceof Double)) + read(); + + skipWhitespace(); + comma = true; + } + + return array; + } + + private String readString() throws IOException { + assertCharacter('"'); + + StringBuilder result = new StringBuilder(); + + while (read() != '"') { + int character = character(); + + if (character >= '\u0000' && character <= '\u001F') + throw new JsonParseException("Found unescaped control character within string"); + + switch (character) { + case -1: + throw new JsonParseException("Expected '\"' but got EOF"); + + case 0x7F: + if (read() == '"') { + return result.toString(); + } + continue; + + case '\\': + int seq = read(); + + switch (seq) { + case -1: + throw new JsonParseException("Expected an escape sequence but got EOF"); + + case '\\': + break; + + case '/': + case '\"': + character = seq; + break; + + case 'b': + character = '\b'; + break; + + case 'f': + character = '\f'; + break; + + case 'n': + character = '\n'; + break; + + case 'r': + character = '\r'; + break; + + case 't': + character = '\t'; + break; + + case 'u': + // char array to allow allocation in advance. + char[] digits = new char[4]; + + for (int index = 0; index < digits.length; index++) { + character = read(); + if (index == 0 && character() == '-') { + throw new JsonParseException("Hex sequence may not be negative"); + } else if (character() == -1) { + throw new JsonParseException("Expected a hex sequence but got EOF"); + } + digits[index] = (char) character; + } + + String digitsString = new String(digits); + + try { + character = Integer.parseInt(digitsString, 16); + } catch (NumberFormatException e) { + throw new JsonParseException("Could not parse hex sequence \"" + digitsString + "\""); + } + + break; + default: + throw new JsonParseException("Invalid escape sequence: \\" + (char) seq); + } + break; + } + + result.append((char) character); + } + + return result.toString(); + } + + private boolean isDigit() { + return character() >= '0' && character() <= '9'; + } + + private Double readNumber() throws IOException { + StringBuilder result = new StringBuilder(); + + if (character() == '-') { + result.append((char) character()); + read(); + } + + if (character() == '0') { + result.append((char) character()); + read(); + + if (isDigit()) + throw new JsonParseException("Found superfluous leading zero"); + } else if (!isDigit()) + throw new JsonParseException("Expected digits"); + + while (character() != -1 && isDigit()) { + result.append((char) character()); + read(); + } + + if (character() == '.') { + result.append('.'); + + read(); + assertNoEOF("digits"); + + if (!isDigit()) + throw new JsonParseException("Expected digits after decimal point"); + + while (character() != -1 && isDigit()) { + result.append((char) character()); + read(); + } + } + + if (character() == 'e' || character() == 'E') { + result.append('E'); + + read(); + assertNoEOF("digits"); + + if (character() == '+' || character() == '-') { + result.append((char) character()); + read(); + } + + if (!(character() == '+' || character() == '-' || isDigit())) + throw new JsonParseException("Expected exponent digits"); + + while (character() != -1 && isDigit()) { + result.append((char) character()); + read(); + } + } + + String resultStr = result.toString(); + + try { + return Double.parseDouble(resultStr); + } catch (NumberFormatException e) { + throw new JsonParseException("Failed to parse number '" + resultStr + "'"); + } + } + + private Boolean readBoolean() throws IOException { + if (character() == 't') { + if (read() == 'r' && read() == 'u' && read() == 'e') { + return true; + } + } else if (character() == 'f' && read() == 'a' && read() == 'l' && read() == 's' && read() == 'e') { + return false; + } + + return null; + } + + private boolean readNull() throws IOException { + return character() == 'n' && read() == 'u' && read() == 'l' && read() == 'l'; + } +} diff --git a/libraries/launcher/legacy/org/prismlauncher/legacy/utils/url/ByteArrayUrlConnection.java b/libraries/launcher/legacy/org/prismlauncher/legacy/utils/url/ByteArrayUrlConnection.java new file mode 100644 index 0000000..bc9cf2c --- /dev/null +++ b/libraries/launcher/legacy/org/prismlauncher/legacy/utils/url/ByteArrayUrlConnection.java @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * Linking this library statically or dynamically with other modules is + * making a combined work based on this library. Thus, the terms and + * conditions of the GNU General Public License cover the whole + * combination. + * + * As a special exception, the copyright holders of this library give + * you permission to link this library with independent modules to + * produce an executable, regardless of the license terms of these + * independent modules, and to copy and distribute the resulting + * executable under terms of your choice, provided that you also meet, + * for each linked independent module, the terms and conditions of the + * license of that module. An independent module is a module which is + * not derived from or based on this library. If you modify this + * library, you may extend this exception to your version of the + * library, but you are not obliged to do so. If you do not wish to do + * so, delete this exception statement from your version. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.prismlauncher.legacy.utils.url; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; + +public final class ByteArrayUrlConnection extends HttpURLConnection { + private final InputStream in; + + public ByteArrayUrlConnection(byte[] data) { + super(null); + this.in = new ByteArrayInputStream(data); + } + + @Override + public void connect() throws IOException { + responseCode = 200; + } + + @Override + public void disconnect() {} + + @Override + public InputStream getInputStream() throws IOException { + return in; + } + + @Override + public boolean usingProxy() { + return false; + } +} diff --git a/libraries/launcher/legacy/org/prismlauncher/legacy/utils/url/UrlUtils.java b/libraries/launcher/legacy/org/prismlauncher/legacy/utils/url/UrlUtils.java new file mode 100644 index 0000000..ae91b68 --- /dev/null +++ b/libraries/launcher/legacy/org/prismlauncher/legacy/utils/url/UrlUtils.java @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * Linking this library statically or dynamically with other modules is + * making a combined work based on this library. Thus, the terms and + * conditions of the GNU General Public License cover the whole + * combination. + * + * As a special exception, the copyright holders of this library give + * you permission to link this library with independent modules to + * produce an executable, regardless of the license terms of these + * independent modules, and to copy and distribute the resulting + * executable under terms of your choice, provided that you also meet, + * for each linked independent module, the terms and conditions of the + * license of that module. An independent module is a module which is + * not derived from or based on this library. If you modify this + * library, you may extend this exception to your version of the + * library, but you are not obliged to do so. If you do not wish to do + * so, delete this exception statement from your version. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.prismlauncher.legacy.utils.url; + +import org.prismlauncher.utils.logging.Log; + +import java.io.IOException; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.reflect.Method; +import java.net.Proxy; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLStreamHandler; + +/** + * A utility class for URLs which uses reflection to access constructors for + * internal classes. + */ +public final class UrlUtils { + private static URLStreamHandler http; + private static MethodHandle openConnection; + + static { + try { + // we first obtain the stock URLStreamHandler for http as we overwrite it later + Method getURLStreamHandler = URL.class.getDeclaredMethod("getURLStreamHandler", String.class); + getURLStreamHandler.setAccessible(true); + http = (URLStreamHandler) getURLStreamHandler.invoke(null, "http"); + + // we next find the openConnection method + Method openConnectionReflect = URLStreamHandler.class.getDeclaredMethod("openConnection", URL.class, Proxy.class); + openConnectionReflect.setAccessible(true); + openConnection = MethodHandles.lookup().unreflect(openConnectionReflect); + } catch (Throwable e) { + Log.error("URL reflection failed - some features may not work", e); + } + } + + /** + * Determines whether all the features of this class are available. + * + * @return true if all features can be used + */ + public static boolean isSupported() { + return http != null && openConnection != null; + } + + public static URLConnection openConnection(URL url, Proxy proxy) throws IOException { + if (http == null) + throw new UnsupportedOperationException(); + + if (url.getProtocol().equals("http")) + return openConnection(http, url, proxy); + + // fall back to Java's default method + // at this point, this should not cause a StackOverflowError unless we've missed + // a protocol out from the if statements + return url.openConnection(); + } + + public static URLConnection openConnection(URLStreamHandler handler, URL url, Proxy proxy) throws IOException { + if (openConnection == null) + throw new UnsupportedOperationException(); + + try { + return (URLConnection) openConnection.invokeExact(handler, url, proxy); + } catch (IOException | Error | RuntimeException e) { + throw e; // rethrow if possible + } catch (Throwable e) { + throw new AssertionError("openConnection should not throw", e); // oh dear! this isn't meant to happen + } + } +} diff --git a/libraries/launcher/org/prismlauncher/EntryPoint.java b/libraries/launcher/org/prismlauncher/EntryPoint.java new file mode 100644 index 0000000..8b9046f --- /dev/null +++ b/libraries/launcher/org/prismlauncher/EntryPoint.java @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 icelimetea + * Copyright (C) 2022 TheKodeToad + * Copyright (C) 2022 solonovamax + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * Linking this library statically or dynamically with other modules is + * making a combined work based on this library. Thus, the terms and + * conditions of the GNU General Public License cover the whole + * combination. + * + * As a special exception, the copyright holders of this library give + * you permission to link this library with independent modules to + * produce an executable, regardless of the license terms of these + * independent modules, and to copy and distribute the resulting + * executable under terms of your choice, provided that you also meet, + * for each linked independent module, the terms and conditions of the + * license of that module. An independent module is a module which is + * not derived from or based on this library. If you modify this + * library, you may extend this exception to your version of the + * library, but you are not obliged to do so. If you do not wish to do + * so, delete this exception statement from your version. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.prismlauncher; + +import org.prismlauncher.exception.ParseException; +import org.prismlauncher.launcher.Launcher; +import org.prismlauncher.launcher.impl.StandardLauncher; +import org.prismlauncher.legacy.LegacyProxy; +import org.prismlauncher.utils.Parameters; +import org.prismlauncher.utils.logging.Log; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; + +public final class EntryPoint { + public static void main(String[] args) { + ExitCode code = listen(); + + if (code != ExitCode.NORMAL) { + Log.fatal("Exiting with " + code); + + System.exit(code.numeric); + } + } + + private static ExitCode listen() { + Parameters params = new Parameters(); + PreLaunchAction action = PreLaunchAction.PROCEED; + + try (BufferedReader reader = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8))) { + while (action == PreLaunchAction.PROCEED) { + String line = reader.readLine(); + if (line != null) + action = parseLine(line, params); + else + action = PreLaunchAction.ABORT; + } + } catch (IllegalArgumentException e) { + Log.fatal("Aborting due to wrong argument", e); + + return ExitCode.ILLEGAL_ARGUMENT; + } catch (Throwable e) { + Log.fatal("Aborting due to exception", e); + + return ExitCode.ABORT; + } + + if (action == PreLaunchAction.ABORT) { + Log.fatal("Launch aborted by the launcher"); + + return ExitCode.ABORT; + } + + SystemProperties.apply(params); + + String launcherType = params.getString("launcher"); + + try { + LegacyProxy.applyOnlineFixes(params); + + Launcher launcher; + + switch (launcherType) { + case "standard": + launcher = new StandardLauncher(params); + break; + + case "legacy": + launcher = LegacyProxy.createLauncher(params); + break; + + default: + throw new IllegalArgumentException("Invalid launcher type: " + launcherType); + } + + launcher.launch(); + + return ExitCode.NORMAL; + } catch (IllegalArgumentException e) { + Log.fatal("Illegal argument", e); + + return ExitCode.ILLEGAL_ARGUMENT; + } catch (Throwable e) { + Log.fatal("Exception caught from launcher", e); + + return ExitCode.ERROR; + } + } + + private static PreLaunchAction parseLine(String input, Parameters params) throws ParseException { + switch (input) { + case "": + return PreLaunchAction.PROCEED; + + case "launch": + return PreLaunchAction.LAUNCH; + + case "abort": + return PreLaunchAction.ABORT; + + default: + String[] pair = input.split(" ", 2); + + if (pair.length != 2) + throw new ParseException(input, "[key] [value]"); + + params.add(pair[0], pair[1]); + + return PreLaunchAction.PROCEED; + } + } + + private enum PreLaunchAction { PROCEED, LAUNCH, ABORT } + + private enum ExitCode { + NORMAL(0), + ABORT(1), + ERROR(2), + ILLEGAL_ARGUMENT(65); + + private final int numeric; + + ExitCode(int numeric) { + this.numeric = numeric; + } + } +} diff --git a/libraries/launcher/org/prismlauncher/SystemProperties.java b/libraries/launcher/org/prismlauncher/SystemProperties.java new file mode 100644 index 0000000..5120e93 --- /dev/null +++ b/libraries/launcher/org/prismlauncher/SystemProperties.java @@ -0,0 +1,38 @@ +package org.prismlauncher; + +import org.prismlauncher.utils.Parameters; + +public final class SystemProperties { + public static void apply(Parameters params) { + String launcherBrand = params.getString("launcherBrand", null); + String launcherVersion = params.getString("launcherVersion", null); + String name = params.getString("instanceName", null); + String iconId = params.getString("instanceIconKey", null); + String iconPath = params.getString("instanceIconPath", null); + String windowTitle = params.getString("windowTitle", null); + String windowDimensions = params.getString("windowParams", null); + + if (launcherBrand != null) + System.setProperty("minecraft.launcher.brand", launcherBrand); + if (launcherVersion != null) + System.setProperty("minecraft.launcher.version", launcherVersion); + + // set useful properties for mods + if (name != null) + System.setProperty("org.prismlauncher.instance.name", name); + if (iconId != null) + System.setProperty("org.prismlauncher.instance.icon.id", iconId); + if (iconPath != null) + System.setProperty("org.prismlauncher.instance.icon.path", iconPath); + if (windowTitle != null) + System.setProperty("org.prismlauncher.window.title", windowTitle); + if (windowDimensions != null) + System.setProperty("org.prismlauncher.window.dimensions", windowDimensions); + + // set multimc properties for compatibility + if (name != null) + System.setProperty("multimc.instance.title", name); + if (iconId != null) + System.setProperty("multimc.instance.icon", iconId); + } +} diff --git a/libraries/launcher/org/prismlauncher/exception/ParameterNotFoundException.java b/libraries/launcher/org/prismlauncher/exception/ParameterNotFoundException.java new file mode 100644 index 0000000..e9abc5e --- /dev/null +++ b/libraries/launcher/org/prismlauncher/exception/ParameterNotFoundException.java @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 icelimetea + * Copyright (C) 2022 solonovamax + * Copyright (C) 2022 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * Linking this library statically or dynamically with other modules is + * making a combined work based on this library. Thus, the terms and + * conditions of the GNU General Public License cover the whole + * combination. + * + * As a special exception, the copyright holders of this library give + * you permission to link this library with independent modules to + * produce an executable, regardless of the license terms of these + * independent modules, and to copy and distribute the resulting + * executable under terms of your choice, provided that you also meet, + * for each linked independent module, the terms and conditions of the + * license of that module. An independent module is a module which is + * not derived from or based on this library. If you modify this + * library, you may extend this exception to your version of the + * library, but you are not obliged to do so. If you do not wish to do + * so, delete this exception statement from your version. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.prismlauncher.exception; + +public final class ParameterNotFoundException extends IllegalArgumentException { + private static final long serialVersionUID = 1L; + + public ParameterNotFoundException(String key) { + super(String.format("Required parameter '%s' was not found", key)); + } +} diff --git a/libraries/launcher/org/prismlauncher/exception/ParseException.java b/libraries/launcher/org/prismlauncher/exception/ParseException.java new file mode 100644 index 0000000..ae05789 --- /dev/null +++ b/libraries/launcher/org/prismlauncher/exception/ParseException.java @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 icelimetea + * Copyright (C) 2022 solonovamax + * Copyright (C) 2022 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * Linking this library statically or dynamically with other modules is + * making a combined work based on this library. Thus, the terms and + * conditions of the GNU General Public License cover the whole + * combination. + * + * As a special exception, the copyright holders of this library give + * you permission to link this library with independent modules to + * produce an executable, regardless of the license terms of these + * independent modules, and to copy and distribute the resulting + * executable under terms of your choice, provided that you also meet, + * for each linked independent module, the terms and conditions of the + * license of that module. An independent module is a module which is + * not derived from or based on this library. If you modify this + * library, you may extend this exception to your version of the + * library, but you are not obliged to do so. If you do not wish to do + * so, delete this exception statement from your version. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.prismlauncher.exception; + +public final class ParseException extends IllegalArgumentException { + private static final long serialVersionUID = 1L; + + public ParseException(String input, String format) { + super(String.format("For input '%s' - should match '%s'", input, format)); + } +} diff --git a/libraries/launcher/org/prismlauncher/launcher/Launcher.java b/libraries/launcher/org/prismlauncher/launcher/Launcher.java new file mode 100644 index 0000000..5dc0bb1 --- /dev/null +++ b/libraries/launcher/org/prismlauncher/launcher/Launcher.java @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 icelimetea + * Copyright (C) 2022 TheKodeToad + * Copyright (C) 2022 solonovamax + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * Linking this library statically or dynamically with other modules is + * making a combined work based on this library. Thus, the terms and + * conditions of the GNU General Public License cover the whole + * combination. + * + * As a special exception, the copyright holders of this library give + * you permission to link this library with independent modules to + * produce an executable, regardless of the license terms of these + * independent modules, and to copy and distribute the resulting + * executable under terms of your choice, provided that you also meet, + * for each linked independent module, the terms and conditions of the + * license of that module. An independent module is a module which is + * not derived from or based on this library. If you modify this + * library, you may extend this exception to your version of the + * library, but you are not obliged to do so. If you do not wish to do + * so, delete this exception statement from your version. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.prismlauncher.launcher; + +public interface Launcher { + void launch() throws Throwable; +} diff --git a/libraries/launcher/org/prismlauncher/launcher/impl/AbstractLauncher.java b/libraries/launcher/org/prismlauncher/launcher/impl/AbstractLauncher.java new file mode 100644 index 0000000..a5f027b --- /dev/null +++ b/libraries/launcher/org/prismlauncher/launcher/impl/AbstractLauncher.java @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 icelimetea + * Copyright (C) 2022 TheKodeToad + * Copyright (C) 2022 solonovamax + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * Linking this library statically or dynamically with other modules is + * making a combined work based on this library. Thus, the terms and + * conditions of the GNU General Public License cover the whole + * combination. + * + * As a special exception, the copyright holders of this library give + * you permission to link this library with independent modules to + * produce an executable, regardless of the license terms of these + * independent modules, and to copy and distribute the resulting + * executable under terms of your choice, provided that you also meet, + * for each linked independent module, the terms and conditions of the + * license of that module. An independent module is a module which is + * not derived from or based on this library. If you modify this + * library, you may extend this exception to your version of the + * library, but you are not obliged to do so. If you do not wish to do + * so, delete this exception statement from your version. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.prismlauncher.launcher.impl; + +import org.prismlauncher.exception.ParseException; +import org.prismlauncher.launcher.Launcher; +import org.prismlauncher.utils.Parameters; + +import java.util.ArrayList; +import java.util.List; + +public abstract class AbstractLauncher implements Launcher { + private static final int DEFAULT_WINDOW_WIDTH = 854, DEFAULT_WINDOW_HEIGHT = 480; + + // parameters, separated from ParamBucket + protected final List gameArgs; + + // secondary parameters + protected final int width, height; + protected final boolean maximize; + protected final String serverAddress, serverPort, worldName; + + protected final String mainClassName; + + protected AbstractLauncher(Parameters params) { + gameArgs = params.getList("param", new ArrayList()); + mainClassName = params.getString("mainClass", "net.minecraft.client.Minecraft"); + + serverAddress = params.getString("serverAddress", null); + serverPort = params.getString("serverPort", null); + worldName = params.getString("worldName", null); + + String windowParams = params.getString("windowParams", null); + + if ("maximized".equals(windowParams) || windowParams == null) { + maximize = windowParams != null; + + width = DEFAULT_WINDOW_WIDTH; + height = DEFAULT_WINDOW_HEIGHT; + } else { + maximize = false; + + String[] sizePair = windowParams.split("x", 2); + + if (sizePair.length == 2) { + try { + width = Integer.parseInt(sizePair[0]); + height = Integer.parseInt(sizePair[1]); + return; + } catch (NumberFormatException ignored) { + } + } + + throw new ParseException(windowParams, "[width]x[height]"); + } + } +} diff --git a/libraries/launcher/org/prismlauncher/launcher/impl/StandardLauncher.java b/libraries/launcher/org/prismlauncher/launcher/impl/StandardLauncher.java new file mode 100644 index 0000000..968499f --- /dev/null +++ b/libraries/launcher/org/prismlauncher/launcher/impl/StandardLauncher.java @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 icelimetea + * Copyright (C) 2022 TheKodeToad + * Copyright (C) 2022 solonovamax + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * Linking this library statically or dynamically with other modules is + * making a combined work based on this library. Thus, the terms and + * conditions of the GNU General Public License cover the whole + * combination. + * + * As a special exception, the copyright holders of this library give + * you permission to link this library with independent modules to + * produce an executable, regardless of the license terms of these + * independent modules, and to copy and distribute the resulting + * executable under terms of your choice, provided that you also meet, + * for each linked independent module, the terms and conditions of the + * license of that module. An independent module is a module which is + * not derived from or based on this library. If you modify this + * library, you may extend this exception to your version of the + * library, but you are not obliged to do so. If you do not wish to do + * so, delete this exception statement from your version. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.prismlauncher.launcher.impl; + +import org.prismlauncher.utils.Parameters; +import org.prismlauncher.utils.ReflectionUtils; + +import java.lang.invoke.MethodHandle; +import java.util.Collections; +import java.util.List; + +public final class StandardLauncher extends AbstractLauncher { + private final boolean quickPlayMultiplayerSupported; + private final boolean quickPlaySingleplayerSupported; + + public StandardLauncher(Parameters params) { + super(params); + + List traits = params.getList("traits", Collections.emptyList()); + quickPlayMultiplayerSupported = traits.contains("feature:is_quick_play_multiplayer"); + quickPlaySingleplayerSupported = traits.contains("feature:is_quick_play_singleplayer"); + } + + @Override + public void launch() throws Throwable { + // window size, title and state + gameArgs.add("--width"); + gameArgs.add(Integer.toString(width)); + gameArgs.add("--height"); + gameArgs.add(Integer.toString(height)); + + if (serverAddress != null) { + if (quickPlayMultiplayerSupported) { + // as of 23w14a + gameArgs.add("--quickPlayMultiplayer"); + gameArgs.add(serverAddress + ':' + serverPort); + } else { + gameArgs.add("--server"); + gameArgs.add(serverAddress); + gameArgs.add("--port"); + gameArgs.add(serverPort); + } + } else if (worldName != null && quickPlaySingleplayerSupported) { + gameArgs.add("--quickPlaySingleplayer"); + gameArgs.add(worldName); + } + + StringBuilder joinedGameArgs = new StringBuilder(); + for (String gameArg : gameArgs) { + if (joinedGameArgs.length() > 0) { + joinedGameArgs.append('\u001F'); // unit separator, designed for this purpose + } + joinedGameArgs.append(gameArg); + } + + // pass the real main class and game arguments in so mods can access them + System.setProperty("org.prismlauncher.launch.mainclass", mainClassName); + // unit separator ('\u001F') delimited list of game args + System.setProperty("org.prismlauncher.launch.gameargs", joinedGameArgs.toString()); + + // find and invoke the main method + MethodHandle method = ReflectionUtils.findMainMethod(mainClassName); + method.invokeExact(gameArgs.toArray(new String[0])); + } +} diff --git a/libraries/launcher/org/prismlauncher/legacy/LegacyProxy.java b/libraries/launcher/org/prismlauncher/legacy/LegacyProxy.java new file mode 100644 index 0000000..133558c --- /dev/null +++ b/libraries/launcher/org/prismlauncher/legacy/LegacyProxy.java @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * Linking this library statically or dynamically with other modules is + * making a combined work based on this library. Thus, the terms and + * conditions of the GNU General Public License cover the whole + * combination. + * + * As a special exception, the copyright holders of this library give + * you permission to link this library with independent modules to + * produce an executable, regardless of the license terms of these + * independent modules, and to copy and distribute the resulting + * executable under terms of your choice, provided that you also meet, + * for each linked independent module, the terms and conditions of the + * license of that module. An independent module is a module which is + * not derived from or based on this library. If you modify this + * library, you may extend this exception to your version of the + * library, but you are not obliged to do so. If you do not wish to do + * so, delete this exception statement from your version. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.prismlauncher.legacy; + +import org.prismlauncher.launcher.Launcher; +import org.prismlauncher.utils.Parameters; + +// used as a fallback if NewLaunchLegacy is not on the classpath +// if it is, this class will be replaced +public final class LegacyProxy { + public static Launcher createLauncher(Parameters params) { + throw new AssertionError("NewLaunchLegacy is not loaded"); + } + + public static void applyOnlineFixes(Parameters params) {} +} diff --git a/libraries/launcher/org/prismlauncher/utils/Parameters.java b/libraries/launcher/org/prismlauncher/utils/Parameters.java new file mode 100644 index 0000000..2b0aed2 --- /dev/null +++ b/libraries/launcher/org/prismlauncher/utils/Parameters.java @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 icelimetea + * Copyright (C) 2022 TheKodeToad + * Copyright (C) 2022 solonovamax + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * Linking this library statically or dynamically with other modules is + * making a combined work based on this library. Thus, the terms and + * conditions of the GNU General Public License cover the whole + * combination. + * + * As a special exception, the copyright holders of this library give + * you permission to link this library with independent modules to + * produce an executable, regardless of the license terms of these + * independent modules, and to copy and distribute the resulting + * executable under terms of your choice, provided that you also meet, + * for each linked independent module, the terms and conditions of the + * license of that module. An independent module is a module which is + * not derived from or based on this library. If you modify this + * library, you may extend this exception to your version of the + * library, but you are not obliged to do so. If you do not wish to do + * so, delete this exception statement from your version. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.prismlauncher.utils; + +import org.prismlauncher.exception.ParameterNotFoundException; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public final class Parameters { + private final Map> map = new HashMap<>(); + + public void add(String key, String value) { + List params = map.get(key); + + if (params == null) { + params = new ArrayList<>(); + + map.put(key, params); + } + + params.add(value); + } + + public List getList(String key) throws ParameterNotFoundException { + List params = map.get(key); + + if (params == null) + throw new ParameterNotFoundException(key); + + return params; + } + + public List getList(String key, List def) { + List params = map.get(key); + + if (params == null || params.isEmpty()) + return def; + + return params; + } + + public String getString(String key) throws ParameterNotFoundException { + List list = getList(key); + + if (list.isEmpty()) + throw new ParameterNotFoundException(key); + + return list.get(0); + } + + public String getString(String key, String def) { + List params = map.get(key); + + if (params == null || params.isEmpty()) + return def; + + return params.get(0); + } +} diff --git a/libraries/launcher/org/prismlauncher/utils/ReflectionUtils.java b/libraries/launcher/org/prismlauncher/utils/ReflectionUtils.java new file mode 100644 index 0000000..9d03a90 --- /dev/null +++ b/libraries/launcher/org/prismlauncher/utils/ReflectionUtils.java @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 icelimetea + * Copyright (C) 2022 solonovamax + * Copyright (C) 2022 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * Linking this library statically or dynamically with other modules is + * making a combined work based on this library. Thus, the terms and + * conditions of the GNU General Public License cover the whole + * combination. + * + * As a special exception, the copyright holders of this library give + * you permission to link this library with independent modules to + * produce an executable, regardless of the license terms of these + * independent modules, and to copy and distribute the resulting + * executable under terms of your choice, provided that you also meet, + * for each linked independent module, the terms and conditions of the + * license of that module. An independent module is a module which is + * not derived from or based on this library. If you modify this + * library, you may extend this exception to your version of the + * library, but you are not obliged to do so. If you do not wish to do + * so, delete this exception statement from your version. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.prismlauncher.utils; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; + +public final class ReflectionUtils { + private static final MethodHandles.Lookup LOOKUP = MethodHandles.lookup(); + private static final ClassLoader LOADER = ClassLoader.getSystemClassLoader(); + + /** + * Gets the main method within a class. + * + * @param clazz The class + * @return A method matching the descriptor of a main method + * @throws ClassNotFoundException + * @throws NoSuchMethodException + * @throws IllegalAccessException + */ + public static MethodHandle findMainMethod(Class clazz) throws NoSuchMethodException, IllegalAccessException { + return LOOKUP.findStatic(clazz, "main", MethodType.methodType(void.class, String[].class)); + } + + /** + * Gets the main method within a class by its name. + * + * @param clazz The class name + * @return A method matching the descriptor of a main method + * @throws ClassNotFoundException + * @throws NoSuchMethodException + * @throws IllegalAccessException + */ + public static MethodHandle findMainMethod(String clazz) throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException { + return findMainMethod(LOADER.loadClass(clazz)); + } +} diff --git a/libraries/launcher/org/prismlauncher/utils/logging/Level.java b/libraries/launcher/org/prismlauncher/utils/logging/Level.java new file mode 100644 index 0000000..7af9c34 --- /dev/null +++ b/libraries/launcher/org/prismlauncher/utils/logging/Level.java @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * Linking this library statically or dynamically with other modules is + * making a combined work based on this library. Thus, the terms and + * conditions of the GNU General Public License cover the whole + * combination. + * + * As a special exception, the copyright holders of this library give + * you permission to link this library with independent modules to + * produce an executable, regardless of the license terms of these + * independent modules, and to copy and distribute the resulting + * executable under terms of your choice, provided that you also meet, + * for each linked independent module, the terms and conditions of the + * license of that module. An independent module is a module which is + * not derived from or based on this library. If you modify this + * library, you may extend this exception to your version of the + * library, but you are not obliged to do so. If you do not wish to do + * so, delete this exception statement from your version. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.prismlauncher.utils.logging; + +public enum Level { + LAUNCHER("Launcher"), + DEBUG("Debug"), + INFO("Info"), + MESSAGE("Message"), + WARNING("Warning"), + ERROR("Error", true), + FATAL("Fatal", true); + + String name; + boolean stderr; + + Level(String name) { + this(name, false); + } + + Level(String name, boolean stderr) { + this.name = name; + this.stderr = stderr; + } +} diff --git a/libraries/launcher/org/prismlauncher/utils/logging/Log.java b/libraries/launcher/org/prismlauncher/utils/logging/Log.java new file mode 100644 index 0000000..992698e --- /dev/null +++ b/libraries/launcher/org/prismlauncher/utils/logging/Log.java @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2022 TheKodeToad + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * Linking this library statically or dynamically with other modules is + * making a combined work based on this library. Thus, the terms and + * conditions of the GNU General Public License cover the whole + * combination. + * + * As a special exception, the copyright holders of this library give + * you permission to link this library with independent modules to + * produce an executable, regardless of the license terms of these + * independent modules, and to copy and distribute the resulting + * executable under terms of your choice, provided that you also meet, + * for each linked independent module, the terms and conditions of the + * license of that module. An independent module is a module which is + * not derived from or based on this library. If you modify this + * library, you may extend this exception to your version of the + * library, but you are not obliged to do so. If you do not wish to do + * so, delete this exception statement from your version. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.prismlauncher.utils.logging; + +import java.io.PrintStream; + +/** + * Used to print messages with different levels used to colourise the output. + * Used instead of a logging framework, as the launcher knows how to parse these + * messages. + */ +public final class Log { + // original before possibly overridden by MC + private static final PrintStream OUT = new PrintStream(System.out), ERR = new PrintStream(System.err); + private static final boolean DEBUG = Boolean.getBoolean("org.prismlauncher.debug"); + + public static void launcher(String message) { + log(message, Level.LAUNCHER); + } + + public static void error(String message) { + log(message, Level.ERROR); + } + + public static void debug(String message) { + log(message, Level.DEBUG); + } + + public static void warning(String message) { + log(message, Level.WARNING); + } + + public static void error(String message, Throwable e) { + error(message); + e.printStackTrace(ERR); + } + + public static void fatal(String message) { + log(message, Level.FATAL); + } + + public static void fatal(String message, Throwable e) { + fatal(message); + e.printStackTrace(ERR); + } + + /** + * Logs a message with the prefix !![LEVEL]!. This is picked up by + * the log viewer to give it nice colours. + * + * @param message The message + * @param level The level + */ + public static void log(String message, Level level) { + if (!DEBUG && level == Level.DEBUG) + return; + + String prefix = "!![" + level.name + "]!"; + // prefix first line + message = prefix + message; + // prefix subsequent lines + message = message.replace("\n", "\n" + prefix); + + if (level.stderr) + ERR.println(message); + else + OUT.println(message); + } +} diff --git a/libraries/libnbtplusplus/.gitattributes b/libraries/libnbtplusplus/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/libraries/libnbtplusplus/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/libraries/libnbtplusplus/.gitignore b/libraries/libnbtplusplus/.gitignore new file mode 100644 index 0000000..b1ef936 --- /dev/null +++ b/libraries/libnbtplusplus/.gitignore @@ -0,0 +1,11 @@ +*.cbp +*.depend +*.layout +*.cbtemp +*.bak +*.swp +/bin +/lib +/obj +/build +/doxygen diff --git a/libraries/libnbtplusplus/CMakeLists.txt b/libraries/libnbtplusplus/CMakeLists.txt new file mode 100644 index 0000000..f01e80c --- /dev/null +++ b/libraries/libnbtplusplus/CMakeLists.txt @@ -0,0 +1,73 @@ +cmake_minimum_required(VERSION 3.15) +project(libnbt++ + VERSION 2.3) + +# supported configure options +option(NBT_BUILD_SHARED "Build shared libraries" OFF) +option(NBT_USE_ZLIB "Build additional zlib stream functionality" ON) +option(NBT_BUILD_TESTS "Build the unit tests. Requires CxxTest." ON) + +if(NBT_NAME) + message("Using override nbt++ name: ${NBT_NAME}") +else() + set(NBT_NAME nbt++) +endif() + +# hide this from includers. +set(BUILD_SHARED_LIBS ${NBT_BUILD_SHARED}) + +include(GenerateExportHeader) + +set(NBT_SOURCES + src/endian_str.cpp + src/tag.cpp + src/tag_compound.cpp + src/tag_list.cpp + src/tag_string.cpp + src/value.cpp + src/value_initializer.cpp + + src/io/stream_reader.cpp + src/io/stream_writer.cpp + + src/text/json_formatter.cpp) + +set(NBT_SOURCES_Z + src/io/izlibstream.cpp + src/io/ozlibstream.cpp) + +if(NBT_USE_ZLIB) + find_package(ZLIB REQUIRED) + list(APPEND NBT_SOURCES ${NBT_SOURCES_Z}) + add_definitions("-DNBT_HAVE_ZLIB") +endif() + +add_library(${NBT_NAME} ${NBT_SOURCES}) +target_include_directories(${NBT_NAME} PUBLIC include ${CMAKE_CURRENT_BINARY_DIR}) + +# Install it +if(DEFINED NBT_DEST_DIR) + install( + TARGETS ${NBT_NAME} + ARCHIVE DESTINATION ${LIBRARY_DEST_DIR} + RUNTIME DESTINATION ${LIBRARY_DEST_DIR} + LIBRARY DESTINATION ${LIBRARY_DEST_DIR} + ) +endif() + +if(NBT_USE_ZLIB) + target_link_libraries(${NBT_NAME} ZLIB::ZLIB) +endif() +set_property(TARGET ${NBT_NAME} PROPERTY CXX_STANDARD 11) +generate_export_header(${NBT_NAME} BASE_NAME nbt) + +if(${BUILD_SHARED_LIBS}) + set_target_properties(${NBT_NAME} PROPERTIES + CXX_VISIBILITY_PRESET hidden + VISIBILITY_INLINES_HIDDEN 1) +endif() + +if(NBT_BUILD_TESTS) + enable_testing() + add_subdirectory(test) +endif() diff --git a/libraries/libnbtplusplus/COPYING b/libraries/libnbtplusplus/COPYING new file mode 100644 index 0000000..94a9ed0 --- /dev/null +++ b/libraries/libnbtplusplus/COPYING @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/libraries/libnbtplusplus/COPYING.LESSER b/libraries/libnbtplusplus/COPYING.LESSER new file mode 100644 index 0000000..65c5ca8 --- /dev/null +++ b/libraries/libnbtplusplus/COPYING.LESSER @@ -0,0 +1,165 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. diff --git a/libraries/libnbtplusplus/README.md b/libraries/libnbtplusplus/README.md new file mode 100644 index 0000000..6ec6b57 --- /dev/null +++ b/libraries/libnbtplusplus/README.md @@ -0,0 +1,12 @@ +# libnbt++ 2 + +libnbt++ is a free C++ library for Minecraft's file format Named Binary Tag +(NBT). It can read and write compressed and uncompressed NBT files and +provides a code interface for working with NBT data. + +---------- + +libnbt++2 is a remake of the old libnbt++ library with the goal of making it +more easily usable and fixing some problems. The old libnbt++ especially +suffered from a very convoluted syntax and boilerplate code needed to work +with NBT data. diff --git a/libraries/libnbtplusplus/include/crtp_tag.h b/libraries/libnbtplusplus/include/crtp_tag.h new file mode 100644 index 0000000..7b80297 --- /dev/null +++ b/libraries/libnbtplusplus/include/crtp_tag.h @@ -0,0 +1,64 @@ +/* + * libnbt++ - A library for the Minecraft Named Binary Tag format. + * Copyright (C) 2013, 2015 ljfa-ag + * + * This file is part of libnbt++. + * + * libnbt++ is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * libnbt++ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with libnbt++. If not, see . + */ +#ifndef CRTP_TAG_H_INCLUDED +#define CRTP_TAG_H_INCLUDED + +#include "tag.h" +#include "nbt_visitor.h" +#include "make_unique.h" + +namespace nbt +{ + +namespace detail +{ + + template + class crtp_tag : public tag + { + public: + //Pure virtual destructor to make the class abstract + virtual ~crtp_tag() noexcept = 0; + + tag_type get_type() const noexcept override final { return Sub::type; }; + + std::unique_ptr clone() const& override final { return make_unique(sub_this()); } + std::unique_ptr move_clone() && override final { return make_unique(std::move(sub_this())); } + + tag& assign(tag&& rhs) override final { return sub_this() = dynamic_cast(rhs); } + + void accept(nbt_visitor& visitor) override final { visitor.visit(sub_this()); } + void accept(const_nbt_visitor& visitor) const override final { visitor.visit(sub_this()); } + + private: + bool equals(const tag& rhs) const override final { return sub_this() == static_cast(rhs); } + + Sub& sub_this() { return static_cast(*this); } + const Sub& sub_this() const { return static_cast(*this); } + }; + + template + crtp_tag::~crtp_tag() noexcept {} + +} + +} + +#endif // CRTP_TAG_H_INCLUDED diff --git a/libraries/libnbtplusplus/include/endian_str.h b/libraries/libnbtplusplus/include/endian_str.h new file mode 100644 index 0000000..ca36835 --- /dev/null +++ b/libraries/libnbtplusplus/include/endian_str.h @@ -0,0 +1,112 @@ +/* + * libnbt++ - A library for the Minecraft Named Binary Tag format. + * Copyright (C) 2013, 2015 ljfa-ag + * + * This file is part of libnbt++. + * + * libnbt++ is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * libnbt++ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with libnbt++. If not, see . + */ +#ifndef ENDIAN_STR_H_INCLUDED +#define ENDIAN_STR_H_INCLUDED + +#include +#include +#include "nbt_export.h" + +/** + * @brief Reading and writing numbers from and to streams + * in binary format with different byte orders. + */ +namespace endian +{ + +enum endian { little, big }; + +///Reads number from stream in specified endian +template +void read(std::istream& is, T& x, endian e); + +///Reads number from stream in little endian +NBT_EXPORT void read_little(std::istream& is, uint8_t& x); +NBT_EXPORT void read_little(std::istream& is, uint16_t& x); +NBT_EXPORT void read_little(std::istream& is, uint32_t& x); +NBT_EXPORT void read_little(std::istream& is, uint64_t& x); +NBT_EXPORT void read_little(std::istream& is, int8_t& x); +NBT_EXPORT void read_little(std::istream& is, int16_t& x); +NBT_EXPORT void read_little(std::istream& is, int32_t& x); +NBT_EXPORT void read_little(std::istream& is, int64_t& x); +NBT_EXPORT void read_little(std::istream& is, float& x); +NBT_EXPORT void read_little(std::istream& is, double& x); + +///Reads number from stream in big endian +NBT_EXPORT void read_big(std::istream& is, uint8_t& x); +NBT_EXPORT void read_big(std::istream& is, uint16_t& x); +NBT_EXPORT void read_big(std::istream& is, uint32_t& x); +NBT_EXPORT void read_big(std::istream& is, uint64_t& x); +NBT_EXPORT void read_big(std::istream& is, int8_t& x); +NBT_EXPORT void read_big(std::istream& is, int16_t& x); +NBT_EXPORT void read_big(std::istream& is, int32_t& x); +NBT_EXPORT void read_big(std::istream& is, int64_t& x); +NBT_EXPORT void read_big(std::istream& is, float& x); +NBT_EXPORT void read_big(std::istream& is, double& x); + +///Writes number to stream in specified endian +template +void write(std::ostream& os, T x, endian e); + +///Writes number to stream in little endian +NBT_EXPORT void write_little(std::ostream& os, uint8_t x); +NBT_EXPORT void write_little(std::ostream& os, uint16_t x); +NBT_EXPORT void write_little(std::ostream& os, uint32_t x); +NBT_EXPORT void write_little(std::ostream& os, uint64_t x); +NBT_EXPORT void write_little(std::ostream& os, int8_t x); +NBT_EXPORT void write_little(std::ostream& os, int16_t x); +NBT_EXPORT void write_little(std::ostream& os, int32_t x); +NBT_EXPORT void write_little(std::ostream& os, int64_t x); +NBT_EXPORT void write_little(std::ostream& os, float x); +NBT_EXPORT void write_little(std::ostream& os, double x); + +///Writes number to stream in big endian +NBT_EXPORT void write_big(std::ostream& os, uint8_t x); +NBT_EXPORT void write_big(std::ostream& os, uint16_t x); +NBT_EXPORT void write_big(std::ostream& os, uint32_t x); +NBT_EXPORT void write_big(std::ostream& os, uint64_t x); +NBT_EXPORT void write_big(std::ostream& os, int8_t x); +NBT_EXPORT void write_big(std::ostream& os, int16_t x); +NBT_EXPORT void write_big(std::ostream& os, int32_t x); +NBT_EXPORT void write_big(std::ostream& os, int64_t x); +NBT_EXPORT void write_big(std::ostream& os, float x); +NBT_EXPORT void write_big(std::ostream& os, double x); + +template +void read(std::istream& is, T& x, endian e) +{ + if(e == little) + read_little(is, x); + else + read_big(is, x); +} + +template +void write(std::ostream& os, T x, endian e) +{ + if(e == little) + write_little(os, x); + else + write_big(os, x); +} + +} + +#endif // ENDIAN_STR_H_INCLUDED diff --git a/libraries/libnbtplusplus/include/io/izlibstream.h b/libraries/libnbtplusplus/include/io/izlibstream.h new file mode 100644 index 0000000..2b9b91a --- /dev/null +++ b/libraries/libnbtplusplus/include/io/izlibstream.h @@ -0,0 +1,93 @@ +/* + * libnbt++ - A library for the Minecraft Named Binary Tag format. + * Copyright (C) 2013, 2015 ljfa-ag + * + * This file is part of libnbt++. + * + * libnbt++ is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * libnbt++ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with libnbt++. If not, see . + */ +#ifndef IZLIBSTREAM_H_INCLUDED +#define IZLIBSTREAM_H_INCLUDED + +#include "io/zlib_streambuf.h" +#include +#include + +namespace zlib +{ + +/** + * @brief Stream buffer used by zlib::izlibstream + * @sa izlibstream + */ +class NBT_EXPORT inflate_streambuf : public zlib_streambuf +{ +public: + /** + * @param input the istream to wrap + * @param bufsize the size of the internal buffers + * @param window_bits the base two logarithm of the maximum window size that + * zlib will use. + * This parameter also determines which type of input to expect. + * The default argument will autodetect between zlib and gzip data. + * Refer to the zlib documentation of inflateInit2 for more details. + * + * @throw zlib_error if zlib encounters a problem during initialization + */ + explicit inflate_streambuf(std::istream& input, size_t bufsize = 32768, int window_bits = 32 + 15); + ~inflate_streambuf() noexcept; + + ///@return the wrapped istream + std::istream& get_istr() const { return is; } + +private: + std::istream& is; + bool stream_end; + + int_type underflow() override; +}; + +/** + * @brief An istream adapter that decompresses data using zlib + * + * This istream wraps another istream. The izlibstream will read compressed + * data from the wrapped istream and inflate (decompress) it with zlib. + * + * @note If you want to read more data from the wrapped istream after the end + * of the compressed data, then it must allow seeking. It is unavoidable for + * the izlibstream to consume more data after the compressed data. + * It will automatically attempt to seek the wrapped istream back to the point + * after the end of the compressed data. + * @sa inflate_streambuf + */ +class NBT_EXPORT izlibstream : public std::istream +{ +public: + /** + * @param input the istream to wrap + * @param bufsize the size of the internal buffers + */ + explicit izlibstream(std::istream& input, size_t bufsize = 32768): + std::istream(&buf), buf(input, bufsize) + {} + ///@return the wrapped istream + std::istream& get_istr() const { return buf.get_istr(); } + +private: + inflate_streambuf buf; +}; + +} + +#endif // IZLIBSTREAM_H_INCLUDED diff --git a/libraries/libnbtplusplus/include/io/ozlibstream.h b/libraries/libnbtplusplus/include/io/ozlibstream.h new file mode 100644 index 0000000..65c97c7 --- /dev/null +++ b/libraries/libnbtplusplus/include/io/ozlibstream.h @@ -0,0 +1,95 @@ +/* + * libnbt++ - A library for the Minecraft Named Binary Tag format. + * Copyright (C) 2013, 2015 ljfa-ag + * + * This file is part of libnbt++. + * + * libnbt++ is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * libnbt++ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with libnbt++. If not, see . + */ +#ifndef OZLIBSTREAM_H_INCLUDED +#define OZLIBSTREAM_H_INCLUDED + +#include "io/zlib_streambuf.h" +#include +#include + +namespace zlib +{ + +/** + * @brief Stream buffer used by zlib::ozlibstream + * @sa ozlibstream + */ +class NBT_EXPORT deflate_streambuf : public zlib_streambuf +{ +public: + /** + * @param output the ostream to wrap + * @param bufsize the size of the internal buffers + * @param level the compression level, ranges from 0 to 9, or -1 for default + * + * Refer to the zlib documentation of deflateInit2 for details about the arguments. + * + * @throw zlib_error if zlib encounters a problem during initialization + */ + explicit deflate_streambuf(std::ostream& output, size_t bufsize = 32768, int level = Z_DEFAULT_COMPRESSION, int window_bits = 15, int mem_level = 8, int strategy = Z_DEFAULT_STRATEGY); + ~deflate_streambuf() noexcept; + + ///@return the wrapped ostream + std::ostream& get_ostr() const { return os; } + + ///Finishes compression and writes all pending data to the output + void close(); +private: + std::ostream& os; + + void deflate_chunk(int flush = Z_NO_FLUSH); + + int_type overflow(int_type ch) override; + int sync() override; +}; + +/** + * @brief An ostream adapter that compresses data using zlib + * + * This ostream wraps another ostream. Data written to an ozlibstream will be + * deflated (compressed) with zlib and written to the wrapped ostream. + * + * @sa deflate_streambuf + */ +class NBT_EXPORT ozlibstream : public std::ostream +{ +public: + /** + * @param output the ostream to wrap + * @param level the compression level, ranges from 0 to 9, or -1 for default + * @param gzip if true, the output will be in gzip format rather than zlib + * @param bufsize the size of the internal buffers + */ + explicit ozlibstream(std::ostream& output, int level = Z_DEFAULT_COMPRESSION, bool gzip = false, size_t bufsize = 32768): + std::ostream(&buf), buf(output, bufsize, level, 15 + (gzip ? 16 : 0)) + {} + + ///@return the wrapped ostream + std::ostream& get_ostr() const { return buf.get_ostr(); } + + ///Finishes compression and writes all pending data to the output + void close(); +private: + deflate_streambuf buf; +}; + +} + +#endif // OZLIBSTREAM_H_INCLUDED diff --git a/libraries/libnbtplusplus/include/io/stream_reader.h b/libraries/libnbtplusplus/include/io/stream_reader.h new file mode 100644 index 0000000..3a677db --- /dev/null +++ b/libraries/libnbtplusplus/include/io/stream_reader.h @@ -0,0 +1,137 @@ +/* + * libnbt++ - A library for the Minecraft Named Binary Tag format. + * Copyright (C) 2013, 2015 ljfa-ag + * + * This file is part of libnbt++. + * + * libnbt++ is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * libnbt++ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with libnbt++. If not, see . + */ +#ifndef STREAM_READER_H_INCLUDED +#define STREAM_READER_H_INCLUDED + +#include "endian_str.h" +#include "tag.h" +#include "tag_compound.h" +#include +#include +#include +#include + +namespace nbt +{ +namespace io +{ + +///Exception that gets thrown when reading is not successful +class NBT_EXPORT input_error : public std::runtime_error +{ + using std::runtime_error::runtime_error; +}; + +/** + * @brief Reads a named tag from the stream, making sure that it is a compound + * @param is the stream to read from + * @param e the byte order of the source data. The Java edition + * of Minecraft uses Big Endian, the Pocket edition uses Little Endian + * @throw input_error on failure, or if the tag in the stream is not a compound + */ +NBT_EXPORT std::pair> read_compound(std::istream& is, endian::endian e = endian::big); + +/** + * @brief Reads a named tag from the stream + * @param is the stream to read from + * @param e the byte order of the source data. The Java edition + * of Minecraft uses Big Endian, the Pocket edition uses Little Endian + * @throw input_error on failure + */ +NBT_EXPORT std::pair> read_tag(std::istream& is, endian::endian e = endian::big); + +/** + * @brief Helper class for reading NBT tags from input streams + * + * Can be reused to read multiple tags + */ +class NBT_EXPORT stream_reader +{ +public: + /** + * @param is the stream to read from + * @param e the byte order of the source data. The Java edition + * of Minecraft uses Big Endian, the Pocket edition uses Little Endian + */ + explicit stream_reader(std::istream& is, endian::endian e = endian::big) noexcept; + + ///Returns the stream + std::istream& get_istr() const; + ///Returns the byte order + endian::endian get_endian() const; + + /** + * @brief Reads a named tag from the stream, making sure that it is a compound + * @throw input_error on failure, or if the tag in the stream is not a compound + */ + std::pair> read_compound(); + + /** + * @brief Reads a named tag from the stream + * @throw input_error on failure + */ + std::pair> read_tag(); + + /** + * @brief Reads a tag of the given type without name from the stream + * @throw input_error on failure + */ + std::unique_ptr read_payload(tag_type type); + + /** + * @brief Reads a tag type from the stream + * @param allow_end whether to consider tag_type::End valid + * @throw input_error on failure + */ + tag_type read_type(bool allow_end = false); + + /** + * @brief Reads a binary number from the stream + * + * On failure, will set the failbit on the stream. + */ + template + void read_num(T& x); + + /** + * @brief Reads an NBT string from the stream + * + * An NBT string consists of two bytes indicating the length, followed by + * the characters encoded in modified UTF-8. + * @throw input_error on failure + */ + std::string read_string(); + +private: + std::istream& is; + int depth = 0; + const endian::endian endian; +}; + +template +void stream_reader::read_num(T& x) +{ + endian::read(is, x, endian); +} + +} +} + +#endif // STREAM_READER_H_INCLUDED diff --git a/libraries/libnbtplusplus/include/io/stream_writer.h b/libraries/libnbtplusplus/include/io/stream_writer.h new file mode 100644 index 0000000..a69508f --- /dev/null +++ b/libraries/libnbtplusplus/include/io/stream_writer.h @@ -0,0 +1,121 @@ +/* + * libnbt++ - A library for the Minecraft Named Binary Tag format. + * Copyright (C) 2013, 2015 ljfa-ag + * + * This file is part of libnbt++. + * + * libnbt++ is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * libnbt++ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with libnbt++. If not, see . + */ +#ifndef STREAM_WRITER_H_INCLUDED +#define STREAM_WRITER_H_INCLUDED + +#include "tag.h" +#include "endian_str.h" +#include +#include + +namespace nbt +{ +namespace io +{ + +/* Not sure if that is even needed +///Exception that gets thrown when writing is not successful +class output_error : public std::runtime_error +{ + using std::runtime_error::runtime_error; +};*/ + +/** + * @brief Writes a named tag into the stream, including the tag type + * @param key the name of the tag + * @param t the tag + * @param os the stream to write to + * @param e the byte order of the written data. The Java edition + * of Minecraft uses Big Endian, the Pocket edition uses Little Endian + */ +NBT_EXPORT void write_tag(const std::string& key, const tag& t, std::ostream& os, endian::endian e = endian::big); + +/** + * @brief Helper class for writing NBT tags to output streams + * + * Can be reused to write multiple tags + */ +class NBT_EXPORT stream_writer +{ +public: + ///Maximum length of an NBT string (16 bit unsigned) + static constexpr size_t max_string_len = UINT16_MAX; + ///Maximum length of an NBT list or array (32 bit signed) + static constexpr uint32_t max_array_len = INT32_MAX; + + /** + * @param os the stream to write to + * @param e the byte order of the written data. The Java edition + * of Minecraft uses Big Endian, the Pocket edition uses Little Endian + */ + explicit stream_writer(std::ostream& os, endian::endian e = endian::big) noexcept: + os(os), endian(e) + {} + + ///Returns the stream + std::ostream& get_ostr() const { return os; } + ///Returns the byte order + endian::endian get_endian() const { return endian; } + + /** + * @brief Writes a named tag into the stream, including the tag type + */ + void write_tag(const std::string& key, const tag& t); + + /** + * @brief Writes the given tag's payload into the stream + */ + void write_payload(const tag& t) { t.write_payload(*this); } + + /** + * @brief Writes a tag type to the stream + */ + void write_type(tag_type tt) { write_num(static_cast(tt)); } + + /** + * @brief Writes a binary number to the stream + */ + template + void write_num(T x); + + /** + * @brief Writes an NBT string to the stream + * + * An NBT string consists of two bytes indicating the length, followed by + * the characters encoded in modified UTF-8. + * @throw std::length_error if the string is too long for NBT + */ + void write_string(const std::string& str); + +private: + std::ostream& os; + const endian::endian endian; +}; + +template +void stream_writer::write_num(T x) +{ + endian::write(os, x, endian); +} + +} +} + +#endif // STREAM_WRITER_H_INCLUDED diff --git a/libraries/libnbtplusplus/include/io/zlib_streambuf.h b/libraries/libnbtplusplus/include/io/zlib_streambuf.h new file mode 100644 index 0000000..4241769 --- /dev/null +++ b/libraries/libnbtplusplus/include/io/zlib_streambuf.h @@ -0,0 +1,46 @@ +#ifndef ZLIB_STREAMBUF_H_INCLUDED +#define ZLIB_STREAMBUF_H_INCLUDED + +#include +#include +#include +#include +#include "nbt_export.h" + +namespace zlib +{ + +///Exception thrown in case zlib encounters a problem +class NBT_EXPORT zlib_error : public std::runtime_error +{ +public: + const int errcode; + + zlib_error(const char* msg, int errcode): + std::runtime_error(msg + ? std::string(zError(errcode)) + ": " + msg + : zError(errcode)), + errcode(errcode) + {} +}; + +///Base class for deflate_streambuf and inflate_streambuf +class zlib_streambuf : public std::streambuf +{ +protected: + std::vector in; + std::vector out; + z_stream zstr; + + explicit zlib_streambuf(size_t bufsize): + in(bufsize), out(bufsize) + { + zstr.zalloc = Z_NULL; + zstr.zfree = Z_NULL; + zstr.opaque = Z_NULL; + } +}; + +} + +#endif // ZLIB_STREAMBUF_H_INCLUDED diff --git a/libraries/libnbtplusplus/include/make_unique.h b/libraries/libnbtplusplus/include/make_unique.h new file mode 100644 index 0000000..9a92954 --- /dev/null +++ b/libraries/libnbtplusplus/include/make_unique.h @@ -0,0 +1,37 @@ +/* + * libnbt++ - A library for the Minecraft Named Binary Tag format. + * Copyright (C) 2013, 2015 ljfa-ag + * + * This file is part of libnbt++. + * + * libnbt++ is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * libnbt++ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with libnbt++. If not, see . + */ +#ifndef MAKE_UNIQUE_H_INCLUDED +#define MAKE_UNIQUE_H_INCLUDED + +#include + +namespace nbt +{ + +///Creates a new object of type T and returns a std::unique_ptr to it +template +std::unique_ptr make_unique(Args&&... args) +{ + return std::unique_ptr(new T(std::forward(args)...)); +} + +} + +#endif // MAKE_UNIQUE_H_INCLUDED diff --git a/libraries/libnbtplusplus/include/nbt_tags.h b/libraries/libnbtplusplus/include/nbt_tags.h new file mode 100644 index 0000000..7f557fc --- /dev/null +++ b/libraries/libnbtplusplus/include/nbt_tags.h @@ -0,0 +1,24 @@ +/* + * libnbt++ - A library for the Minecraft Named Binary Tag format. + * Copyright (C) 2013, 2015 ljfa-ag + * + * This file is part of libnbt++. + * + * libnbt++ is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * libnbt++ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with libnbt++. If not, see . + */ +#include "tag_primitive.h" +#include "tag_string.h" +#include "tag_array.h" +#include "tag_list.h" +#include "tag_compound.h" diff --git a/libraries/libnbtplusplus/include/nbt_visitor.h b/libraries/libnbtplusplus/include/nbt_visitor.h new file mode 100644 index 0000000..fe2688a --- /dev/null +++ b/libraries/libnbtplusplus/include/nbt_visitor.h @@ -0,0 +1,82 @@ +/* + * libnbt++ - A library for the Minecraft Named Binary Tag format. + * Copyright (C) 2013, 2015 ljfa-ag + * + * This file is part of libnbt++. + * + * libnbt++ is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * libnbt++ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with libnbt++. If not, see . + */ +#ifndef NBT_VISITOR_H_INCLUDED +#define NBT_VISITOR_H_INCLUDED + +#include "tagfwd.h" + +namespace nbt +{ + +/** + * @brief Base class for visitors of tags + * + * Implementing the Visitor pattern + */ +class nbt_visitor +{ +public: + virtual ~nbt_visitor() noexcept = 0; //Abstract class + + virtual void visit(tag_byte&) {} + virtual void visit(tag_short&) {} + virtual void visit(tag_int&) {} + virtual void visit(tag_long&) {} + virtual void visit(tag_float&) {} + virtual void visit(tag_double&) {} + virtual void visit(tag_byte_array&) {} + virtual void visit(tag_string&) {} + virtual void visit(tag_list&) {} + virtual void visit(tag_compound&) {} + virtual void visit(tag_int_array&) {} + virtual void visit(tag_long_array&) {} +}; + +/** + * @brief Base class for visitors of constant tags + * + * Implementing the Visitor pattern + */ +class const_nbt_visitor +{ +public: + virtual ~const_nbt_visitor() noexcept = 0; //Abstract class + + virtual void visit(const tag_byte&) {} + virtual void visit(const tag_short&) {} + virtual void visit(const tag_int&) {} + virtual void visit(const tag_long&) {} + virtual void visit(const tag_float&) {} + virtual void visit(const tag_double&) {} + virtual void visit(const tag_byte_array&) {} + virtual void visit(const tag_string&) {} + virtual void visit(const tag_list&) {} + virtual void visit(const tag_compound&) {} + virtual void visit(const tag_int_array&) {} + virtual void visit(const tag_long_array&) {} +}; + +inline nbt_visitor::~nbt_visitor() noexcept {} + +inline const_nbt_visitor::~const_nbt_visitor() noexcept {} + +} + +#endif // NBT_VISITOR_H_INCLUDED diff --git a/libraries/libnbtplusplus/include/primitive_detail.h b/libraries/libnbtplusplus/include/primitive_detail.h new file mode 100644 index 0000000..4715ee7 --- /dev/null +++ b/libraries/libnbtplusplus/include/primitive_detail.h @@ -0,0 +1,46 @@ +/* + * libnbt++ - A library for the Minecraft Named Binary Tag format. + * Copyright (C) 2013, 2015 ljfa-ag + * + * This file is part of libnbt++. + * + * libnbt++ is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * libnbt++ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with libnbt++. If not, see . + */ +#ifndef PRIMITIVE_DETAIL_H_INCLUDED +#define PRIMITIVE_DETAIL_H_INCLUDED + +#include + +///@cond +namespace nbt +{ + +namespace detail +{ + ///Meta-struct that holds the tag_type value for a specific primitive type + template struct get_primitive_type + { static_assert(sizeof(T) != sizeof(T), "Invalid type paramter for tag_primitive, can only use types that NBT uses"); }; + + template<> struct get_primitive_type : public std::integral_constant {}; + template<> struct get_primitive_type : public std::integral_constant {}; + template<> struct get_primitive_type : public std::integral_constant {}; + template<> struct get_primitive_type : public std::integral_constant {}; + template<> struct get_primitive_type : public std::integral_constant {}; + template<> struct get_primitive_type : public std::integral_constant {}; +} + +} +///@endcond + +#endif // PRIMITIVE_DETAIL_H_INCLUDED diff --git a/libraries/libnbtplusplus/include/tag.h b/libraries/libnbtplusplus/include/tag.h new file mode 100644 index 0000000..c4f1d5d --- /dev/null +++ b/libraries/libnbtplusplus/include/tag.h @@ -0,0 +1,159 @@ +/* + * libnbt++ - A library for the Minecraft Named Binary Tag format. + * Copyright (C) 2013, 2015 ljfa-ag + * + * This file is part of libnbt++. + * + * libnbt++ is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * libnbt++ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with libnbt++. If not, see . + */ +#ifndef TAG_H_INCLUDED +#define TAG_H_INCLUDED + +#include +#include +#include +#include "nbt_export.h" + +namespace nbt +{ + +///Tag type values used in the binary format +enum class tag_type : int8_t +{ + End = 0, + Byte = 1, + Short = 2, + Int = 3, + Long = 4, + Float = 5, + Double = 6, + Byte_Array = 7, + String = 8, + List = 9, + Compound = 10, + Int_Array = 11, + Long_Array = 12, + Null = -1 ///< Used to denote empty @ref value s +}; + +/** + * @brief Returns whether the given number falls within the range of valid tag types + * @param allow_end whether to consider tag_type::End (0) valid + */ +NBT_EXPORT bool is_valid_type(int type, bool allow_end = false); + +//Forward declarations +class nbt_visitor; +class const_nbt_visitor; +namespace io +{ + class stream_reader; + class stream_writer; +} + +///Base class for all NBT tag classes +class NBT_EXPORT tag +{ +public: + //Virtual destructor + virtual ~tag() noexcept {} + + ///Returns the type of the tag + virtual tag_type get_type() const noexcept = 0; + + //Polymorphic clone methods + virtual std::unique_ptr clone() const& = 0; + virtual std::unique_ptr move_clone() && = 0; + std::unique_ptr clone() &&; + + /** + * @brief Returns a reference to the tag as an instance of T + * @throw std::bad_cast if the tag is not of type T + */ + template + T& as(); + template + const T& as() const; + + /** + * @brief Move-assigns the given tag if the class is the same + * @throw std::bad_cast if @c rhs is not the same type as @c *this + */ + virtual tag& assign(tag&& rhs) = 0; + + /** + * @brief Calls the appropriate overload of @c visit() on the visitor with + * @c *this as argument + * + * Implementing the Visitor pattern + */ + virtual void accept(nbt_visitor& visitor) = 0; + virtual void accept(const_nbt_visitor& visitor) const = 0; + + /** + * @brief Reads the tag's payload from the stream + * @throw io::stream_reader::input_error on failure + */ + virtual void read_payload(io::stream_reader& reader) = 0; + + /** + * @brief Writes the tag's payload into the stream + */ + virtual void write_payload(io::stream_writer& writer) const = 0; + + /** + * @brief Default-constructs a new tag of the given type + * @throw std::invalid_argument if the type is not valid (e.g. End or Null) + */ + static std::unique_ptr create(tag_type type); + + friend NBT_EXPORT bool operator==(const tag& lhs, const tag& rhs); + friend NBT_EXPORT bool operator!=(const tag& lhs, const tag& rhs); + +private: + /** + * @brief Checks for equality to a tag of the same type + * @param rhs an instance of the same class as @c *this + */ + virtual bool equals(const tag& rhs) const = 0; +}; + +///Output operator for tag types +NBT_EXPORT std::ostream& operator<<(std::ostream& os, tag_type tt); + +/** + * @brief Output operator for tags + * + * Uses @ref text::json_formatter + * @relates tag + */ +NBT_EXPORT std::ostream& operator<<(std::ostream& os, const tag& t); + +template +T& tag::as() +{ + static_assert(std::is_base_of::value, "T must be a subclass of tag"); + return dynamic_cast(*this); +} + +template +const T& tag::as() const +{ + static_assert(std::is_base_of::value, "T must be a subclass of tag"); + return dynamic_cast(*this); +} + +} + +#endif // TAG_H_INCLUDED diff --git a/libraries/libnbtplusplus/include/tag_array.h b/libraries/libnbtplusplus/include/tag_array.h new file mode 100644 index 0000000..a12c226 --- /dev/null +++ b/libraries/libnbtplusplus/include/tag_array.h @@ -0,0 +1,235 @@ +/* + * libnbt++ - A library for the Minecraft Named Binary Tag format. + * Copyright (C) 2013, 2015 ljfa-ag + * + * This file is part of libnbt++. + * + * libnbt++ is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * libnbt++ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with libnbt++. If not, see . + */ +#ifndef TAG_ARRAY_H_INCLUDED +#define TAG_ARRAY_H_INCLUDED + +#include "crtp_tag.h" +#include "io/stream_reader.h" +#include "io/stream_writer.h" +#include +#include +#include + +namespace nbt +{ + +///@cond +namespace detail +{ + ///Meta-struct that holds the tag_type value for a specific array type + template struct get_array_type + { static_assert(sizeof(T) != sizeof(T), "Invalid type paramter for tag_array, can only use byte or int"); }; + + template<> struct get_array_type : public std::integral_constant {}; + template<> struct get_array_type : public std::integral_constant {}; + template<> struct get_array_type : public std::integral_constant {}; +} +///@cond + +/** + * @brief Tag that contains an array of byte or int values + * + * Common class for tag_byte_array, tag_int_array and tag_long_array. + */ +template +class tag_array final : public detail::crtp_tag> +{ +public: + //Iterator types + typedef typename std::vector::iterator iterator; + typedef typename std::vector::const_iterator const_iterator; + + ///The type of the contained values + typedef T value_type; + + ///The type of the tag + static constexpr tag_type type = detail::get_array_type::value; + + ///Constructs an empty array + tag_array() {} + + ///Constructs an array with the given values + tag_array(std::initializer_list init): data(init) {} + tag_array(std::vector&& vec) noexcept: data(std::move(vec)) {} + + ///Returns a reference to the vector that contains the values + std::vector& get() { return data; } + const std::vector& get() const { return data; } + + /** + * @brief Accesses a value by index with bounds checking + * @throw std::out_of_range if the index is out of range + */ + T& at(size_t i) { return data.at(i); } + T at(size_t i) const { return data.at(i); } + + /** + * @brief Accesses a value by index + * + * No bounds checking is performed. + */ + T& operator[](size_t i) { return data[i]; } + T operator[](size_t i) const { return data[i]; } + + ///Appends a value at the end of the array + void push_back(T val) { data.push_back(val); } + + ///Removes the last element from the array + void pop_back() { data.pop_back(); } + + ///Returns the number of values in the array + size_t size() const { return data.size(); } + + ///Erases all values from the array. + void clear() { data.clear(); } + + //Iterators + iterator begin() { return data.begin(); } + iterator end() { return data.end(); } + const_iterator begin() const { return data.begin(); } + const_iterator end() const { return data.end(); } + const_iterator cbegin() const { return data.cbegin(); } + const_iterator cend() const { return data.cend(); } + + void read_payload(io::stream_reader& reader) override; + /** + * @inheritdoc + * @throw std::length_error if the array is too large for NBT + */ + void write_payload(io::stream_writer& writer) const override; + +private: + std::vector data; +}; + +template bool operator==(const tag_array& lhs, const tag_array& rhs) +{ return lhs.get() == rhs.get(); } +template bool operator!=(const tag_array& lhs, const tag_array& rhs) +{ return !(lhs == rhs); } + +//Slightly different between byte_array and int_array +//Reading +template<> +inline void tag_array::read_payload(io::stream_reader& reader) +{ + int32_t length; + reader.read_num(length); + if(length < 0) + reader.get_istr().setstate(std::ios::failbit); + if(!reader.get_istr()) + throw io::input_error("Error reading length of tag_byte_array"); + + data.resize(length); + reader.get_istr().read(reinterpret_cast(data.data()), length); + if(!reader.get_istr()) + throw io::input_error("Error reading contents of tag_byte_array"); +} + +template +inline void tag_array::read_payload(io::stream_reader& reader) +{ + int32_t length; + reader.read_num(length); + if(length < 0) + reader.get_istr().setstate(std::ios::failbit); + if(!reader.get_istr()) + throw io::input_error("Error reading length of generic array tag"); + + data.clear(); + data.reserve(length); + for(T i = 0; i < length; ++i) + { + T val; + reader.read_num(val); + data.push_back(val); + } + if(!reader.get_istr()) + throw io::input_error("Error reading contents of generic array tag"); +} + +template<> +inline void tag_array::read_payload(io::stream_reader& reader) +{ + int32_t length; + reader.read_num(length); + if(length < 0) + reader.get_istr().setstate(std::ios::failbit); + if(!reader.get_istr()) + throw io::input_error("Error reading length of tag_long_array"); + + data.clear(); + data.reserve(length); + for(int32_t i = 0; i < length; ++i) + { + int64_t val; + reader.read_num(val); + data.push_back(val); + } + if(!reader.get_istr()) + throw io::input_error("Error reading contents of tag_long_array"); +} + +//Writing +template<> +inline void tag_array::write_payload(io::stream_writer& writer) const +{ + if(size() > io::stream_writer::max_array_len) + { + writer.get_ostr().setstate(std::ios::failbit); + throw std::length_error("Byte array is too large for NBT"); + } + writer.write_num(static_cast(size())); + writer.get_ostr().write(reinterpret_cast(data.data()), data.size()); +} + +template +inline void tag_array::write_payload(io::stream_writer& writer) const +{ + if(size() > io::stream_writer::max_array_len) + { + writer.get_ostr().setstate(std::ios::failbit); + throw std::length_error("Generic array is too large for NBT"); + } + writer.write_num(static_cast(size())); + for(T i: data) + writer.write_num(i); +} + +template<> +inline void tag_array::write_payload(io::stream_writer& writer) const +{ + if(size() > io::stream_writer::max_array_len) + { + writer.get_ostr().setstate(std::ios::failbit); + throw std::length_error("Long array is too large for NBT"); + } + writer.write_num(static_cast(size())); + for(int64_t i: data) + writer.write_num(i); +} + +//Typedefs that should be used instead of the template tag_array. +typedef tag_array tag_byte_array; +typedef tag_array tag_int_array; +typedef tag_array tag_long_array; + +} + +#endif // TAG_ARRAY_H_INCLUDED diff --git a/libraries/libnbtplusplus/include/tag_compound.h b/libraries/libnbtplusplus/include/tag_compound.h new file mode 100644 index 0000000..3bbc1f2 --- /dev/null +++ b/libraries/libnbtplusplus/include/tag_compound.h @@ -0,0 +1,142 @@ +/* + * libnbt++ - A library for the Minecraft Named Binary Tag format. + * Copyright (C) 2013, 2015 ljfa-ag + * + * This file is part of libnbt++. + * + * libnbt++ is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * libnbt++ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with libnbt++. If not, see . + */ +#ifndef TAG_COMPOUND_H_INCLUDED +#define TAG_COMPOUND_H_INCLUDED + +#include "crtp_tag.h" +#include "value_initializer.h" +#include +#include + +namespace nbt +{ + +///Tag that contains multiple unordered named tags of arbitrary types +class NBT_EXPORT tag_compound final : public detail::crtp_tag +{ + typedef std::map map_t_; + +public: + //Iterator types + typedef map_t_::iterator iterator; + typedef map_t_::const_iterator const_iterator; + + ///The type of the tag + static constexpr tag_type type = tag_type::Compound; + + ///Constructs an empty compound + tag_compound() {} + + ///Constructs a compound with the given key-value pairs + tag_compound(std::initializer_list> init); + + /** + * @brief Accesses a tag by key with bounds checking + * + * Returns a value to the tag with the specified key, or throws an + * exception if it does not exist. + * @throw std::out_of_range if given key does not exist + */ + value& at(const std::string& key); + const value& at(const std::string& key) const; + + /** + * @brief Accesses a tag by key + * + * Returns a value to the tag with the specified key. If it does not exist, + * creates a new uninitialized entry under the key. + */ + value& operator[](const std::string& key) { return tags[key]; } + + /** + * @brief Inserts or assigns a tag + * + * If the given key already exists, assigns the tag to it. + * Otherwise, it is inserted under the given key. + * @return a pair of the iterator to the value and a bool indicating + * whether the key did not exist + */ + std::pair put(const std::string& key, value_initializer&& val); + + /** + * @brief Inserts a tag if the key does not exist + * @return a pair of the iterator to the value with the key and a bool + * indicating whether the value was actually inserted + */ + std::pair insert(const std::string& key, value_initializer&& val); + + /** + * @brief Constructs and assigns or inserts a tag into the compound + * + * Constructs a new tag of type @c T with the given args and inserts + * or assigns it to the given key. + * @note Unlike std::map::emplace, this will overwrite existing values + * @return a pair of the iterator to the value and a bool indicating + * whether the key did not exist + */ + template + std::pair emplace(const std::string& key, Args&&... args); + + /** + * @brief Erases a tag from the compound + * @return true if a tag was erased + */ + bool erase(const std::string& key); + + ///Returns true if the given key exists in the compound + bool has_key(const std::string& key) const; + ///Returns true if the given key exists and the tag has the given type + bool has_key(const std::string& key, tag_type type) const; + + ///Returns the number of tags in the compound + size_t size() const { return tags.size(); } + + ///Erases all tags from the compound + void clear() { tags.clear(); } + + //Iterators + iterator begin() { return tags.begin(); } + iterator end() { return tags.end(); } + const_iterator begin() const { return tags.begin(); } + const_iterator end() const { return tags.end(); } + const_iterator cbegin() const { return tags.cbegin(); } + const_iterator cend() const { return tags.cend(); } + + void read_payload(io::stream_reader& reader) override; + void write_payload(io::stream_writer& writer) const override; + + friend bool operator==(const tag_compound& lhs, const tag_compound& rhs) + { return lhs.tags == rhs.tags; } + friend bool operator!=(const tag_compound& lhs, const tag_compound& rhs) + { return !(lhs == rhs); } + +private: + map_t_ tags; +}; + +template +std::pair tag_compound::emplace(const std::string& key, Args&&... args) +{ + return put(key, value(make_unique(std::forward(args)...))); +} + +} + +#endif // TAG_COMPOUND_H_INCLUDED diff --git a/libraries/libnbtplusplus/include/tag_list.h b/libraries/libnbtplusplus/include/tag_list.h new file mode 100644 index 0000000..07b68c2 --- /dev/null +++ b/libraries/libnbtplusplus/include/tag_list.h @@ -0,0 +1,223 @@ +/* + * libnbt++ - A library for the Minecraft Named Binary Tag format. + * Copyright (C) 2013, 2015 ljfa-ag + * + * This file is part of libnbt++. + * + * libnbt++ is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * libnbt++ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with libnbt++. If not, see . + */ +#ifndef TAG_LIST_H_INCLUDED +#define TAG_LIST_H_INCLUDED + +#include "crtp_tag.h" +#include "tagfwd.h" +#include "value_initializer.h" +#include +#include + +namespace nbt +{ + +/** + * @brief Tag that contains multiple unnamed tags of the same type + * + * All the tags contained in the list have the same type, which can be queried + * with el_type(). The types of the values contained in the list should not + * be changed to mismatch the element type. + * + * If the list is empty, the type can be undetermined, in which case el_type() + * will return tag_type::Null. The type will then be set when the first tag + * is added to the list. + */ +class NBT_EXPORT tag_list final : public detail::crtp_tag +{ +public: + //Iterator types + typedef std::vector::iterator iterator; + typedef std::vector::const_iterator const_iterator; + + ///The type of the tag + static constexpr tag_type type = tag_type::List; + + /** + * @brief Constructs a list of type T with the given values + * + * Example: @code tag_list::of({3, 4, 5}) @endcode + * @param init list of values from which the elements are constructed + */ + template + static tag_list of(std::initializer_list init); + + /** + * @brief Constructs an empty list + * + * The content type is determined when the first tag is added. + */ + tag_list(): tag_list(tag_type::Null) {} + + ///Constructs an empty list with the given content type + explicit tag_list(tag_type content_type): el_type_(content_type) {} + + ///Constructs a list with the given contents + tag_list(std::initializer_list init); + tag_list(std::initializer_list init); + tag_list(std::initializer_list init); + tag_list(std::initializer_list init); + tag_list(std::initializer_list init); + tag_list(std::initializer_list init); + tag_list(std::initializer_list init); + tag_list(std::initializer_list init); + tag_list(std::initializer_list init); + tag_list(std::initializer_list init); + tag_list(std::initializer_list init); + tag_list(std::initializer_list init); + + /** + * @brief Constructs a list with the given contents + * @throw std::invalid_argument if the tags are not all of the same type + */ + tag_list(std::initializer_list init); + + /** + * @brief Accesses a tag by index with bounds checking + * + * Returns a value to the tag at the specified index, or throws an + * exception if it is out of range. + * @throw std::out_of_range if the index is out of range + */ + value& at(size_t i); + const value& at(size_t i) const; + + /** + * @brief Accesses a tag by index + * + * Returns a value to the tag at the specified index. No bounds checking + * is performed. + */ + value& operator[](size_t i) { return tags[i]; } + const value& operator[](size_t i) const { return tags[i]; } + + /** + * @brief Assigns a value at the given index + * @throw std::invalid_argument if the type of the value does not match the list's + * content type + * @throw std::out_of_range if the index is out of range + */ + void set(size_t i, value&& val); + + /** + * @brief Appends the tag to the end of the list + * @throw std::invalid_argument if the type of the tag does not match the list's + * content type + */ + void push_back(value_initializer&& val); + + /** + * @brief Constructs and appends a tag to the end of the list + * @throw std::invalid_argument if the type of the tag does not match the list's + * content type + */ + template + void emplace_back(Args&&... args); + + ///Removes the last element of the list + void pop_back() { tags.pop_back(); } + + ///Returns the content type of the list, or tag_type::Null if undetermined + tag_type el_type() const { return el_type_; } + + ///Returns the number of tags in the list + size_t size() const { return tags.size(); } + + ///Erases all tags from the list. Preserves the content type. + void clear() { tags.clear(); } + + /** + * @brief Erases all tags from the list and changes the content type. + * @param type the new content type. Can be tag_type::Null to leave it undetermined. + */ + void reset(tag_type type = tag_type::Null); + + //Iterators + iterator begin() { return tags.begin(); } + iterator end() { return tags.end(); } + const_iterator begin() const { return tags.begin(); } + const_iterator end() const { return tags.end(); } + const_iterator cbegin() const { return tags.cbegin(); } + const_iterator cend() const { return tags.cend(); } + + /** + * @inheritdoc + * In case of a list of tag_end, the content type will be undetermined. + */ + void read_payload(io::stream_reader& reader) override; + /** + * @inheritdoc + * In case of a list of undetermined content type, the written type will be tag_end. + * @throw std::length_error if the list is too long for NBT + */ + void write_payload(io::stream_writer& writer) const override; + + /** + * @brief Equality comparison for lists + * + * Lists are considered equal if their content types and the contained tags + * are equal. + */ + friend NBT_EXPORT bool operator==(const tag_list& lhs, const tag_list& rhs); + friend NBT_EXPORT bool operator!=(const tag_list& lhs, const tag_list& rhs); + +private: + std::vector tags; + tag_type el_type_; + + /** + * Internally used initialization function that initializes the list with + * tags of type T, with the constructor arguments of each T given by il. + * @param il list of values that are, one by one, given to a constructor of T + */ + template + void init(std::initializer_list il); +}; + +template +tag_list tag_list::of(std::initializer_list il) +{ + tag_list result; + result.init(il); + return result; +} + +template +void tag_list::emplace_back(Args&&... args) +{ + if(el_type_ == tag_type::Null) //set content type if undetermined + el_type_ = T::type; + else if(el_type_ != T::type) + throw std::invalid_argument("The tag type does not match the list's content type"); + tags.emplace_back(make_unique(std::forward(args)...)); +} + +template +void tag_list::init(std::initializer_list init) +{ + el_type_ = T::type; + tags.reserve(init.size()); + for(const Arg& arg: init) + tags.emplace_back(nbt::make_unique(arg)); +} + +} + +#endif // TAG_LIST_H_INCLUDED diff --git a/libraries/libnbtplusplus/include/tag_primitive.h b/libraries/libnbtplusplus/include/tag_primitive.h new file mode 100644 index 0000000..e3d5111 --- /dev/null +++ b/libraries/libnbtplusplus/include/tag_primitive.h @@ -0,0 +1,108 @@ +/* + * libnbt++ - A library for the Minecraft Named Binary Tag format. + * Copyright (C) 2013, 2015 ljfa-ag + * + * This file is part of libnbt++. + * + * libnbt++ is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * libnbt++ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with libnbt++. If not, see . + */ +#ifndef TAG_PRIMITIVE_H_INCLUDED +#define TAG_PRIMITIVE_H_INCLUDED + +#include "crtp_tag.h" +#include "primitive_detail.h" +#include "io/stream_reader.h" +#include "io/stream_writer.h" +#include +#include + +namespace nbt +{ + +/** + * @brief Tag that contains an integral or floating-point value + * + * Common class for tag_byte, tag_short, tag_int, tag_long, tag_float and tag_double. + */ +template +class tag_primitive final : public detail::crtp_tag> +{ +public: + ///The type of the value + typedef T value_type; + + ///The type of the tag + static constexpr tag_type type = detail::get_primitive_type::value; + + //Constructor + constexpr tag_primitive(T val = 0) noexcept: value(val) {} + + //Getters + operator T&() { return value; } + constexpr operator T() const { return value; } + constexpr T get() const { return value; } + + //Setters + tag_primitive& operator=(T val) { value = val; return *this; } + void set(T val) { value = val; } + + void read_payload(io::stream_reader& reader) override; + void write_payload(io::stream_writer& writer) const override; + +private: + T value; +}; + +template bool operator==(const tag_primitive& lhs, const tag_primitive& rhs) +{ return lhs.get() == rhs.get(); } +template bool operator!=(const tag_primitive& lhs, const tag_primitive& rhs) +{ return !(lhs == rhs); } + +//Typedefs that should be used instead of the template tag_primitive. +typedef tag_primitive tag_byte; +typedef tag_primitive tag_short; +typedef tag_primitive tag_int; +typedef tag_primitive tag_long; +typedef tag_primitive tag_float; +typedef tag_primitive tag_double; + +//Explicit instantiations +template class NBT_EXPORT tag_primitive; +template class NBT_EXPORT tag_primitive; +template class NBT_EXPORT tag_primitive; +template class NBT_EXPORT tag_primitive; +template class NBT_EXPORT tag_primitive; +template class NBT_EXPORT tag_primitive; + +template +void tag_primitive::read_payload(io::stream_reader& reader) +{ + reader.read_num(value); + if(!reader.get_istr()) + { + std::ostringstream str; + str << "Error reading tag_" << type; + throw io::input_error(str.str()); + } +} + +template +void tag_primitive::write_payload(io::stream_writer& writer) const +{ + writer.write_num(value); +} + +} + +#endif // TAG_PRIMITIVE_H_INCLUDED diff --git a/libraries/libnbtplusplus/include/tag_string.h b/libraries/libnbtplusplus/include/tag_string.h new file mode 100644 index 0000000..f6c49fd --- /dev/null +++ b/libraries/libnbtplusplus/include/tag_string.h @@ -0,0 +1,72 @@ +/* + * libnbt++ - A library for the Minecraft Named Binary Tag format. + * Copyright (C) 2013, 2015 ljfa-ag + * + * This file is part of libnbt++. + * + * libnbt++ is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * libnbt++ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with libnbt++. If not, see . + */ +#ifndef TAG_STRING_H_INCLUDED +#define TAG_STRING_H_INCLUDED + +#include "crtp_tag.h" +#include + +namespace nbt +{ + +///Tag that contains a UTF-8 string +class NBT_EXPORT tag_string final : public detail::crtp_tag +{ +public: + ///The type of the tag + static constexpr tag_type type = tag_type::String; + + //Constructors + tag_string() {} + tag_string(const std::string& str): value(str) {} + tag_string(std::string&& str) noexcept: value(std::move(str)) {} + tag_string(const char* str): value(str) {} + + //Getters + operator std::string&() { return value; } + operator const std::string&() const { return value; } + const std::string& get() const { return value; } + + //Setters + tag_string& operator=(const std::string& str) { value = str; return *this; } + tag_string& operator=(std::string&& str) { value = std::move(str); return *this; } + tag_string& operator=(const char* str) { value = str; return *this; } + void set(const std::string& str) { value = str; } + void set(std::string&& str) { value = std::move(str); } + + void read_payload(io::stream_reader& reader) override; + /** + * @inheritdoc + * @throw std::length_error if the string is too long for NBT + */ + void write_payload(io::stream_writer& writer) const override; + +private: + std::string value; +}; + +inline bool operator==(const tag_string& lhs, const tag_string& rhs) +{ return lhs.get() == rhs.get(); } +inline bool operator!=(const tag_string& lhs, const tag_string& rhs) +{ return !(lhs == rhs); } + +} + +#endif // TAG_STRING_H_INCLUDED diff --git a/libraries/libnbtplusplus/include/tagfwd.h b/libraries/libnbtplusplus/include/tagfwd.h new file mode 100644 index 0000000..a359885 --- /dev/null +++ b/libraries/libnbtplusplus/include/tagfwd.h @@ -0,0 +1,52 @@ +/* + * libnbt++ - A library for the Minecraft Named Binary Tag format. + * Copyright (C) 2013, 2015 ljfa-ag + * + * This file is part of libnbt++. + * + * libnbt++ is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * libnbt++ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with libnbt++. If not, see . + */ +/** @file + * @brief Provides forward declarations for all tag classes + */ +#ifndef TAGFWD_H_INCLUDED +#define TAGFWD_H_INCLUDED +#include + +namespace nbt +{ + +class tag; + +template class tag_primitive; +typedef tag_primitive tag_byte; +typedef tag_primitive tag_short; +typedef tag_primitive tag_int; +typedef tag_primitive tag_long; +typedef tag_primitive tag_float; +typedef tag_primitive tag_double; + +class tag_string; + +template class tag_array; +typedef tag_array tag_byte_array; +typedef tag_array tag_int_array; +typedef tag_array tag_long_array; + +class tag_list; +class tag_compound; + +} + +#endif // TAGFWD_H_INCLUDED diff --git a/libraries/libnbtplusplus/include/text/json_formatter.h b/libraries/libnbtplusplus/include/text/json_formatter.h new file mode 100644 index 0000000..876caff --- /dev/null +++ b/libraries/libnbtplusplus/include/text/json_formatter.h @@ -0,0 +1,47 @@ +/* + * libnbt++ - A library for the Minecraft Named Binary Tag format. + * Copyright (C) 2013, 2015 ljfa-ag + * + * This file is part of libnbt++. + * + * libnbt++ is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * libnbt++ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with libnbt++. If not, see . + */ +#ifndef JSON_FORMATTER_H_INCLUDED +#define JSON_FORMATTER_H_INCLUDED + +#include "tagfwd.h" +#include +#include "nbt_export.h" + +namespace nbt +{ +namespace text +{ + +/** + * @brief Prints tags in a JSON-like syntax into a stream + * + * @todo Make it configurable and able to produce actual standard-conformant JSON + */ +class NBT_EXPORT json_formatter +{ +public: + json_formatter() {} + void print(std::ostream& os, const tag& t) const; +}; + +} +} + +#endif // JSON_FORMATTER_H_INCLUDED diff --git a/libraries/libnbtplusplus/include/value.h b/libraries/libnbtplusplus/include/value.h new file mode 100644 index 0000000..fffe5cd --- /dev/null +++ b/libraries/libnbtplusplus/include/value.h @@ -0,0 +1,221 @@ +/* + * libnbt++ - A library for the Minecraft Named Binary Tag format. + * Copyright (C) 2013, 2015 ljfa-ag + * + * This file is part of libnbt++. + * + * libnbt++ is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * libnbt++ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with libnbt++. If not, see . + */ +#ifndef TAG_REF_PROXY_H_INCLUDED +#define TAG_REF_PROXY_H_INCLUDED + +#include "tag.h" +#include +#include + +namespace nbt +{ + +/** + * @brief Contains an NBT value of fixed type + * + * This class is a convenience wrapper for @c std::unique_ptr. + * A value can contain any kind of tag or no tag (nullptr) and provides + * operations for handling tags of which the type is not known at compile time. + * Assignment or the set method on a value with no tag will fill in the value. + * + * The rationale for the existance of this class is to provide a type-erasured + * means of storing tags, especially when they are contained in tag_compound + * or tag_list. The alternative would be directly using @c std::unique_ptr + * and @c tag&, which is how it was done in libnbt++1. The main drawback is that + * it becomes very cumbersome to deal with tags of unknown type. + * + * For example, in this case it would not be possible to allow a syntax like + * compound["foo"] = 42. If the key "foo" does not exist beforehand, + * the left hand side could not have any sensible value if it was of type + * @c tag&. + * Firstly, the compound tag would have to create a new tag_int there, but it + * cannot know that the new tag is going to be assigned an integer. + * Also, if the type was @c tag& and it allowed assignment of integers, that + * would mean the tag base class has assignments and conversions like this. + * Which means that all other tag classes would inherit them from the base + * class, even though it does not make any sense to allow converting a + * tag_compound into an integer. Attempts like this should be caught at + * compile time. + * + * This is why all the syntactic sugar for tags is contained in the value class + * while the tag class only contains common operations for all tag types. + */ +class NBT_EXPORT value +{ +public: + //Constructors + value() noexcept {} + explicit value(std::unique_ptr&& t) noexcept: tag_(std::move(t)) {} + explicit value(tag&& t); + + //Moving + value(value&&) noexcept = default; + value& operator=(value&&) noexcept = default; + + //Copying + explicit value(const value& rhs); + value& operator=(const value& rhs); + + /** + * @brief Assigns the given value to the tag if the type matches + * @throw std::bad_cast if the type of @c t is not the same as the type + * of this value + */ + value& operator=(tag&& t); + void set(tag&& t); + + //Conversion to tag + /** + * @brief Returns the contained tag + * + * If the value is uninitialized, the behavior is undefined. + */ + operator tag&() { return get(); } + operator const tag&() const { return get(); } + tag& get() { return *tag_; } + const tag& get() const { return *tag_; } + + /** + * @brief Returns a reference to the contained tag as an instance of T + * @throw std::bad_cast if the tag is not of type T + */ + template + T& as(); + template + const T& as() const; + + //Assignment of primitives and string + /** + * @brief Assigns the given value to the tag if the type is compatible + * @throw std::bad_cast if the value is not convertible to the tag type + * via a widening conversion + */ + value& operator=(int8_t val); + value& operator=(int16_t val); + value& operator=(int32_t val); + value& operator=(int64_t val); + value& operator=(float val); + value& operator=(double val); + + /** + * @brief Assigns the given string to the tag if it is a tag_string + * @throw std::bad_cast if the contained tag is not a tag_string + */ + value& operator=(const std::string& str); + value& operator=(std::string&& str); + + //Conversions to primitives and string + /** + * @brief Returns the contained value if the type is compatible + * @throw std::bad_cast if the tag type is not convertible to the desired + * type via a widening conversion + */ + explicit operator int8_t() const; + explicit operator int16_t() const; + explicit operator int32_t() const; + explicit operator int64_t() const; + explicit operator float() const; + explicit operator double() const; + + /** + * @brief Returns the contained string if the type is tag_string + * + * If the value is uninitialized, the behavior is undefined. + * @throw std::bad_cast if the tag type is not tag_string + */ + explicit operator const std::string&() const; + + ///Returns true if the value is not uninitialized + explicit operator bool() const { return tag_ != nullptr; } + + /** + * @brief In case of a tag_compound, accesses a tag by key with bounds checking + * + * If the value is uninitialized, the behavior is undefined. + * @throw std::bad_cast if the tag type is not tag_compound + * @throw std::out_of_range if given key does not exist + * @sa tag_compound::at + */ + value& at(const std::string& key); + const value& at(const std::string& key) const; + + /** + * @brief In case of a tag_compound, accesses a tag by key + * + * If the value is uninitialized, the behavior is undefined. + * @throw std::bad_cast if the tag type is not tag_compound + * @sa tag_compound::operator[] + */ + value& operator[](const std::string& key); + value& operator[](const char* key); //need this overload because of conflict with built-in operator[] + + /** + * @brief In case of a tag_list, accesses a tag by index with bounds checking + * + * If the value is uninitialized, the behavior is undefined. + * @throw std::bad_cast if the tag type is not tag_list + * @throw std::out_of_range if the index is out of range + * @sa tag_list::at + */ + value& at(size_t i); + const value& at(size_t i) const; + + /** + * @brief In case of a tag_list, accesses a tag by index + * + * No bounds checking is performed. If the value is uninitialized, the + * behavior is undefined. + * @throw std::bad_cast if the tag type is not tag_list + * @sa tag_list::operator[] + */ + value& operator[](size_t i); + const value& operator[](size_t i) const; + + ///Returns a reference to the underlying std::unique_ptr + std::unique_ptr& get_ptr() { return tag_; } + const std::unique_ptr& get_ptr() const { return tag_; } + ///Resets the underlying std::unique_ptr to a different value + void set_ptr(std::unique_ptr&& t) { tag_ = std::move(t); } + + ///@sa tag::get_type + tag_type get_type() const; + + friend NBT_EXPORT bool operator==(const value& lhs, const value& rhs); + friend NBT_EXPORT bool operator!=(const value& lhs, const value& rhs); + +private: + std::unique_ptr tag_; +}; + +template +T& value::as() +{ + return tag_->as(); +} + +template +const T& value::as() const +{ + return tag_->as(); +} + +} + +#endif // TAG_REF_PROXY_H_INCLUDED diff --git a/libraries/libnbtplusplus/include/value_initializer.h b/libraries/libnbtplusplus/include/value_initializer.h new file mode 100644 index 0000000..20fd436 --- /dev/null +++ b/libraries/libnbtplusplus/include/value_initializer.h @@ -0,0 +1,65 @@ +/* + * libnbt++ - A library for the Minecraft Named Binary Tag format. + * Copyright (C) 2013, 2015 ljfa-ag + * + * This file is part of libnbt++. + * + * libnbt++ is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * libnbt++ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with libnbt++. If not, see . + */ +#ifndef VALUE_INITIALIZER_H_INCLUDED +#define VALUE_INITIALIZER_H_INCLUDED + +#include "value.h" + +namespace nbt +{ + +/** + * @brief Helper class for implicitly constructing value objects + * + * This type is a subclass of @ref value. However the only difference to value + * is that this class has additional constructors which allow implicit + * conversion of various types to value objects. These constructors are not + * part of the value class itself because implicit conversions like this + * (especially from @c tag&& to @c value) can cause problems and ambiguities + * in some cases. + * + * value_initializer is especially useful as function parameter type, it will + * allow convenient conversion of various values to tags on function call. + * + * As value_initializer objects are in no way different than value objects, + * they can just be converted to value after construction. + */ +class NBT_EXPORT value_initializer : public value +{ +public: + value_initializer(std::unique_ptr&& t) noexcept: value(std::move(t)) {} + value_initializer(std::nullptr_t) noexcept : value(nullptr) {} + value_initializer(value&& val) noexcept : value(std::move(val)) {} + value_initializer(tag&& t) : value(std::move(t)) {} + + value_initializer(int8_t val); + value_initializer(int16_t val); + value_initializer(int32_t val); + value_initializer(int64_t val); + value_initializer(float val); + value_initializer(double val); + value_initializer(const std::string& str); + value_initializer(std::string&& str); + value_initializer(const char* str); +}; + +} + +#endif // VALUE_INITIALIZER_H_INCLUDED diff --git a/libraries/libnbtplusplus/src/endian_str.cpp b/libraries/libnbtplusplus/src/endian_str.cpp new file mode 100644 index 0000000..8d136b0 --- /dev/null +++ b/libraries/libnbtplusplus/src/endian_str.cpp @@ -0,0 +1,284 @@ +/* + * libnbt++ - A library for the Minecraft Named Binary Tag format. + * Copyright (C) 2013, 2015 ljfa-ag + * + * This file is part of libnbt++. + * + * libnbt++ is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * libnbt++ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with libnbt++. If not, see . + */ +#include "endian_str.h" +#include +#include +#include + +static_assert(CHAR_BIT == 8, "Assuming that a byte has 8 bits"); +static_assert(sizeof(float) == 4, "Assuming that a float is 4 byte long"); +static_assert(sizeof(double) == 8, "Assuming that a double is 8 byte long"); + +namespace endian +{ + +namespace //anonymous +{ + void pun_int_to_float(float& f, uint32_t i) + { + //Yes we need to do it this way to avoid undefined behavior + memcpy(&f, &i, 4); + } + + uint32_t pun_float_to_int(float f) + { + uint32_t ret; + memcpy(&ret, &f, 4); + return ret; + } + + void pun_int_to_double(double& d, uint64_t i) + { + memcpy(&d, &i, 8); + } + + uint64_t pun_double_to_int(double f) + { + uint64_t ret; + memcpy(&ret, &f, 8); + return ret; + } +} + +//------------------------------------------------------------------------------ + +void read_little(std::istream& is, uint8_t& x) +{ + is.get(reinterpret_cast(x)); +} + +void read_little(std::istream& is, uint16_t& x) +{ + uint8_t tmp[2]; + is.read(reinterpret_cast(tmp), 2); + x = uint16_t(tmp[0]) + | (uint16_t(tmp[1]) << 8); +} + +void read_little(std::istream& is, uint32_t& x) +{ + uint8_t tmp[4]; + is.read(reinterpret_cast(tmp), 4); + x = uint32_t(tmp[0]) + | (uint32_t(tmp[1]) << 8) + | (uint32_t(tmp[2]) << 16) + | (uint32_t(tmp[3]) << 24); +} + +void read_little(std::istream& is, uint64_t& x) +{ + uint8_t tmp[8]; + is.read(reinterpret_cast(tmp), 8); + x = uint64_t(tmp[0]) + | (uint64_t(tmp[1]) << 8) + | (uint64_t(tmp[2]) << 16) + | (uint64_t(tmp[3]) << 24) + | (uint64_t(tmp[4]) << 32) + | (uint64_t(tmp[5]) << 40) + | (uint64_t(tmp[6]) << 48) + | (uint64_t(tmp[7]) << 56); +} + +void read_little(std::istream& is, int8_t & x) { read_little(is, reinterpret_cast(x)); } +void read_little(std::istream& is, int16_t& x) { read_little(is, reinterpret_cast(x)); } +void read_little(std::istream& is, int32_t& x) { read_little(is, reinterpret_cast(x)); } +void read_little(std::istream& is, int64_t& x) { read_little(is, reinterpret_cast(x)); } + +void read_little(std::istream& is, float& x) +{ + uint32_t tmp; + read_little(is, tmp); + pun_int_to_float(x, tmp); +} + +void read_little(std::istream& is, double& x) +{ + uint64_t tmp; + read_little(is, tmp); + pun_int_to_double(x, tmp); +} + +//------------------------------------------------------------------------------ + +void read_big(std::istream& is, uint8_t& x) +{ + is.read(reinterpret_cast(&x), 1); +} + +void read_big(std::istream& is, uint16_t& x) +{ + uint8_t tmp[2]; + is.read(reinterpret_cast(tmp), 2); + x = uint16_t(tmp[1]) + | (uint16_t(tmp[0]) << 8); +} + +void read_big(std::istream& is, uint32_t& x) +{ + uint8_t tmp[4]; + is.read(reinterpret_cast(tmp), 4); + x = uint32_t(tmp[3]) + | (uint32_t(tmp[2]) << 8) + | (uint32_t(tmp[1]) << 16) + | (uint32_t(tmp[0]) << 24); +} + +void read_big(std::istream& is, uint64_t& x) +{ + uint8_t tmp[8]; + is.read(reinterpret_cast(tmp), 8); + x = uint64_t(tmp[7]) + | (uint64_t(tmp[6]) << 8) + | (uint64_t(tmp[5]) << 16) + | (uint64_t(tmp[4]) << 24) + | (uint64_t(tmp[3]) << 32) + | (uint64_t(tmp[2]) << 40) + | (uint64_t(tmp[1]) << 48) + | (uint64_t(tmp[0]) << 56); +} + +void read_big(std::istream& is, int8_t & x) { read_big(is, reinterpret_cast(x)); } +void read_big(std::istream& is, int16_t& x) { read_big(is, reinterpret_cast(x)); } +void read_big(std::istream& is, int32_t& x) { read_big(is, reinterpret_cast(x)); } +void read_big(std::istream& is, int64_t& x) { read_big(is, reinterpret_cast(x)); } + +void read_big(std::istream& is, float& x) +{ + uint32_t tmp; + read_big(is, tmp); + pun_int_to_float(x, tmp); +} + +void read_big(std::istream& is, double& x) +{ + uint64_t tmp; + read_big(is, tmp); + pun_int_to_double(x, tmp); +} + +//------------------------------------------------------------------------------ + +void write_little(std::ostream& os, uint8_t x) +{ + os.put(x); +} + +void write_little(std::ostream& os, uint16_t x) +{ + uint8_t tmp[2] { + uint8_t(x), + uint8_t(x >> 8)}; + os.write(reinterpret_cast(tmp), 2); +} + +void write_little(std::ostream& os, uint32_t x) +{ + uint8_t tmp[4] { + uint8_t(x), + uint8_t(x >> 8), + uint8_t(x >> 16), + uint8_t(x >> 24)}; + os.write(reinterpret_cast(tmp), 4); +} + +void write_little(std::ostream& os, uint64_t x) +{ + uint8_t tmp[8] { + uint8_t(x), + uint8_t(x >> 8), + uint8_t(x >> 16), + uint8_t(x >> 24), + uint8_t(x >> 32), + uint8_t(x >> 40), + uint8_t(x >> 48), + uint8_t(x >> 56)}; + os.write(reinterpret_cast(tmp), 8); +} + +void write_little(std::ostream& os, int8_t x) { write_little(os, static_cast(x)); } +void write_little(std::ostream& os, int16_t x) { write_little(os, static_cast(x)); } +void write_little(std::ostream& os, int32_t x) { write_little(os, static_cast(x)); } +void write_little(std::ostream& os, int64_t x) { write_little(os, static_cast(x)); } + +void write_little(std::ostream& os, float x) +{ + write_little(os, pun_float_to_int(x)); +} + +void write_little(std::ostream& os, double x) +{ + write_little(os, pun_double_to_int(x)); +} + +//------------------------------------------------------------------------------ + +void write_big(std::ostream& os, uint8_t x) +{ + os.put(x); +} + +void write_big(std::ostream& os, uint16_t x) +{ + uint8_t tmp[2] { + uint8_t(x >> 8), + uint8_t(x)}; + os.write(reinterpret_cast(tmp), 2); +} + +void write_big(std::ostream& os, uint32_t x) +{ + uint8_t tmp[4] { + uint8_t(x >> 24), + uint8_t(x >> 16), + uint8_t(x >> 8), + uint8_t(x)}; + os.write(reinterpret_cast(tmp), 4); +} + +void write_big(std::ostream& os, uint64_t x) +{ + uint8_t tmp[8] { + uint8_t(x >> 56), + uint8_t(x >> 48), + uint8_t(x >> 40), + uint8_t(x >> 32), + uint8_t(x >> 24), + uint8_t(x >> 16), + uint8_t(x >> 8), + uint8_t(x)}; + os.write(reinterpret_cast(tmp), 8); +} + +void write_big(std::ostream& os, int8_t x) { write_big(os, static_cast(x)); } +void write_big(std::ostream& os, int16_t x) { write_big(os, static_cast(x)); } +void write_big(std::ostream& os, int32_t x) { write_big(os, static_cast(x)); } +void write_big(std::ostream& os, int64_t x) { write_big(os, static_cast(x)); } + +void write_big(std::ostream& os, float x) +{ + write_big(os, pun_float_to_int(x)); +} + +void write_big(std::ostream& os, double x) +{ + write_big(os, pun_double_to_int(x)); +} + +} diff --git a/libraries/libnbtplusplus/src/io/izlibstream.cpp b/libraries/libnbtplusplus/src/io/izlibstream.cpp new file mode 100644 index 0000000..0a75124 --- /dev/null +++ b/libraries/libnbtplusplus/src/io/izlibstream.cpp @@ -0,0 +1,98 @@ +/* + * libnbt++ - A library for the Minecraft Named Binary Tag format. + * Copyright (C) 2013, 2015 ljfa-ag + * + * This file is part of libnbt++. + * + * libnbt++ is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * libnbt++ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with libnbt++. If not, see . + */ +#include "io/izlibstream.h" +#include "io/zlib_streambuf.h" + +namespace zlib +{ + +inflate_streambuf::inflate_streambuf(std::istream& input, size_t bufsize, int window_bits): + zlib_streambuf(bufsize), is(input), stream_end(false) +{ + zstr.next_in = Z_NULL; + zstr.avail_in = 0; + int ret = inflateInit2(&zstr, window_bits); + if(ret != Z_OK) + throw zlib_error(zstr.msg, ret); + + char* end = out.data() + out.size(); + setg(end, end, end); +} + +inflate_streambuf::~inflate_streambuf() noexcept +{ + inflateEnd(&zstr); +} + +inflate_streambuf::int_type inflate_streambuf::underflow() +{ + if(gptr() < egptr()) + return traits_type::to_int_type(*gptr()); + + size_t have; + do + { + //Read if input buffer is empty + if(zstr.avail_in <= 0) + { + is.read(in.data(), in.size()); + if(is.bad()) + throw std::ios_base::failure("Input stream is bad"); + size_t count = is.gcount(); + if(count == 0 && !stream_end) + throw zlib_error("Unexpected end of stream", Z_DATA_ERROR); + + zstr.next_in = reinterpret_cast(in.data()); + zstr.avail_in = count; + } + + zstr.next_out = reinterpret_cast(out.data()); + zstr.avail_out = out.size(); + + int ret = inflate(&zstr, Z_NO_FLUSH); + have = out.size() - zstr.avail_out; + switch(ret) + { + case Z_NEED_DICT: + case Z_DATA_ERROR: + throw zlib_error(zstr.msg, ret); + + case Z_MEM_ERROR: + throw std::bad_alloc(); + + case Z_STREAM_END: + if(!stream_end) + { + stream_end = true; + //In case we consumed too much, we have to rewind the input stream + is.clear(); + is.seekg(-static_cast(zstr.avail_in), std::ios_base::cur); + } + if(have == 0) + return traits_type::eof(); + break; + } + } while(have == 0); + + setg(out.data(), out.data(), out.data() + have); + return traits_type::to_int_type(*gptr()); +} + +} diff --git a/libraries/libnbtplusplus/src/io/ozlibstream.cpp b/libraries/libnbtplusplus/src/io/ozlibstream.cpp new file mode 100644 index 0000000..73f1057 --- /dev/null +++ b/libraries/libnbtplusplus/src/io/ozlibstream.cpp @@ -0,0 +1,106 @@ +/* + * libnbt++ - A library for the Minecraft Named Binary Tag format. + * Copyright (C) 2013, 2015 ljfa-ag + * + * This file is part of libnbt++. + * + * libnbt++ is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * libnbt++ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with libnbt++. If not, see . + */ +#include "io/ozlibstream.h" +#include "io/zlib_streambuf.h" + +namespace zlib +{ + +deflate_streambuf::deflate_streambuf(std::ostream& output, size_t bufsize, int level, int window_bits, int mem_level, int strategy): + zlib_streambuf(bufsize), os(output) +{ + int ret = deflateInit2(&zstr, level, Z_DEFLATED, window_bits, mem_level, strategy); + if(ret != Z_OK) + throw zlib_error(zstr.msg, ret); + + setp(in.data(), in.data() + in.size()); +} + +deflate_streambuf::~deflate_streambuf() noexcept +{ + try + { + close(); + } + catch(...) + { + //ignore as we can't do anything about it + } + deflateEnd(&zstr); +} + +void deflate_streambuf::close() +{ + deflate_chunk(Z_FINISH); +} + +void deflate_streambuf::deflate_chunk(int flush) +{ + zstr.next_in = reinterpret_cast(pbase()); + zstr.avail_in = pptr() - pbase(); + do + { + zstr.next_out = reinterpret_cast(out.data()); + zstr.avail_out = out.size(); + int ret = deflate(&zstr, flush); + if(ret != Z_OK && ret != Z_STREAM_END) + { + os.setstate(std::ios_base::failbit); + throw zlib_error(zstr.msg, ret); + } + int have = out.size() - zstr.avail_out; + if(!os.write(out.data(), have)) + throw std::ios_base::failure("Could not write to the output stream"); + } while(zstr.avail_out == 0); + setp(in.data(), in.data() + in.size()); +} + +deflate_streambuf::int_type deflate_streambuf::overflow(int_type ch) +{ + deflate_chunk(); + if(ch != traits_type::eof()) + { + *pptr() = ch; + pbump(1); + } + return ch; +} + +int deflate_streambuf::sync() +{ + deflate_chunk(); + return 0; +} + +void ozlibstream::close() +{ + try + { + buf.close(); + } + catch(...) + { + setstate(badbit); //FIXME: This will throw the wrong type of exception + //but there's no good way of setting the badbit + //without causing an exception when exceptions is set + } +} + +} diff --git a/libraries/libnbtplusplus/src/io/stream_reader.cpp b/libraries/libnbtplusplus/src/io/stream_reader.cpp new file mode 100644 index 0000000..28c1e1d --- /dev/null +++ b/libraries/libnbtplusplus/src/io/stream_reader.cpp @@ -0,0 +1,115 @@ +/* + * libnbt++ - A library for the Minecraft Named Binary Tag format. + * Copyright (C) 2013, 2015 ljfa-ag + * + * This file is part of libnbt++. + * + * libnbt++ is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * libnbt++ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with libnbt++. If not, see . + */ +#include "io/stream_reader.h" +#include "make_unique.h" +#include "tag_compound.h" +#include + +namespace nbt +{ +namespace io +{ + +static constexpr int MAX_DEPTH = 1024; + +std::pair> read_compound(std::istream& is, endian::endian e) +{ + return stream_reader(is, e).read_compound(); +} + +std::pair> read_tag(std::istream& is, endian::endian e) +{ + return stream_reader(is, e).read_tag(); +} + +stream_reader::stream_reader(std::istream& is, endian::endian e) noexcept: + is(is), endian(e) +{} + +std::istream& stream_reader::get_istr() const +{ + return is; +} + +endian::endian stream_reader::get_endian() const +{ + return endian; +} + +std::pair> stream_reader::read_compound() +{ + if(read_type() != tag_type::Compound) + { + is.setstate(std::ios::failbit); + throw input_error("Tag is not a compound"); + } + std::string key = read_string(); + auto comp = make_unique(); + comp->read_payload(*this); + return {std::move(key), std::move(comp)}; +} + +std::pair> stream_reader::read_tag() +{ + tag_type type = read_type(); + std::string key = read_string(); + std::unique_ptr t = read_payload(type); + return {std::move(key), std::move(t)}; +} + +std::unique_ptr stream_reader::read_payload(tag_type type) +{ + if (++depth > MAX_DEPTH) + throw input_error("Too deeply nested"); + std::unique_ptr t = tag::create(type); + t->read_payload(*this); + --depth; + return t; +} + +tag_type stream_reader::read_type(bool allow_end) +{ + int type = is.get(); + if(!is) + throw input_error("Error reading tag type"); + if(!is_valid_type(type, allow_end)) + { + is.setstate(std::ios::failbit); + throw input_error("Invalid tag type: " + std::to_string(type)); + } + return static_cast(type); +} + +std::string stream_reader::read_string() +{ + uint16_t len; + read_num(len); + if(!is) + throw input_error("Error reading string"); + + std::string ret(len, '\0'); + is.read(&ret[0], len); //C++11 allows us to do this + if(!is) + throw input_error("Error reading string"); + return ret; +} + +} +} diff --git a/libraries/libnbtplusplus/src/io/stream_writer.cpp b/libraries/libnbtplusplus/src/io/stream_writer.cpp new file mode 100644 index 0000000..036c5d4 --- /dev/null +++ b/libraries/libnbtplusplus/src/io/stream_writer.cpp @@ -0,0 +1,54 @@ +/* + * libnbt++ - A library for the Minecraft Named Binary Tag format. + * Copyright (C) 2013, 2015 ljfa-ag + * + * This file is part of libnbt++. + * + * libnbt++ is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * libnbt++ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with libnbt++. If not, see . + */ +#include "io/stream_writer.h" +#include + +namespace nbt +{ +namespace io +{ + +void write_tag(const std::string& key, const tag& t, std::ostream& os, endian::endian e) +{ + stream_writer(os, e).write_tag(key, t); +} + +void stream_writer::write_tag(const std::string& key, const tag& t) +{ + write_type(t.get_type()); + write_string(key); + write_payload(t); +} + +void stream_writer::write_string(const std::string& str) +{ + if(str.size() > max_string_len) + { + os.setstate(std::ios::failbit); + std::ostringstream sstr; + sstr << "String is too long for NBT (" << str.size() << " > " << max_string_len << ")"; + throw std::length_error(sstr.str()); + } + write_num(static_cast(str.size())); + os.write(str.data(), str.size()); +} + +} +} diff --git a/libraries/libnbtplusplus/src/tag.cpp b/libraries/libnbtplusplus/src/tag.cpp new file mode 100644 index 0000000..7e3be39 --- /dev/null +++ b/libraries/libnbtplusplus/src/tag.cpp @@ -0,0 +1,107 @@ +/* + * libnbt++ - A library for the Minecraft Named Binary Tag format. + * Copyright (C) 2013, 2015 ljfa-ag + * + * This file is part of libnbt++. + * + * libnbt++ is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * libnbt++ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with libnbt++. If not, see . + */ +#include "tag.h" +#include "nbt_tags.h" +#include "text/json_formatter.h" +#include +#include +#include +#include + +namespace nbt +{ + +static_assert(std::numeric_limits::is_iec559 && std::numeric_limits::is_iec559, + "The floating point values for NBT must conform to IEC 559/IEEE 754"); + +bool is_valid_type(int type, bool allow_end) +{ + return (allow_end ? 0 : 1) <= type && type <= 12; +} + +std::unique_ptr tag::clone() && +{ + return std::move(*this).move_clone(); +} + +std::unique_ptr tag::create(tag_type type) +{ + switch(type) + { + case tag_type::Byte: return make_unique(); + case tag_type::Short: return make_unique(); + case tag_type::Int: return make_unique(); + case tag_type::Long: return make_unique(); + case tag_type::Float: return make_unique(); + case tag_type::Double: return make_unique(); + case tag_type::Byte_Array: return make_unique(); + case tag_type::String: return make_unique(); + case tag_type::List: return make_unique(); + case tag_type::Compound: return make_unique(); + case tag_type::Int_Array: return make_unique(); + case tag_type::Long_Array: return make_unique(); + + default: throw std::invalid_argument("Invalid tag type"); + } +} + +bool operator==(const tag& lhs, const tag& rhs) +{ + if(typeid(lhs) != typeid(rhs)) + return false; + return lhs.equals(rhs); +} + +bool operator!=(const tag& lhs, const tag& rhs) +{ + return !(lhs == rhs); +} + +std::ostream& operator<<(std::ostream& os, tag_type tt) +{ + switch(tt) + { + case tag_type::End: return os << "end"; + case tag_type::Byte: return os << "byte"; + case tag_type::Short: return os << "short"; + case tag_type::Int: return os << "int"; + case tag_type::Long: return os << "long"; + case tag_type::Float: return os << "float"; + case tag_type::Double: return os << "double"; + case tag_type::Byte_Array: return os << "byte_array"; + case tag_type::String: return os << "string"; + case tag_type::List: return os << "list"; + case tag_type::Compound: return os << "compound"; + case tag_type::Int_Array: return os << "int_array"; + case tag_type::Long_Array: return os << "long_array"; + case tag_type::Null: return os << "null"; + + default: return os << "invalid"; + } +} + +std::ostream& operator<<(std::ostream& os, const tag& t) +{ + static const text::json_formatter formatter; + formatter.print(os, t); + return os; +} + +} diff --git a/libraries/libnbtplusplus/src/tag_compound.cpp b/libraries/libnbtplusplus/src/tag_compound.cpp new file mode 100644 index 0000000..4085bb4 --- /dev/null +++ b/libraries/libnbtplusplus/src/tag_compound.cpp @@ -0,0 +1,109 @@ +/* + * libnbt++ - A library for the Minecraft Named Binary Tag format. + * Copyright (C) 2013, 2015 ljfa-ag + * + * This file is part of libnbt++. + * + * libnbt++ is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * libnbt++ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with libnbt++. If not, see . + */ +#include "tag_compound.h" +#include "io/stream_reader.h" +#include "io/stream_writer.h" +#include +#include + +namespace nbt +{ + +tag_compound::tag_compound(std::initializer_list> init) +{ + for(const auto& pair: init) + tags.emplace(std::move(pair.first), std::move(pair.second)); +} + +value& tag_compound::at(const std::string& key) +{ + return tags.at(key); +} + +const value& tag_compound::at(const std::string& key) const +{ + return tags.at(key); +} + +std::pair tag_compound::put(const std::string& key, value_initializer&& val) +{ + auto it = tags.find(key); + if(it != tags.end()) + { + it->second = std::move(val); + return {it, false}; + } + else + { + return tags.emplace(key, std::move(val)); + } +} + +std::pair tag_compound::insert(const std::string& key, value_initializer&& val) +{ + return tags.emplace(key, std::move(val)); +} + +bool tag_compound::erase(const std::string& key) +{ + return tags.erase(key) != 0; +} + +bool tag_compound::has_key(const std::string& key) const +{ + return tags.find(key) != tags.end(); +} + +bool tag_compound::has_key(const std::string& key, tag_type type) const +{ + auto it = tags.find(key); + return it != tags.end() && it->second.get_type() == type; +} + +void tag_compound::read_payload(io::stream_reader& reader) +{ + clear(); + tag_type tt; + while((tt = reader.read_type(true)) != tag_type::End) + { + std::string key; + try + { + key = reader.read_string(); + } + catch(io::input_error& ex) + { + std::ostringstream str; + str << "Error reading key of tag_" << tt; + throw io::input_error(str.str()); + } + auto tptr = reader.read_payload(tt); + tags.emplace(std::move(key), value(std::move(tptr))); + } +} + +void tag_compound::write_payload(io::stream_writer& writer) const +{ + for(const auto& pair: tags) + writer.write_tag(pair.first, pair.second); + writer.write_type(tag_type::End); +} + +} diff --git a/libraries/libnbtplusplus/src/tag_list.cpp b/libraries/libnbtplusplus/src/tag_list.cpp new file mode 100644 index 0000000..1650e60 --- /dev/null +++ b/libraries/libnbtplusplus/src/tag_list.cpp @@ -0,0 +1,151 @@ +/* + * libnbt++ - A library for the Minecraft Named Binary Tag format. + * Copyright (C) 2013, 2015 ljfa-ag + * + * This file is part of libnbt++. + * + * libnbt++ is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * libnbt++ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with libnbt++. If not, see . + */ +#include "tag_list.h" +#include "nbt_tags.h" +#include "io/stream_reader.h" +#include "io/stream_writer.h" +#include + +namespace nbt +{ + +tag_list::tag_list(std::initializer_list il) { init(il); } +tag_list::tag_list(std::initializer_list il) { init(il); } +tag_list::tag_list(std::initializer_list il) { init(il); } +tag_list::tag_list(std::initializer_list il) { init(il); } +tag_list::tag_list(std::initializer_list il) { init(il); } +tag_list::tag_list(std::initializer_list il) { init(il); } +tag_list::tag_list(std::initializer_list il) { init(il); } +tag_list::tag_list(std::initializer_list il) { init(il); } +tag_list::tag_list(std::initializer_list il) { init(il); } +tag_list::tag_list(std::initializer_list il) { init(il); } +tag_list::tag_list(std::initializer_list il) { init(il); } +tag_list::tag_list(std::initializer_list il) { init(il); } + +tag_list::tag_list(std::initializer_list init) +{ + if(init.size() == 0) + el_type_ = tag_type::Null; + else + { + el_type_ = init.begin()->get_type(); + for(const value& val: init) + { + if(!val || val.get_type() != el_type_) + throw std::invalid_argument("The values are not all the same type"); + } + tags.assign(init.begin(), init.end()); + } +} + +value& tag_list::at(size_t i) +{ + return tags.at(i); +} + +const value& tag_list::at(size_t i) const +{ + return tags.at(i); +} + +void tag_list::set(size_t i, value&& val) +{ + if(val.get_type() != el_type_) + throw std::invalid_argument("The tag type does not match the list's content type"); + tags.at(i) = std::move(val); +} + +void tag_list::push_back(value_initializer&& val) +{ + if(!val) //don't allow null values + throw std::invalid_argument("The value must not be null"); + if(el_type_ == tag_type::Null) //set content type if undetermined + el_type_ = val.get_type(); + else if(el_type_ != val.get_type()) + throw std::invalid_argument("The tag type does not match the list's content type"); + tags.push_back(std::move(val)); +} + +void tag_list::reset(tag_type type) +{ + clear(); + el_type_ = type; +} + +void tag_list::read_payload(io::stream_reader& reader) +{ + tag_type lt = reader.read_type(true); + + int32_t length; + reader.read_num(length); + if(length < 0) + reader.get_istr().setstate(std::ios::failbit); + if(!reader.get_istr()) + throw io::input_error("Error reading length of tag_list"); + + if(lt != tag_type::End) + { + reset(lt); + tags.reserve(length); + + for(int32_t i = 0; i < length; ++i) + tags.emplace_back(reader.read_payload(lt)); + } + else + { + //In case of tag_end, ignore the length and leave the type undetermined + reset(tag_type::Null); + } +} + +void tag_list::write_payload(io::stream_writer& writer) const +{ + if(size() > io::stream_writer::max_array_len) + { + writer.get_ostr().setstate(std::ios::failbit); + throw std::length_error("List is too large for NBT"); + } + writer.write_type(el_type_ != tag_type::Null + ? el_type_ + : tag_type::End); + writer.write_num(static_cast(size())); + for(const auto& val: tags) + { + //check if the value is of the correct type + if(val.get_type() != el_type_) + { + writer.get_ostr().setstate(std::ios::failbit); + throw std::logic_error("The tags in the list do not all match the content type"); + } + writer.write_payload(val); + } +} + +bool operator==(const tag_list& lhs, const tag_list& rhs) +{ + return lhs.el_type_ == rhs.el_type_ && lhs.tags == rhs.tags; +} + +bool operator!=(const tag_list& lhs, const tag_list& rhs) +{ + return !(lhs == rhs); +} + +} diff --git a/libraries/libnbtplusplus/src/tag_string.cpp b/libraries/libnbtplusplus/src/tag_string.cpp new file mode 100644 index 0000000..3034781 --- /dev/null +++ b/libraries/libnbtplusplus/src/tag_string.cpp @@ -0,0 +1,44 @@ +/* + * libnbt++ - A library for the Minecraft Named Binary Tag format. + * Copyright (C) 2013, 2015 ljfa-ag + * + * This file is part of libnbt++. + * + * libnbt++ is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * libnbt++ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with libnbt++. If not, see . + */ +#include "tag_string.h" +#include "io/stream_reader.h" +#include "io/stream_writer.h" + +namespace nbt +{ + +void tag_string::read_payload(io::stream_reader& reader) +{ + try + { + value = reader.read_string(); + } + catch(io::input_error& ex) + { + throw io::input_error("Error reading tag_string"); + } +} + +void tag_string::write_payload(io::stream_writer& writer) const +{ + writer.write_string(value); +} + +} diff --git a/libraries/libnbtplusplus/src/text/json_formatter.cpp b/libraries/libnbtplusplus/src/text/json_formatter.cpp new file mode 100644 index 0000000..8a62825 --- /dev/null +++ b/libraries/libnbtplusplus/src/text/json_formatter.cpp @@ -0,0 +1,207 @@ +/* + * libnbt++ - A library for the Minecraft Named Binary Tag format. + * Copyright (C) 2013, 2015 ljfa-ag + * + * This file is part of libnbt++. + * + * libnbt++ is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * libnbt++ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with libnbt++. If not, see . + */ +#include "text/json_formatter.h" +#include "nbt_tags.h" +#include "nbt_visitor.h" +#include +#include +#include + +namespace nbt +{ +namespace text +{ + +namespace //anonymous +{ + ///Helper class which uses the Visitor pattern to pretty-print tags + class json_fmt_visitor : public const_nbt_visitor + { + public: + json_fmt_visitor(std::ostream& os): + os(os) + {} + + void visit(const tag_byte& b) override + { os << static_cast(b.get()) << "b"; } //We don't want to print a character + + void visit(const tag_short& s) override + { os << s.get() << "s"; } + + void visit(const tag_int& i) override + { os << i.get(); } + + void visit(const tag_long& l) override + { os << l.get() << "l"; } + + void visit(const tag_float& f) override + { + write_float(f.get()); + os << "f"; + } + + void visit(const tag_double& d) override + { + write_float(d.get()); + os << "d"; + } + + void visit(const tag_byte_array& ba) override + { os << "[" << ba.size() << " bytes]"; } + + void visit(const tag_string& s) override + { os << '"' << s.get() << '"'; } //TODO: escape special characters + + void visit(const tag_list& l) override + { + //Wrap lines for lists of lists or compounds. + //Lists of other types can usually be on one line without problem. + const bool break_lines = l.size() > 0 && + (l.el_type() == tag_type::List || l.el_type() == tag_type::Compound); + + os << "["; + if(break_lines) + { + os << "\n"; + ++indent_lvl; + for(unsigned int i = 0; i < l.size(); ++i) + { + indent(); + if(l[i]) + l[i].get().accept(*this); + else + write_null(); + if(i != l.size()-1) + os << ","; + os << "\n"; + } + --indent_lvl; + indent(); + } + else + { + for(unsigned int i = 0; i < l.size(); ++i) + { + if(l[i]) + l[i].get().accept(*this); + else + write_null(); + if(i != l.size()-1) + os << ", "; + } + } + os << "]"; + } + + void visit(const tag_compound& c) override + { + if(c.size() == 0) //No line breaks inside empty compounds please + { + os << "{}"; + return; + } + + os << "{\n"; + ++indent_lvl; + unsigned int i = 0; + for(const auto& kv: c) + { + indent(); + os << kv.first << ": "; + if(kv.second) + kv.second.get().accept(*this); + else + write_null(); + if(i != c.size()-1) + os << ","; + os << "\n"; + ++i; + } + --indent_lvl; + indent(); + os << "}"; + } + + void visit(const tag_int_array& ia) override + { + os << "["; + for(unsigned int i = 0; i < ia.size(); ++i) + { + os << ia[i]; + if(i != ia.size()-1) + os << ", "; + } + os << "]"; + } + + void visit(const tag_long_array& la) override + { + os << "["; + for(unsigned int i = 0; i < la.size(); ++i) + { + os << la[i]; + if(i != la.size()-1) + os << ", "; + } + os << "]"; + } + + private: + const std::string indent_str = " "; + + std::ostream& os; + int indent_lvl = 0; + + void indent() + { + for(int i = 0; i < indent_lvl; ++i) + os << indent_str; + } + + template + void write_float(T val, int precision = std::numeric_limits::max_digits10) + { + if(std::isfinite(val)) + os << std::setprecision(precision) << val; + else if(std::isinf(val)) + { + if(std::signbit(val)) + os << "-"; + os << "Infinity"; + } + else + os << "NaN"; + } + + void write_null() + { + os << "null"; + } + }; +} + +void json_formatter::print(std::ostream& os, const tag& t) const +{ + json_fmt_visitor v(os); + t.accept(v); +} + +} +} diff --git a/libraries/libnbtplusplus/src/value.cpp b/libraries/libnbtplusplus/src/value.cpp new file mode 100644 index 0000000..8376dc9 --- /dev/null +++ b/libraries/libnbtplusplus/src/value.cpp @@ -0,0 +1,376 @@ +/* + * libnbt++ - A library for the Minecraft Named Binary Tag format. + * Copyright (C) 2013, 2015 ljfa-ag + * + * This file is part of libnbt++. + * + * libnbt++ is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * libnbt++ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with libnbt++. If not, see . + */ +#include "value.h" +#include "nbt_tags.h" +#include + +namespace nbt +{ + +value::value(tag&& t): + tag_(std::move(t).move_clone()) +{} + +value::value(const value& rhs): + tag_(rhs.tag_ ? rhs.tag_->clone() : nullptr) +{} + +value& value::operator=(const value& rhs) +{ + if(this != &rhs) + { + tag_ = rhs.tag_ ? rhs.tag_->clone() : nullptr; + } + return *this; +} + +value& value::operator=(tag&& t) +{ + set(std::move(t)); + return *this; +} + +void value::set(tag&& t) +{ + if(tag_) + tag_->assign(std::move(t)); + else + tag_ = std::move(t).move_clone(); +} + +//Primitive assignment +//FIXME: Make this less copypaste! +value& value::operator=(int8_t val) +{ + if(!tag_) + set(tag_byte(val)); + else switch(tag_->get_type()) + { + case tag_type::Byte: + static_cast(*tag_).set(val); + break; + case tag_type::Short: + static_cast(*tag_).set(val); + break; + case tag_type::Int: + static_cast(*tag_).set(val); + break; + case tag_type::Long: + static_cast(*tag_).set(val); + break; + case tag_type::Float: + static_cast(*tag_).set(val); + break; + case tag_type::Double: + static_cast(*tag_).set(val); + break; + + default: + throw std::bad_cast(); + } + return *this; +} + +value& value::operator=(int16_t val) +{ + if(!tag_) + set(tag_short(val)); + else switch(tag_->get_type()) + { + case tag_type::Short: + static_cast(*tag_).set(val); + break; + case tag_type::Int: + static_cast(*tag_).set(val); + break; + case tag_type::Long: + static_cast(*tag_).set(val); + break; + case tag_type::Float: + static_cast(*tag_).set(val); + break; + case tag_type::Double: + static_cast(*tag_).set(val); + break; + + default: + throw std::bad_cast(); + } + return *this; +} + +value& value::operator=(int32_t val) +{ + if(!tag_) + set(tag_int(val)); + else switch(tag_->get_type()) + { + case tag_type::Int: + static_cast(*tag_).set(val); + break; + case tag_type::Long: + static_cast(*tag_).set(val); + break; + case tag_type::Float: + static_cast(*tag_).set(val); + break; + case tag_type::Double: + static_cast(*tag_).set(val); + break; + + default: + throw std::bad_cast(); + } + return *this; +} + +value& value::operator=(int64_t val) +{ + if(!tag_) + set(tag_long(val)); + else switch(tag_->get_type()) + { + case tag_type::Long: + static_cast(*tag_).set(val); + break; + case tag_type::Float: + static_cast(*tag_).set(val); + break; + case tag_type::Double: + static_cast(*tag_).set(val); + break; + + default: + throw std::bad_cast(); + } + return *this; +} + +value& value::operator=(float val) +{ + if(!tag_) + set(tag_float(val)); + else switch(tag_->get_type()) + { + case tag_type::Float: + static_cast(*tag_).set(val); + break; + case tag_type::Double: + static_cast(*tag_).set(val); + break; + + default: + throw std::bad_cast(); + } + return *this; +} + +value& value::operator=(double val) +{ + if(!tag_) + set(tag_double(val)); + else switch(tag_->get_type()) + { + case tag_type::Double: + static_cast(*tag_).set(val); + break; + + default: + throw std::bad_cast(); + } + return *this; +} + +//Primitive conversion +value::operator int8_t() const +{ + switch(tag_->get_type()) + { + case tag_type::Byte: + return static_cast(*tag_).get(); + + default: + throw std::bad_cast(); + } +} + +value::operator int16_t() const +{ + switch(tag_->get_type()) + { + case tag_type::Byte: + return static_cast(*tag_).get(); + case tag_type::Short: + return static_cast(*tag_).get(); + + default: + throw std::bad_cast(); + } +} + +value::operator int32_t() const +{ + switch(tag_->get_type()) + { + case tag_type::Byte: + return static_cast(*tag_).get(); + case tag_type::Short: + return static_cast(*tag_).get(); + case tag_type::Int: + return static_cast(*tag_).get(); + + default: + throw std::bad_cast(); + } +} + +value::operator int64_t() const +{ + switch(tag_->get_type()) + { + case tag_type::Byte: + return static_cast(*tag_).get(); + case tag_type::Short: + return static_cast(*tag_).get(); + case tag_type::Int: + return static_cast(*tag_).get(); + case tag_type::Long: + return static_cast(*tag_).get(); + + default: + throw std::bad_cast(); + } +} + +value::operator float() const +{ + switch(tag_->get_type()) + { + case tag_type::Byte: + return static_cast(*tag_).get(); + case tag_type::Short: + return static_cast(*tag_).get(); + case tag_type::Int: + return static_cast(*tag_).get(); + case tag_type::Long: + return static_cast(*tag_).get(); + case tag_type::Float: + return static_cast(*tag_).get(); + + default: + throw std::bad_cast(); + } +} + +value::operator double() const +{ + switch(tag_->get_type()) + { + case tag_type::Byte: + return static_cast(*tag_).get(); + case tag_type::Short: + return static_cast(*tag_).get(); + case tag_type::Int: + return static_cast(*tag_).get(); + case tag_type::Long: + return static_cast(*tag_).get(); + case tag_type::Float: + return static_cast(*tag_).get(); + case tag_type::Double: + return static_cast(*tag_).get(); + + default: + throw std::bad_cast(); + } +} + +value& value::operator=(std::string&& str) +{ + if(!tag_) + set(tag_string(std::move(str))); + else + dynamic_cast(*tag_).set(std::move(str)); + return *this; +} + +value::operator const std::string&() const +{ + return dynamic_cast(*tag_).get(); +} + +value& value::at(const std::string& key) +{ + return dynamic_cast(*tag_).at(key); +} + +const value& value::at(const std::string& key) const +{ + return dynamic_cast(*tag_).at(key); +} + +value& value::operator[](const std::string& key) +{ + return dynamic_cast(*tag_)[key]; +} + +value& value::operator[](const char* key) +{ + return (*this)[std::string(key)]; +} + +value& value::at(size_t i) +{ + return dynamic_cast(*tag_).at(i); +} + +const value& value::at(size_t i) const +{ + return dynamic_cast(*tag_).at(i); +} + +value& value::operator[](size_t i) +{ + return dynamic_cast(*tag_)[i]; +} + +const value& value::operator[](size_t i) const +{ + return dynamic_cast(*tag_)[i]; +} + +tag_type value::get_type() const +{ + return tag_ ? tag_->get_type() : tag_type::Null; +} + +bool operator==(const value& lhs, const value& rhs) +{ + if(lhs.tag_ != nullptr && rhs.tag_ != nullptr) + return *lhs.tag_ == *rhs.tag_; + else + return lhs.tag_ == nullptr && rhs.tag_ == nullptr; +} + +bool operator!=(const value& lhs, const value& rhs) +{ + return !(lhs == rhs); +} + +} diff --git a/libraries/libnbtplusplus/src/value_initializer.cpp b/libraries/libnbtplusplus/src/value_initializer.cpp new file mode 100644 index 0000000..3735bfd --- /dev/null +++ b/libraries/libnbtplusplus/src/value_initializer.cpp @@ -0,0 +1,36 @@ +/* + * libnbt++ - A library for the Minecraft Named Binary Tag format. + * Copyright (C) 2013, 2015 ljfa-ag + * + * This file is part of libnbt++. + * + * libnbt++ is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * libnbt++ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with libnbt++. If not, see . + */ +#include "value_initializer.h" +#include "nbt_tags.h" + +namespace nbt +{ + +value_initializer::value_initializer(int8_t val) : value(tag_byte(val)) {} +value_initializer::value_initializer(int16_t val) : value(tag_short(val)) {} +value_initializer::value_initializer(int32_t val) : value(tag_int(val)) {} +value_initializer::value_initializer(int64_t val) : value(tag_long(val)) {} +value_initializer::value_initializer(float val) : value(tag_float(val)) {} +value_initializer::value_initializer(double val) : value(tag_double(val)) {} +value_initializer::value_initializer(const std::string& str): value(tag_string(str)) {} +value_initializer::value_initializer(std::string&& str) : value(tag_string(std::move(str))) {} +value_initializer::value_initializer(const char* str) : value(tag_string(str)) {} + +} diff --git a/libraries/libnbtplusplus/test/CMakeLists.txt b/libraries/libnbtplusplus/test/CMakeLists.txt new file mode 100644 index 0000000..5f6e241 --- /dev/null +++ b/libraries/libnbtplusplus/test/CMakeLists.txt @@ -0,0 +1,105 @@ +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + if(CMAKE_SYSTEM_PROCESSOR STREQUAL x86_64 OR CMAKE_SYSTEM_PROCESSOR STREQUAL amd64) + set(OBJCOPY_TARGET "elf64-x86-64") + set(OBJCOPY_ARCH "x86_64") + elseif(CMAKE_SYSTEM_PROCESSOR STREQUAL i686) + set(OBJCOPY_TARGET "elf32-i386") + set(OBJCOPY_ARCH "i386") + else() + message(AUTHOR_WARNING "This is not a platform that would support testing nbt++") + return() + endif() +else() + message(AUTHOR_WARNING "This is not a platform that would support testing nbt++") + return() +endif() + +enable_testing() +find_package(CxxTest REQUIRED) + +include_directories(${libnbt++_SOURCE_DIR}/include) +include_directories(${CXXTEST_INCLUDE_DIR}) + +function(build_data out_var) + set(result) + foreach(in_f ${ARGN}) + set(out_f "${CMAKE_CURRENT_BINARY_DIR}/testfiles/${in_f}.obj") + add_custom_command( + COMMAND mkdir -p "${CMAKE_CURRENT_BINARY_DIR}/testfiles" + COMMAND ${CMAKE_OBJCOPY} --prefix-symbol=_ --input-target=binary --output-target=${OBJCOPY_TARGET} "${in_f}" "${out_f}" + DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/testfiles/${in_f} + OUTPUT ${out_f} + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/testfiles/ + VERBATIM + ) + SET_SOURCE_FILES_PROPERTIES( + ${out_f} + PROPERTIES + EXTERNAL_OBJECT true + GENERATED true + ) + list(APPEND result ${out_f}) + endforeach() + set(${out_var} "${result}" PARENT_SCOPE) +endfunction() + +build_data(DATA_OBJECTS + bigtest.nbt + bigtest.zlib + bigtest_corrupt.nbt + bigtest_eof.nbt + bigtest_uncompr + errortest_eof1 + errortest_eof2 + errortest_neg_length + errortest_noend + littletest_uncompr + toplevel_string + trailing_data.zlib +) +add_library(NbtTestData STATIC ${DATA_OBJECTS}) + +#Specifies that the directory containing the testfiles get copied when the target is built +function(use_testfiles target) + add_custom_command(TARGET ${target} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E + copy_directory ${CMAKE_CURRENT_SOURCE_DIR}/testfiles ${CMAKE_CURRENT_BINARY_DIR}) +endfunction() + +function(stop_warnings target) + target_compile_options(${target} PRIVATE + -Wno-unused-value + -Wno-self-assign-overloaded + ) +endfunction() + +if(NBT_USE_ZLIB) + set(EXTRA_TEST_LIBS ${ZLIB_LIBRARY}) +endif() + +CXXTEST_ADD_TEST(nbttest nbttest.cpp ${CMAKE_CURRENT_SOURCE_DIR}/nbttest.h) +target_link_libraries(nbttest ${NBT_NAME}) +stop_warnings(nbttest) + +CXXTEST_ADD_TEST(endian_str_test endian_str_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/endian_str_test.h) +target_link_libraries(endian_str_test ${NBT_NAME}) +stop_warnings(endian_str_test) + +CXXTEST_ADD_TEST(read_test read_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/read_test.h) +target_link_libraries(read_test ${NBT_NAME} ${EXTRA_TEST_LIBS} NbtTestData) +stop_warnings(read_test) + +CXXTEST_ADD_TEST(write_test write_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/write_test.h) +target_link_libraries(write_test ${NBT_NAME} ${EXTRA_TEST_LIBS} NbtTestData) +stop_warnings(write_test) + +if(NBT_USE_ZLIB) + CXXTEST_ADD_TEST(zlibstream_test zlibstream_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/zlibstream_test.h) + target_link_libraries(zlibstream_test ${NBT_NAME} ${EXTRA_TEST_LIBS} NbtTestData) + stop_warnings(zlibstream_test) +endif() + +add_executable(format_test format_test.cpp) +target_link_libraries(format_test ${NBT_NAME}) +add_test(format_test format_test) +stop_warnings(format_test) diff --git a/libraries/libnbtplusplus/test/data.h b/libraries/libnbtplusplus/test/data.h new file mode 100644 index 0000000..b699519 --- /dev/null +++ b/libraries/libnbtplusplus/test/data.h @@ -0,0 +1,39 @@ +#pragma once + +#include + +extern "C" uint8_t __binary_bigtest_uncompr_start[]; +extern "C" uint8_t __binary_bigtest_uncompr_end[]; + +extern "C" uint8_t __binary_littletest_uncompr_start[]; +extern "C" uint8_t __binary_littletest_uncompr_end[]; + +extern "C" uint8_t __binary_errortest_eof1_start[]; +extern "C" uint8_t __binary_errortest_eof1_end[]; + +extern "C" uint8_t __binary_errortest_eof2_start[]; +extern "C" uint8_t __binary_errortest_eof2_end[]; + +extern "C" uint8_t __binary_errortest_noend_start[]; +extern "C" uint8_t __binary_errortest_noend_end[]; + +extern "C" uint8_t __binary_errortest_neg_length_start[]; +extern "C" uint8_t __binary_errortest_neg_length_end[]; + +extern "C" uint8_t __binary_toplevel_string_start[]; +extern "C" uint8_t __binary_toplevel_string_end[]; + +extern "C" uint8_t __binary_bigtest_nbt_start[]; +extern "C" uint8_t __binary_bigtest_nbt_end[]; + +extern "C" uint8_t __binary_bigtest_zlib_start[]; +extern "C" uint8_t __binary_bigtest_zlib_end[]; + +extern "C" uint8_t __binary_bigtest_corrupt_nbt_start[]; +extern "C" uint8_t __binary_bigtest_corrupt_nbt_end[]; + +extern "C" uint8_t __binary_bigtest_eof_nbt_start[]; +extern "C" uint8_t __binary_bigtest_eof_nbt_end[]; + +extern "C" uint8_t __binary_trailing_data_zlib_start[]; +extern "C" uint8_t __binary_trailing_data_zlib_end[]; diff --git a/libraries/libnbtplusplus/test/endian_str_test.h b/libraries/libnbtplusplus/test/endian_str_test.h new file mode 100644 index 0000000..6dfba9f --- /dev/null +++ b/libraries/libnbtplusplus/test/endian_str_test.h @@ -0,0 +1,175 @@ +/* + * libnbt++ - A library for the Minecraft Named Binary Tag format. + * Copyright (C) 2013, 2015 ljfa-ag + * + * This file is part of libnbt++. + * + * libnbt++ is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * libnbt++ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with libnbt++. If not, see . + */ +#include +#include "endian_str.h" +#include +#include +#include + +using namespace endian; + +class endian_str_test : public CxxTest::TestSuite +{ +public: + void test_uint() + { + std::stringstream str(std::ios::in | std::ios::out | std::ios::binary); + + write_little(str, uint8_t (0x01)); + write_little(str, uint16_t(0x0102)); + write (str, uint32_t(0x01020304), little); + write_little(str, uint64_t(0x0102030405060708)); + + write_big (str, uint8_t (0x09)); + write_big (str, uint16_t(0x090A)); + write_big (str, uint32_t(0x090A0B0C)); + write (str, uint64_t(0x090A0B0C0D0E0F10), big); + + std::string expected{ + 1, + 2, 1, + 4, 3, 2, 1, + 8, 7, 6, 5, 4, 3, 2, 1, + + 9, + 9, 10, + 9, 10, 11, 12, + 9, 10, 11, 12, 13, 14, 15, 16 + }; + TS_ASSERT_EQUALS(str.str(), expected); + + uint8_t u8; + uint16_t u16; + uint32_t u32; + uint64_t u64; + + read_little(str, u8); + TS_ASSERT_EQUALS(u8, 0x01); + read_little(str, u16); + TS_ASSERT_EQUALS(u16, 0x0102); + read_little(str, u32); + TS_ASSERT_EQUALS(u32, 0x01020304u); + read(str, u64, little); + TS_ASSERT_EQUALS(u64, 0x0102030405060708u); + + read_big(str, u8); + TS_ASSERT_EQUALS(u8, 0x09); + read_big(str, u16); + TS_ASSERT_EQUALS(u16, 0x090A); + read(str, u32, big); + TS_ASSERT_EQUALS(u32, 0x090A0B0Cu); + read_big(str, u64); + TS_ASSERT_EQUALS(u64, 0x090A0B0C0D0E0F10u); + + TS_ASSERT(str); //Check if stream has failed + } + + void test_sint() + { + std::stringstream str(std::ios::in | std::ios::out | std::ios::binary); + + write_little(str, int8_t (-0x01)); + write_little(str, int16_t(-0x0102)); + write_little(str, int32_t(-0x01020304)); + write (str, int64_t(-0x0102030405060708), little); + + write_big (str, int8_t (-0x09)); + write_big (str, int16_t(-0x090A)); + write (str, int32_t(-0x090A0B0C), big); + write_big (str, int64_t(-0x090A0B0C0D0E0F10)); + + std::string expected{ //meh, stupid narrowing conversions + '\xFF', + '\xFE', '\xFE', + '\xFC', '\xFC', '\xFD', '\xFE', + '\xF8', '\xF8', '\xF9', '\xFA', '\xFB', '\xFC', '\xFD', '\xFE', + + '\xF7', + '\xF6', '\xF6', + '\xF6', '\xF5', '\xF4', '\xF4', + '\xF6', '\xF5', '\xF4', '\xF3', '\xF2', '\xF1', '\xF0', '\xF0' + }; + TS_ASSERT_EQUALS(str.str(), expected); + + int8_t i8; + int16_t i16; + int32_t i32; + int64_t i64; + + read_little(str, i8); + TS_ASSERT_EQUALS(i8, -0x01); + read_little(str, i16); + TS_ASSERT_EQUALS(i16, -0x0102); + read(str, i32, little); + TS_ASSERT_EQUALS(i32, -0x01020304); + read_little(str, i64); + TS_ASSERT_EQUALS(i64, -0x0102030405060708); + + read_big(str, i8); + TS_ASSERT_EQUALS(i8, -0x09); + read_big(str, i16); + TS_ASSERT_EQUALS(i16, -0x090A); + read_big(str, i32); + TS_ASSERT_EQUALS(i32, -0x090A0B0C); + read(str, i64, big); + TS_ASSERT_EQUALS(i64, -0x090A0B0C0D0E0F10); + + TS_ASSERT(str); //Check if stream has failed + } + + void test_float() + { + std::stringstream str(std::ios::in | std::ios::out | std::ios::binary); + + //C99 has hexadecimal floating point literals, C++ doesn't... + const float fconst = std::stof("-0xCDEF01p-63"); //-1.46325e-012 + const double dconst = std::stod("-0x1DEF0102030405p-375"); //-1.09484e-097 + //We will be assuming IEEE 754 here + + write_little(str, fconst); + write_little(str, dconst); + write_big (str, fconst); + write_big (str, dconst); + + std::string expected{ + '\x01', '\xEF', '\xCD', '\xAB', + '\x05', '\x04', '\x03', '\x02', '\x01', '\xEF', '\xCD', '\xAB', + + '\xAB', '\xCD', '\xEF', '\x01', + '\xAB', '\xCD', '\xEF', '\x01', '\x02', '\x03', '\x04', '\x05' + }; + TS_ASSERT_EQUALS(str.str(), expected); + + float f; + double d; + + read_little(str, f); + TS_ASSERT_EQUALS(f, fconst); + read_little(str, d); + TS_ASSERT_EQUALS(d, dconst); + + read_big(str, f); + TS_ASSERT_EQUALS(f, fconst); + read_big(str, d); + TS_ASSERT_EQUALS(d, dconst); + + TS_ASSERT(str); //Check if stream has failed + } +}; diff --git a/libraries/libnbtplusplus/test/format_test.cpp b/libraries/libnbtplusplus/test/format_test.cpp new file mode 100644 index 0000000..87f7b21 --- /dev/null +++ b/libraries/libnbtplusplus/test/format_test.cpp @@ -0,0 +1,82 @@ +/* + * libnbt++ - A library for the Minecraft Named Binary Tag format. + * Copyright (C) 2013, 2015 ljfa-ag + * + * This file is part of libnbt++. + * + * libnbt++ is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * libnbt++ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with libnbt++. If not, see . + */ +//#include "text/json_formatter.h" +//#include "io/stream_reader.h" +#include +#include +#include +#include "nbt_tags.h" + +using namespace nbt; + +int main() +{ + //TODO: Write that into a file + tag_compound comp{ + {"byte", tag_byte(-128)}, + {"short", tag_short(-32768)}, + {"int", tag_int(-2147483648)}, + {"long", tag_long(-9223372036854775808U)}, + + {"float 1", 1.618034f}, + {"float 2", 6.626070e-34f}, + {"float 3", 2.273737e+29f}, + {"float 4", -std::numeric_limits::infinity()}, + {"float 5", std::numeric_limits::quiet_NaN()}, + + {"double 1", 3.141592653589793}, + {"double 2", 1.749899444387479e-193}, + {"double 3", 2.850825855152578e+175}, + {"double 4", -std::numeric_limits::infinity()}, + {"double 5", std::numeric_limits::quiet_NaN()}, + + {"string 1", "Hello World! \u00E4\u00F6\u00FC\u00DF"}, + {"string 2", "String with\nline breaks\tand tabs"}, + + {"byte array", tag_byte_array{12, 13, 14, 15, 16}}, + {"int array", tag_int_array{0x0badc0de, -0x0dedbeef, 0x1badbabe}}, + {"long array", tag_long_array{0x0badc0de0badc0de, -0x0dedbeef0dedbeef, 0x1badbabe1badbabe}}, + + {"list (empty)", tag_list::of({})}, + {"list (float)", tag_list{2.0f, 1.0f, 0.5f, 0.25f}}, + {"list (list)", tag_list::of({ + {}, + {4, 5, 6}, + {tag_compound{{"egg", "ham"}}, tag_compound{{"foo", "bar"}}} + })}, + {"list (compound)", tag_list::of({ + {{"created-on", 42}, {"names", tag_list{"Compound", "tag", "#0"}}}, + {{"created-on", 45}, {"names", tag_list{"Compound", "tag", "#1"}}} + })}, + + {"compound (empty)", tag_compound()}, + {"compound (nested)", tag_compound{ + {"key", "value"}, + {"key with \u00E4\u00F6\u00FC", tag_byte(-1)}, + {"key with\nnewline and\ttab", tag_compound{}} + }}, + + {"null", nullptr} + }; + + std::cout << "----- default operator<<:\n"; + std::cout << comp; + std::cout << "\n-----" << std::endl; +} diff --git a/libraries/libnbtplusplus/test/nbttest.h b/libraries/libnbtplusplus/test/nbttest.h new file mode 100644 index 0000000..e3e16c5 --- /dev/null +++ b/libraries/libnbtplusplus/test/nbttest.h @@ -0,0 +1,498 @@ +/* + * libnbt++ - A library for the Minecraft Named Binary Tag format. + * Copyright (C) 2013, 2015 ljfa-ag + * + * This file is part of libnbt++. + * + * libnbt++ is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * libnbt++ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with libnbt++. If not, see . + */ +#include +#include "nbt_tags.h" +#include "nbt_visitor.h" +#include +#include +#include + +using namespace nbt; + +class nbttest : public CxxTest::TestSuite +{ +public: + void test_tag() + { + TS_ASSERT(!is_valid_type(-1)); + TS_ASSERT(!is_valid_type(0)); + TS_ASSERT(is_valid_type(0, true)); + TS_ASSERT(is_valid_type(1)); + TS_ASSERT(is_valid_type(5, false)); + TS_ASSERT(is_valid_type(7, true)); + TS_ASSERT(is_valid_type(12)); + TS_ASSERT(!is_valid_type(13)); + + //looks like TS_ASSERT_EQUALS can't handle abstract classes... + TS_ASSERT(*tag::create(tag_type::Byte) == tag_byte()); + TS_ASSERT_THROWS(tag::create(tag_type::Null), std::invalid_argument); + TS_ASSERT_THROWS(tag::create(tag_type::End), std::invalid_argument); + + tag_string tstr("foo"); + auto cl = tstr.clone(); + TS_ASSERT_EQUALS(tstr.get(), "foo"); + TS_ASSERT(tstr == *cl); + + cl = std::move(tstr).clone(); + TS_ASSERT(*cl == tag_string("foo")); + TS_ASSERT(*cl != tag_string("bar")); + + cl = std::move(*cl).move_clone(); + TS_ASSERT(*cl == tag_string("foo")); + + tstr.assign(tag_string("bar")); + TS_ASSERT_THROWS(tstr.assign(tag_int(6)), std::bad_cast); + TS_ASSERT_EQUALS(tstr.get(), "bar"); + + TS_ASSERT_EQUALS(&tstr.as(), &tstr); + TS_ASSERT_THROWS(tstr.as(), std::bad_cast); + } + + void test_get_type() + { + TS_ASSERT_EQUALS(tag_byte().get_type() , tag_type::Byte); + TS_ASSERT_EQUALS(tag_short().get_type() , tag_type::Short); + TS_ASSERT_EQUALS(tag_int().get_type() , tag_type::Int); + TS_ASSERT_EQUALS(tag_long().get_type() , tag_type::Long); + TS_ASSERT_EQUALS(tag_float().get_type() , tag_type::Float); + TS_ASSERT_EQUALS(tag_double().get_type() , tag_type::Double); + TS_ASSERT_EQUALS(tag_byte_array().get_type(), tag_type::Byte_Array); + TS_ASSERT_EQUALS(tag_string().get_type() , tag_type::String); + TS_ASSERT_EQUALS(tag_list().get_type() , tag_type::List); + TS_ASSERT_EQUALS(tag_compound().get_type() , tag_type::Compound); + TS_ASSERT_EQUALS(tag_int_array().get_type() , tag_type::Int_Array); + TS_ASSERT_EQUALS(tag_long_array().get_type(), tag_type::Long_Array); + } + + void test_tag_primitive() + { + tag_int tag(6); + TS_ASSERT_EQUALS(tag.get(), 6); + int& ref = tag; + ref = 12; + TS_ASSERT(tag == 12); + TS_ASSERT(tag != 6); + tag.set(24); + TS_ASSERT_EQUALS(ref, 24); + tag = 7; + TS_ASSERT_EQUALS(static_cast(tag), 7); + + TS_ASSERT_EQUALS(tag, tag_int(7)); + TS_ASSERT_DIFFERS(tag_float(2.5), tag_float(-2.5)); + TS_ASSERT_DIFFERS(tag_float(2.5), tag_double(2.5)); + + TS_ASSERT(tag_double() == 0.0); + + TS_ASSERT_EQUALS(tag_byte(INT8_MAX).get(), INT8_MAX); + TS_ASSERT_EQUALS(tag_byte(INT8_MIN).get(), INT8_MIN); + TS_ASSERT_EQUALS(tag_short(INT16_MAX).get(), INT16_MAX); + TS_ASSERT_EQUALS(tag_short(INT16_MIN).get(), INT16_MIN); + TS_ASSERT_EQUALS(tag_int(INT32_MAX).get(), INT32_MAX); + TS_ASSERT_EQUALS(tag_int(INT32_MIN).get(), INT32_MIN); + TS_ASSERT_EQUALS(tag_long(INT64_MAX).get(), INT64_MAX); + TS_ASSERT_EQUALS(tag_long(INT64_MIN).get(), INT64_MIN); + } + + void test_tag_string() + { + tag_string tag("foo"); + TS_ASSERT_EQUALS(tag.get(), "foo"); + std::string& ref = tag; + ref = "bar"; + TS_ASSERT_EQUALS(tag.get(), "bar"); + TS_ASSERT_DIFFERS(tag.get(), "foo"); + tag.set("baz"); + TS_ASSERT_EQUALS(ref, "baz"); + tag = "quux"; + TS_ASSERT_EQUALS("quux", static_cast(tag)); + std::string str("foo"); + tag = str; + TS_ASSERT_EQUALS(tag.get(),str); + + TS_ASSERT_EQUALS(tag_string(str).get(), "foo"); + TS_ASSERT_EQUALS(tag_string().get(), ""); + } + + void test_tag_compound() + { + tag_compound comp{ + {"foo", int16_t(12)}, + {"bar", "baz"}, + {"baz", -2.0}, + {"list", tag_list{16, 17}} + }; + + //Test assignments and conversions, and exceptions on bad conversions + TS_ASSERT_EQUALS(comp["foo"].get_type(), tag_type::Short); + TS_ASSERT_EQUALS(static_cast(comp["foo"]), 12); + TS_ASSERT_EQUALS(static_cast(comp.at("foo")), int16_t(12)); + TS_ASSERT(comp["foo"] == tag_short(12)); + TS_ASSERT_THROWS(static_cast(comp["foo"]), std::bad_cast); + TS_ASSERT_THROWS(static_cast(comp["foo"]), std::bad_cast); + + TS_ASSERT_THROWS(comp["foo"] = 32, std::bad_cast); + comp["foo"] = int8_t(32); + TS_ASSERT_EQUALS(static_cast(comp["foo"]), 32); + + TS_ASSERT_EQUALS(comp["bar"].get_type(), tag_type::String); + TS_ASSERT_EQUALS(static_cast(comp["bar"]), "baz"); + TS_ASSERT_THROWS(static_cast(comp["bar"]), std::bad_cast); + + TS_ASSERT_THROWS(comp["bar"] = -128, std::bad_cast); + comp["bar"] = "barbaz"; + TS_ASSERT_EQUALS(static_cast(comp["bar"]), "barbaz"); + + TS_ASSERT_EQUALS(comp["baz"].get_type(), tag_type::Double); + TS_ASSERT_EQUALS(static_cast(comp["baz"]), -2.0); + TS_ASSERT_THROWS(static_cast(comp["baz"]), std::bad_cast); + + //Test nested access + comp["quux"] = tag_compound{{"Hello", "World"}, {"zero", 0}}; + TS_ASSERT_EQUALS(comp.at("quux").get_type(), tag_type::Compound); + TS_ASSERT_EQUALS(static_cast(comp["quux"].at("Hello")), "World"); + TS_ASSERT_EQUALS(static_cast(comp["quux"]["Hello"]), "World"); + TS_ASSERT(comp["list"][1] == tag_int(17)); + + TS_ASSERT_THROWS(comp.at("nothing"), std::out_of_range); + + //Test equality comparisons + tag_compound comp2{ + {"foo", int16_t(32)}, + {"bar", "barbaz"}, + {"baz", -2.0}, + {"quux", tag_compound{{"Hello", "World"}, {"zero", 0}}}, + {"list", tag_list{16, 17}} + }; + TS_ASSERT(comp == comp2); + TS_ASSERT(comp != dynamic_cast(comp2["quux"].get())); + TS_ASSERT(comp != comp2["quux"]); + TS_ASSERT(dynamic_cast(comp["quux"].get()) == comp2["quux"]); + + //Test whether begin() through end() goes through all the keys and their + //values. The order of iteration is irrelevant there. + std::set keys{"bar", "baz", "foo", "list", "quux"}; + TS_ASSERT_EQUALS(comp2.size(), keys.size()); + unsigned int i = 0; + for(const std::pair& val: comp2) + { + TS_ASSERT_LESS_THAN(i, comp2.size()); + TS_ASSERT(keys.count(val.first)); + TS_ASSERT(val.second == comp2[val.first]); + ++i; + } + TS_ASSERT_EQUALS(i, comp2.size()); + + //Test erasing and has_key + TS_ASSERT_EQUALS(comp.erase("nothing"), false); + TS_ASSERT(comp.has_key("quux")); + TS_ASSERT(comp.has_key("quux", tag_type::Compound)); + TS_ASSERT(!comp.has_key("quux", tag_type::List)); + TS_ASSERT(!comp.has_key("quux", tag_type::Null)); + + TS_ASSERT_EQUALS(comp.erase("quux"), true); + TS_ASSERT(!comp.has_key("quux")); + TS_ASSERT(!comp.has_key("quux", tag_type::Compound)); + TS_ASSERT(!comp.has_key("quux", tag_type::Null)); + + comp.clear(); + TS_ASSERT(comp == tag_compound{}); + + //Test inserting values + TS_ASSERT_EQUALS(comp.put("abc", tag_double(6.0)).second, true); + TS_ASSERT_EQUALS(comp.put("abc", tag_long(-28)).second, false); + TS_ASSERT_EQUALS(comp.insert("ghi", tag_string("world")).second, true); + TS_ASSERT_EQUALS(comp.insert("abc", tag_string("hello")).second, false); + TS_ASSERT_EQUALS(comp.emplace("def", "ghi").second, true); + TS_ASSERT_EQUALS(comp.emplace("def", 4).second, false); + TS_ASSERT((comp == tag_compound{ + {"abc", tag_long(-28)}, + {"def", tag_byte(4)}, + {"ghi", tag_string("world")} + })); + } + + void test_value() + { + value val1; + value val2(make_unique(42)); + value val3(tag_int(42)); + + TS_ASSERT(!val1 && val2 && val3); + TS_ASSERT(val1 == val1); + TS_ASSERT(val1 != val2); + TS_ASSERT(val2 == val3); + TS_ASSERT(val3 == val3); + + value valstr(tag_string("foo")); + TS_ASSERT_EQUALS(static_cast(valstr), "foo"); + valstr = "bar"; + TS_ASSERT_THROWS(valstr = 5, std::bad_cast); + TS_ASSERT_EQUALS(static_cast(valstr), "bar"); + TS_ASSERT(valstr.as() == "bar"); + TS_ASSERT_EQUALS(&valstr.as(), &valstr.get()); + TS_ASSERT_THROWS(valstr.as(), std::bad_cast); + + val1 = int64_t(42); + TS_ASSERT(val2 != val1); + + TS_ASSERT_THROWS(val2 = int64_t(12), std::bad_cast); + TS_ASSERT_EQUALS(static_cast(val2), 42); + tag_int* ptr = dynamic_cast(val2.get_ptr().get()); + TS_ASSERT(*ptr == 42); + val2 = 52; + TS_ASSERT_EQUALS(static_cast(val2), 52); + TS_ASSERT(*ptr == 52); + + TS_ASSERT_THROWS(val1["foo"], std::bad_cast); + TS_ASSERT_THROWS(val1.at("foo"), std::bad_cast); + + val3 = 52; + TS_ASSERT(val2 == val3); + TS_ASSERT(val2.get_ptr() != val3.get_ptr()); + + val3 = std::move(val2); + TS_ASSERT(val3 == tag_int(52)); + TS_ASSERT(!val2); + + tag_int& tag = dynamic_cast(val3.get()); + TS_ASSERT(tag == tag_int(52)); + tag = 21; + TS_ASSERT_EQUALS(static_cast(val3), 21); + val1.set_ptr(std::move(val3.get_ptr())); + TS_ASSERT(val1.as() == 21); + + TS_ASSERT_EQUALS(val1.get_type(), tag_type::Int); + TS_ASSERT_EQUALS(val2.get_type(), tag_type::Null); + TS_ASSERT_EQUALS(val3.get_type(), tag_type::Null); + + val2 = val1; + val1 = val3; + TS_ASSERT(!val1 && val2 && !val3); + TS_ASSERT(val1.get_ptr() == nullptr); + TS_ASSERT(val2.get() == tag_int(21)); + TS_ASSERT(value(val1) == val1); + TS_ASSERT(value(val2) == val2); + val1 = val1; + val2 = val2; + TS_ASSERT(!val1); + TS_ASSERT(val1 == value_initializer(nullptr)); + TS_ASSERT(val2 == tag_int(21)); + + val3 = tag_short(2); + TS_ASSERT_THROWS(val3 = tag_string("foo"), std::bad_cast); + TS_ASSERT(val3.get() == tag_short(2)); + + val2.set_ptr(make_unique("foo")); + TS_ASSERT(val2 == tag_string("foo")); + } + + void test_tag_list() + { + tag_list list; + TS_ASSERT_EQUALS(list.el_type(), tag_type::Null); + TS_ASSERT_THROWS(list.push_back(value(nullptr)), std::invalid_argument); + + list.emplace_back("foo"); + TS_ASSERT_EQUALS(list.el_type(), tag_type::String); + list.push_back(tag_string("bar")); + TS_ASSERT_THROWS(list.push_back(tag_int(42)), std::invalid_argument); + TS_ASSERT_THROWS(list.emplace_back(), std::invalid_argument); + + TS_ASSERT((list == tag_list{"foo", "bar"})); + TS_ASSERT(list[0] == tag_string("foo")); + TS_ASSERT_EQUALS(static_cast(list.at(1)), "bar"); + + TS_ASSERT_EQUALS(list.size(), 2u); + TS_ASSERT_THROWS(list.at(2), std::out_of_range); + TS_ASSERT_THROWS(list.at(-1), std::out_of_range); + + list.set(1, value(tag_string("baz"))); + TS_ASSERT_THROWS(list.set(1, value(nullptr)), std::invalid_argument); + TS_ASSERT_THROWS(list.set(1, value(tag_int(-42))), std::invalid_argument); + TS_ASSERT_EQUALS(static_cast(list[1]), "baz"); + + TS_ASSERT_EQUALS(list.size(), 2u); + tag_string values[] = {"foo", "baz"}; + TS_ASSERT_EQUALS(list.end() - list.begin(), int(list.size())); + TS_ASSERT(std::equal(list.begin(), list.end(), values)); + + list.pop_back(); + TS_ASSERT(list == tag_list{"foo"}); + TS_ASSERT(list == tag_list::of({"foo"})); + TS_ASSERT(tag_list::of({"foo"}) == tag_list{"foo"}); + TS_ASSERT((list != tag_list{2, 3, 5, 7})); + + list.clear(); + TS_ASSERT_EQUALS(list.size(), 0u); + TS_ASSERT_EQUALS(list.el_type(), tag_type::String) + TS_ASSERT_THROWS(list.push_back(tag_short(25)), std::invalid_argument); + TS_ASSERT_THROWS(list.push_back(value(nullptr)), std::invalid_argument); + + list.reset(); + TS_ASSERT_EQUALS(list.el_type(), tag_type::Null); + list.emplace_back(17); + TS_ASSERT_EQUALS(list.el_type(), tag_type::Int); + + list.reset(tag_type::Float); + TS_ASSERT_EQUALS(list.el_type(), tag_type::Float); + list.emplace_back(17.0f); + TS_ASSERT(list == tag_list({17.0f})); + + TS_ASSERT(tag_list() != tag_list(tag_type::Int)); + TS_ASSERT(tag_list() == tag_list()); + TS_ASSERT(tag_list(tag_type::Short) != tag_list(tag_type::Int)); + TS_ASSERT(tag_list(tag_type::Short) == tag_list(tag_type::Short)); + + tag_list short_list = tag_list::of({25, 36}); + TS_ASSERT_EQUALS(short_list.el_type(), tag_type::Short); + TS_ASSERT((short_list == tag_list{int16_t(25), int16_t(36)})); + TS_ASSERT((short_list != tag_list{25, 36})); + TS_ASSERT((short_list == tag_list{value(tag_short(25)), value(tag_short(36))})); + + TS_ASSERT_THROWS((tag_list{value(tag_byte(4)), value(tag_int(5))}), std::invalid_argument); + TS_ASSERT_THROWS((tag_list{value(nullptr), value(tag_int(6))}), std::invalid_argument); + TS_ASSERT_THROWS((tag_list{value(tag_int(7)), value(tag_int(8)), value(nullptr)}), std::invalid_argument); + TS_ASSERT_EQUALS((tag_list(std::initializer_list{})).el_type(), tag_type::Null); + TS_ASSERT_EQUALS((tag_list{2, 3, 5, 7}).el_type(), tag_type::Int); + } + + void test_tag_byte_array() + { + std::vector vec{1, 2, 127, -128}; + tag_byte_array arr{1, 2, 127, -128}; + TS_ASSERT_EQUALS(arr.size(), 4u); + TS_ASSERT(arr.at(0) == 1 && arr[1] == 2 && arr[2] == 127 && arr.at(3) == -128); + TS_ASSERT_THROWS(arr.at(-1), std::out_of_range); + TS_ASSERT_THROWS(arr.at(4), std::out_of_range); + + TS_ASSERT(arr.get() == vec); + TS_ASSERT(arr == tag_byte_array(std::vector(vec))); + + arr.push_back(42); + vec.push_back(42); + + TS_ASSERT_EQUALS(arr.size(), 5u); + TS_ASSERT_EQUALS(arr.end() - arr.begin(), int(arr.size())); + TS_ASSERT(std::equal(arr.begin(), arr.end(), vec.begin())); + + arr.pop_back(); + arr.pop_back(); + TS_ASSERT_EQUALS(arr.size(), 3u); + TS_ASSERT((arr == tag_byte_array{1, 2, 127})); + TS_ASSERT((arr != tag_int_array{1, 2, 127})); + TS_ASSERT((arr != tag_long_array{1, 2, 127})); + TS_ASSERT((arr != tag_byte_array{1, 2, -1})); + + arr.clear(); + TS_ASSERT(arr == tag_byte_array()); + } + + void test_tag_int_array() + { + std::vector vec{100, 200, INT32_MAX, INT32_MIN}; + tag_int_array arr{100, 200, INT32_MAX, INT32_MIN}; + TS_ASSERT_EQUALS(arr.size(), 4u); + TS_ASSERT(arr.at(0) == 100 && arr[1] == 200 && arr[2] == INT32_MAX && arr.at(3) == INT32_MIN); + TS_ASSERT_THROWS(arr.at(-1), std::out_of_range); + TS_ASSERT_THROWS(arr.at(4), std::out_of_range); + + TS_ASSERT(arr.get() == vec); + TS_ASSERT(arr == tag_int_array(std::vector(vec))); + + arr.push_back(42); + vec.push_back(42); + + TS_ASSERT_EQUALS(arr.size(), 5u); + TS_ASSERT_EQUALS(arr.end() - arr.begin(), int(arr.size())); + TS_ASSERT(std::equal(arr.begin(), arr.end(), vec.begin())); + + arr.pop_back(); + arr.pop_back(); + TS_ASSERT_EQUALS(arr.size(), 3u); + TS_ASSERT((arr == tag_int_array{100, 200, INT32_MAX})); + TS_ASSERT((arr != tag_int_array{100, -56, -1})); + + arr.clear(); + TS_ASSERT(arr == tag_int_array()); + } + + void test_tag_long_array() + { + std::vector vec{100, 200, INT64_MAX, INT64_MIN}; + tag_long_array arr{100, 200, INT64_MAX, INT64_MIN}; + TS_ASSERT_EQUALS(arr.size(), 4u); + TS_ASSERT(arr.at(0) == 100 && arr[1] == 200 && arr[2] == INT64_MAX && arr.at(3) == INT64_MIN); + TS_ASSERT_THROWS(arr.at(-1), std::out_of_range); + TS_ASSERT_THROWS(arr.at(4), std::out_of_range); + + TS_ASSERT(arr.get() == vec); + TS_ASSERT(arr == tag_long_array(std::vector(vec))); + + arr.push_back(42); + vec.push_back(42); + + TS_ASSERT_EQUALS(arr.size(), 5u); + TS_ASSERT_EQUALS(arr.end() - arr.begin(), int(arr.size())); + TS_ASSERT(std::equal(arr.begin(), arr.end(), vec.begin())); + + arr.pop_back(); + arr.pop_back(); + TS_ASSERT_EQUALS(arr.size(), 3u); + TS_ASSERT((arr == tag_long_array{100, 200, INT64_MAX})); + TS_ASSERT((arr != tag_long_array{100, -56, -1})); + + arr.clear(); + TS_ASSERT(arr == tag_long_array()); + } + + void test_visitor() + { + struct : public nbt_visitor + { + tag* visited = nullptr; + + void visit(tag_byte& tag) { visited = &tag; } + void visit(tag_short& tag) { visited = &tag; } + void visit(tag_int& tag) { visited = &tag; } + void visit(tag_long& tag) { visited = &tag; } + void visit(tag_float& tag) { visited = &tag; } + void visit(tag_double& tag) { visited = &tag; } + void visit(tag_byte_array& tag) { visited = &tag; } + void visit(tag_string& tag) { visited = &tag; } + void visit(tag_list& tag) { visited = &tag; } + void visit(tag_compound& tag) { visited = &tag; } + void visit(tag_int_array& tag) { visited = &tag; } + void visit(tag_long_array& tag) { visited = &tag; } + } v; + + tag_byte b; b.accept(v); TS_ASSERT_EQUALS(v.visited, &b); + tag_short s; s.accept(v); TS_ASSERT_EQUALS(v.visited, &s); + tag_int i; i.accept(v); TS_ASSERT_EQUALS(v.visited, &i); + tag_long l; l.accept(v); TS_ASSERT_EQUALS(v.visited, &l); + tag_float f; f.accept(v); TS_ASSERT_EQUALS(v.visited, &f); + tag_double d; d.accept(v); TS_ASSERT_EQUALS(v.visited, &d); + tag_byte_array ba; ba.accept(v); TS_ASSERT_EQUALS(v.visited, &ba); + tag_string st; st.accept(v); TS_ASSERT_EQUALS(v.visited, &st); + tag_list ls; ls.accept(v); TS_ASSERT_EQUALS(v.visited, &ls); + tag_compound c; c.accept(v); TS_ASSERT_EQUALS(v.visited, &c); + tag_int_array ia; ia.accept(v); TS_ASSERT_EQUALS(v.visited, &ia); + tag_long_array la; la.accept(v); TS_ASSERT_EQUALS(v.visited, &la); + } +}; diff --git a/libraries/libnbtplusplus/test/read_test.h b/libraries/libnbtplusplus/test/read_test.h new file mode 100644 index 0000000..75ddbd5 --- /dev/null +++ b/libraries/libnbtplusplus/test/read_test.h @@ -0,0 +1,250 @@ +/* + * libnbt++ - A library for the Minecraft Named Binary Tag format. + * Copyright (C) 2013, 2015 ljfa-ag + * + * This file is part of libnbt++. + * + * libnbt++ is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * libnbt++ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with libnbt++. If not, see . + */ +#include +#include "io/stream_reader.h" +#ifdef NBT_HAVE_ZLIB +#include "io/izlibstream.h" +#endif +#include "nbt_tags.h" +#include +#include +#include + +using namespace nbt; + +#include "data.h" + +class read_test : public CxxTest::TestSuite +{ +public: + void test_stream_reader_big() + { + std::string input{ + 1, //tag_type::Byte + 0, //tag_type::End + 11, //tag_type::Int_Array + + 0x0a, 0x0b, 0x0c, 0x0d, //0x0a0b0c0d in Big Endian + + 0x00, 0x06, //String length in Big Endian + 'f', 'o', 'o', 'b', 'a', 'r', + + 0 //tag_type::End (invalid with allow_end = false) + }; + std::istringstream is(input); + nbt::io::stream_reader reader(is); + + TS_ASSERT_EQUALS(&reader.get_istr(), &is); + TS_ASSERT_EQUALS(reader.get_endian(), endian::big); + + TS_ASSERT_EQUALS(reader.read_type(), tag_type::Byte); + TS_ASSERT_EQUALS(reader.read_type(true), tag_type::End); + TS_ASSERT_EQUALS(reader.read_type(false), tag_type::Int_Array); + + int32_t i; + reader.read_num(i); + TS_ASSERT_EQUALS(i, 0x0a0b0c0d); + + TS_ASSERT_EQUALS(reader.read_string(), "foobar"); + + TS_ASSERT_THROWS(reader.read_type(false), io::input_error); + TS_ASSERT(!is); + is.clear(); + + //Test for invalid tag type 13 + is.str("\x0d"); + TS_ASSERT_THROWS(reader.read_type(), io::input_error); + TS_ASSERT(!is); + is.clear(); + + //Test for unexpcted EOF on numbers (input too short for int32_t) + is.str("\x03\x04"); + reader.read_num(i); + TS_ASSERT(!is); + } + + void test_stream_reader_little() + { + std::string input{ + 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, //0x0d0c0b0a09080706 in Little Endian + + 0x06, 0x00, //String length in Little Endian + 'f', 'o', 'o', 'b', 'a', 'r', + + 0x10, 0x00, //String length (intentionally too large) + 'a', 'b', 'c', 'd' //unexpected EOF + }; + std::istringstream is(input); + nbt::io::stream_reader reader(is, endian::little); + + TS_ASSERT_EQUALS(reader.get_endian(), endian::little); + + int64_t i; + reader.read_num(i); + TS_ASSERT_EQUALS(i, 0x0d0c0b0a09080706); + + TS_ASSERT_EQUALS(reader.read_string(), "foobar"); + + TS_ASSERT_THROWS(reader.read_string(), io::input_error); + TS_ASSERT(!is); + } + + //Tests if comp equals an extended variant of Notch's bigtest NBT + void verify_bigtest_structure(const tag_compound& comp) + { + TS_ASSERT_EQUALS(comp.size(), 13u); + + TS_ASSERT(comp.at("byteTest") == tag_byte(127)); + TS_ASSERT(comp.at("shortTest") == tag_short(32767)); + TS_ASSERT(comp.at("intTest") == tag_int(2147483647)); + TS_ASSERT(comp.at("longTest") == tag_long(9223372036854775807)); + TS_ASSERT(comp.at("floatTest") == tag_float(std::stof("0xff1832p-25"))); //0.4982315 + TS_ASSERT(comp.at("doubleTest") == tag_double(std::stod("0x1f8f6bbbff6a5ep-54"))); //0.493128713218231 + + //From bigtest.nbt: "the first 1000 values of (n*n*255+n*7)%100, starting with n=0 (0, 62, 34, 16, 8, ...)" + tag_byte_array byteArrayTest; + for(int n = 0; n < 1000; ++n) + byteArrayTest.push_back((n*n*255 + n*7) % 100); + TS_ASSERT(comp.at("byteArrayTest (the first 1000 values of (n*n*255+n*7)%100, starting with n=0 (0, 62, 34, 16, 8, ...))") == byteArrayTest); + + TS_ASSERT(comp.at("stringTest") == tag_string("HELLO WORLD THIS IS A TEST STRING \u00C5\u00C4\u00D6!")); + + TS_ASSERT(comp.at("listTest (compound)") == tag_list::of({ + {{"created-on", tag_long(1264099775885)}, {"name", "Compound tag #0"}}, + {{"created-on", tag_long(1264099775885)}, {"name", "Compound tag #1"}} + })); + TS_ASSERT(comp.at("listTest (long)") == tag_list::of({11, 12, 13, 14, 15})); + TS_ASSERT(comp.at("listTest (end)") == tag_list()); + + TS_ASSERT((comp.at("nested compound test") == tag_compound{ + {"egg", tag_compound{{"value", 0.5f}, {"name", "Eggbert"}}}, + {"ham", tag_compound{{"value", 0.75f}, {"name", "Hampus"}}} + })); + + TS_ASSERT(comp.at("intArrayTest") == tag_int_array( + {0x00010203, 0x04050607, 0x08090a0b, 0x0c0d0e0f})); + } + + void test_read_bigtest() + { + //Uses an extended variant of Notch's original bigtest file + std::string input(__binary_bigtest_uncompr_start, __binary_bigtest_uncompr_end); + std::istringstream file(input, std::ios::binary); + + auto pair = nbt::io::read_compound(file); + TS_ASSERT_EQUALS(pair.first, "Level"); + verify_bigtest_structure(*pair.second); + } + + void test_read_littletest() + { + //Same as bigtest, but little endian + std::string input(__binary_littletest_uncompr_start, __binary_littletest_uncompr_end); + std::istringstream file(input, std::ios::binary); + + auto pair = nbt::io::read_compound(file, endian::little); + TS_ASSERT_EQUALS(pair.first, "Level"); + TS_ASSERT_EQUALS(pair.second->get_type(), tag_type::Compound); + verify_bigtest_structure(*pair.second); + } + + void test_read_eof1() + { + std::string input(__binary_errortest_eof1_start, __binary_errortest_eof1_end); + std::istringstream file(input, std::ios::binary); + nbt::io::stream_reader reader(file); + + //EOF within a tag_double payload + TS_ASSERT(file); + TS_ASSERT_THROWS(reader.read_tag(), io::input_error); + TS_ASSERT(!file); + } + + void test_read_eof2() + { + std::string input(__binary_errortest_eof2_start, __binary_errortest_eof2_end); + std::istringstream file(input, std::ios::binary); + nbt::io::stream_reader reader(file); + + //EOF within a key in a compound + TS_ASSERT(file); + TS_ASSERT_THROWS(reader.read_tag(), io::input_error); + TS_ASSERT(!file); + } + + void test_read_errortest_noend() + { + std::string input(__binary_errortest_noend_start, __binary_errortest_noend_end); + std::istringstream file(input, std::ios::binary); + nbt::io::stream_reader reader(file); + + //Missing tag_end + TS_ASSERT(file); + TS_ASSERT_THROWS(reader.read_tag(), io::input_error); + TS_ASSERT(!file); + } + + void test_read_errortest_neg_length() + { + std::string input(__binary_errortest_neg_length_start, __binary_errortest_neg_length_end); + std::istringstream file(input, std::ios::binary); + nbt::io::stream_reader reader(file); + + //Negative list length + TS_ASSERT(file); + TS_ASSERT_THROWS(reader.read_tag(), io::input_error); + TS_ASSERT(!file); + } + + void test_read_misc() + { + std::string input(__binary_toplevel_string_start, __binary_toplevel_string_end); + std::istringstream file(input, std::ios::binary); + nbt::io::stream_reader reader(file); + + //Toplevel tag other than compound + TS_ASSERT(file); + TS_ASSERT_THROWS(reader.read_compound(), io::input_error); + TS_ASSERT(!file); + + //Rewind and try again with read_tag + file.clear(); + TS_ASSERT(file.seekg(0)); + auto pair = reader.read_tag(); + TS_ASSERT_EQUALS(pair.first, "Test (toplevel tag_string)"); + TS_ASSERT(*pair.second == tag_string( + "Even though unprovided for by NBT, the library should also handle " + "the case where the file consists of something else than tag_compound")); + } + void test_read_gzip() + { +#ifdef NBT_HAVE_ZLIB + std::string input(__binary_bigtest_nbt_start, __binary_bigtest_nbt_end); + std::istringstream file(input, std::ios::binary); + zlib::izlibstream igzs(file); + TS_ASSERT(file && igzs); + + auto pair = nbt::io::read_compound(igzs); + TS_ASSERT(igzs); + TS_ASSERT_EQUALS(pair.first, "Level"); + verify_bigtest_structure(*pair.second); +#endif + } +}; diff --git a/libraries/libnbtplusplus/test/testfiles/bigtest.nbt b/libraries/libnbtplusplus/test/testfiles/bigtest.nbt new file mode 100644 index 0000000..de1a912 Binary files /dev/null and b/libraries/libnbtplusplus/test/testfiles/bigtest.nbt differ diff --git a/libraries/libnbtplusplus/test/testfiles/bigtest.zlib b/libraries/libnbtplusplus/test/testfiles/bigtest.zlib new file mode 100644 index 0000000..36aeee5 Binary files /dev/null and b/libraries/libnbtplusplus/test/testfiles/bigtest.zlib differ diff --git a/libraries/libnbtplusplus/test/testfiles/bigtest_corrupt.nbt b/libraries/libnbtplusplus/test/testfiles/bigtest_corrupt.nbt new file mode 100644 index 0000000..71eba42 Binary files /dev/null and b/libraries/libnbtplusplus/test/testfiles/bigtest_corrupt.nbt differ diff --git a/libraries/libnbtplusplus/test/testfiles/bigtest_eof.nbt b/libraries/libnbtplusplus/test/testfiles/bigtest_eof.nbt new file mode 100644 index 0000000..eeedb9d Binary files /dev/null and b/libraries/libnbtplusplus/test/testfiles/bigtest_eof.nbt differ diff --git a/libraries/libnbtplusplus/test/testfiles/bigtest_uncompr b/libraries/libnbtplusplus/test/testfiles/bigtest_uncompr new file mode 100644 index 0000000..dc1c9c1 Binary files /dev/null and b/libraries/libnbtplusplus/test/testfiles/bigtest_uncompr differ diff --git a/libraries/libnbtplusplus/test/testfiles/errortest_eof1 b/libraries/libnbtplusplus/test/testfiles/errortest_eof1 new file mode 100644 index 0000000..abb7ac5 Binary files /dev/null and b/libraries/libnbtplusplus/test/testfiles/errortest_eof1 differ diff --git a/libraries/libnbtplusplus/test/testfiles/errortest_eof2 b/libraries/libnbtplusplus/test/testfiles/errortest_eof2 new file mode 100644 index 0000000..1e9a503 Binary files /dev/null and b/libraries/libnbtplusplus/test/testfiles/errortest_eof2 differ diff --git a/libraries/libnbtplusplus/test/testfiles/errortest_neg_length b/libraries/libnbtplusplus/test/testfiles/errortest_neg_length new file mode 100644 index 0000000..228de89 Binary files /dev/null and b/libraries/libnbtplusplus/test/testfiles/errortest_neg_length differ diff --git a/libraries/libnbtplusplus/test/testfiles/errortest_noend b/libraries/libnbtplusplus/test/testfiles/errortest_noend new file mode 100644 index 0000000..d906146 Binary files /dev/null and b/libraries/libnbtplusplus/test/testfiles/errortest_noend differ diff --git a/libraries/libnbtplusplus/test/testfiles/littletest_uncompr b/libraries/libnbtplusplus/test/testfiles/littletest_uncompr new file mode 100644 index 0000000..86619e9 Binary files /dev/null and b/libraries/libnbtplusplus/test/testfiles/littletest_uncompr differ diff --git a/libraries/libnbtplusplus/test/testfiles/toplevel_string b/libraries/libnbtplusplus/test/testfiles/toplevel_string new file mode 100644 index 0000000..996cc78 Binary files /dev/null and b/libraries/libnbtplusplus/test/testfiles/toplevel_string differ diff --git a/libraries/libnbtplusplus/test/testfiles/trailing_data.zlib b/libraries/libnbtplusplus/test/testfiles/trailing_data.zlib new file mode 100644 index 0000000..83848f3 Binary files /dev/null and b/libraries/libnbtplusplus/test/testfiles/trailing_data.zlib differ diff --git a/libraries/libnbtplusplus/test/write_test.h b/libraries/libnbtplusplus/test/write_test.h new file mode 100644 index 0000000..8b16b2a --- /dev/null +++ b/libraries/libnbtplusplus/test/write_test.h @@ -0,0 +1,272 @@ +/* + * libnbt++ - A library for the Minecraft Named Binary Tag format. + * Copyright (C) 2013, 2015 ljfa-ag + * + * This file is part of libnbt++. + * + * libnbt++ is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * libnbt++ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with libnbt++. If not, see . + */ +#include +#include "io/stream_writer.h" +#include "io/stream_reader.h" +#ifdef NBT_HAVE_ZLIB +#include "io/ozlibstream.h" +#include "io/izlibstream.h" +#endif +#include "nbt_tags.h" +#include +#include +#include + +#include "data.h" + +using namespace nbt; + +class read_test : public CxxTest::TestSuite +{ +public: + void test_stream_writer_big() + { + std::ostringstream os; + nbt::io::stream_writer writer(os); + + TS_ASSERT_EQUALS(&writer.get_ostr(), &os); + TS_ASSERT_EQUALS(writer.get_endian(), endian::big); + + writer.write_type(tag_type::End); + writer.write_type(tag_type::Long); + writer.write_type(tag_type::Int_Array); + + writer.write_num(int64_t(0x0102030405060708)); + + writer.write_string("foobar"); + + TS_ASSERT(os); + std::string expected{ + 0, //tag_type::End + 4, //tag_type::Long + 11, //tag_type::Int_Array + + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, //0x0102030405060708 in Big Endian + + 0x00, 0x06, //string length in Big Endian + 'f', 'o', 'o', 'b', 'a', 'r' + }; + TS_ASSERT_EQUALS(os.str(), expected); + + //too long for NBT + TS_ASSERT_THROWS(writer.write_string(std::string(65536, '.')), std::length_error); + TS_ASSERT(!os); + } + + void test_stream_writer_little() + { + std::ostringstream os; + nbt::io::stream_writer writer(os, endian::little); + + TS_ASSERT_EQUALS(writer.get_endian(), endian::little); + + writer.write_num(int32_t(0x0a0b0c0d)); + + writer.write_string("foobar"); + + TS_ASSERT(os); + std::string expected{ + 0x0d, 0x0c, 0x0b, 0x0a, //0x0a0b0c0d in Little Endian + + 0x06, 0x00, //string length in Little Endian + 'f', 'o', 'o', 'b', 'a', 'r' + }; + TS_ASSERT_EQUALS(os.str(), expected); + + TS_ASSERT_THROWS(writer.write_string(std::string(65536, '.')), std::length_error); + TS_ASSERT(!os); + } + + void test_write_payload_big() + { + std::ostringstream os; + nbt::io::stream_writer writer(os); + + //tag_primitive + writer.write_payload(tag_byte(127)); + writer.write_payload(tag_short(32767)); + writer.write_payload(tag_int(2147483647)); + writer.write_payload(tag_long(9223372036854775807)); + + //Same values as in endian_str_test + writer.write_payload(tag_float(std::stof("-0xCDEF01p-63"))); + writer.write_payload(tag_double(std::stod("-0x1DEF0102030405p-375"))); + + TS_ASSERT_EQUALS(os.str(), (std::string{ + '\x7F', + '\x7F', '\xFF', + '\x7F', '\xFF', '\xFF', '\xFF', + '\x7F', '\xFF', '\xFF', '\xFF', '\xFF', '\xFF', '\xFF', '\xFF', + + '\xAB', '\xCD', '\xEF', '\x01', + '\xAB', '\xCD', '\xEF', '\x01', '\x02', '\x03', '\x04', '\x05' + })); + os.str(""); //clear and reuse the stream + + //tag_string + writer.write_payload(tag_string("barbaz")); + TS_ASSERT_EQUALS(os.str(), (std::string{ + 0x00, 0x06, //string length in Big Endian + 'b', 'a', 'r', 'b', 'a', 'z' + })); + TS_ASSERT_THROWS(writer.write_payload(tag_string(std::string(65536, '.'))), std::length_error); + TS_ASSERT(!os); + os.clear(); + + //tag_byte_array + os.str(""); + writer.write_payload(tag_byte_array{0, 1, 127, -128, -127}); + TS_ASSERT_EQUALS(os.str(), (std::string{ + 0x00, 0x00, 0x00, 0x05, //length in Big Endian + 0, 1, 127, -128, -127 + })); + os.str(""); + + //tag_int_array + writer.write_payload(tag_int_array{0x01020304, 0x05060708, 0x090a0b0c}); + TS_ASSERT_EQUALS(os.str(), (std::string{ + 0x00, 0x00, 0x00, 0x03, //length in Big Endian + 0x01, 0x02, 0x03, 0x04, + 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0a, 0x0b, 0x0c + })); + os.str(""); + + //tag_list + writer.write_payload(tag_list()); //empty list with undetermined type, should be written as list of tag_end + writer.write_payload(tag_list(tag_type::Int)); //empty list of tag_int + writer.write_payload(tag_list{ //nested list + tag_list::of({0x3456, 0x789a}), + tag_list::of({0x0a, 0x0b, 0x0c, 0x0d}) + }); + TS_ASSERT_EQUALS(os.str(), (std::string{ + 0, //tag_type::End + 0x00, 0x00, 0x00, 0x00, //length + + 3, //tag_type::Int + 0x00, 0x00, 0x00, 0x00, //length + + 9, //tag_type::List + 0x00, 0x00, 0x00, 0x02, //length + //list 0 + 2, //tag_type::Short + 0x00, 0x00, 0x00, 0x02, //length + '\x34', '\x56', + '\x78', '\x9a', + //list 1 + 1, //tag_type::Byte + 0x00, 0x00, 0x00, 0x04, //length + 0x0a, + 0x0b, + 0x0c, + 0x0d + })); + os.str(""); + + //tag_compound + /* Testing if writing compounds works properly is problematic because the + order of the tags is not guaranteed. However with only two tags in a + compound we only have two possible orderings. + See below for a more thorough test that uses writing and re-reading. */ + writer.write_payload(tag_compound{}); + writer.write_payload(tag_compound{ + {"foo", "quux"}, + {"bar", tag_int(0x789abcde)} + }); + + std::string endtag{0x00}; + std::string subtag1{ + 8, //tag_type::String + 0x00, 0x03, //key length + 'f', 'o', 'o', + 0x00, 0x04, //string length + 'q', 'u', 'u', 'x' + }; + std::string subtag2{ + 3, //tag_type::Int + 0x00, 0x03, //key length + 'b', 'a', 'r', + '\x78', '\x9A', '\xBC', '\xDE' + }; + + TS_ASSERT(os.str() == endtag + subtag1 + subtag2 + endtag + || os.str() == endtag + subtag2 + subtag1 + endtag); + + //Now for write_tag: + os.str(""); + writer.write_tag("foo", tag_string("quux")); + TS_ASSERT_EQUALS(os.str(), subtag1); + TS_ASSERT(os); + + //too long key for NBT + TS_ASSERT_THROWS(writer.write_tag(std::string(65536, '.'), tag_long(-1)), std::length_error); + TS_ASSERT(!os); + } + + void test_write_bigtest() + { + /* Like already stated above, because no order is guaranteed for + tag_compound, we cannot simply test it by writing into a stream and directly + comparing the output to a reference value. + Instead, we assume that reading already works correctly and re-read the + written tag. + Smaller-grained tests are already done above. */ + std::string input(__binary_bigtest_uncompr_start, __binary_bigtest_uncompr_end); + std::istringstream file(input, std::ios::binary); + + const auto orig_pair = io::read_compound(file); + std::stringstream sstr; + + //Write into stream in Big Endian + io::write_tag(orig_pair.first, *orig_pair.second, sstr); + TS_ASSERT(sstr); + + //Read from stream in Big Endian and compare + auto written_pair = io::read_compound(sstr); + TS_ASSERT_EQUALS(orig_pair.first, written_pair.first); + TS_ASSERT(*orig_pair.second == *written_pair.second); + + sstr.str(""); //Reset and reuse stream + //Write into stream in Little Endian + io::write_tag(orig_pair.first, *orig_pair.second, sstr, endian::little); + TS_ASSERT(sstr); + + //Read from stream in Little Endian and compare + written_pair = io::read_compound(sstr, endian::little); + TS_ASSERT_EQUALS(orig_pair.first, written_pair.first); + TS_ASSERT(*orig_pair.second == *written_pair.second); + +#ifdef NBT_HAVE_ZLIB + //Now with gzip compression + sstr.str(""); + zlib::ozlibstream ogzs(sstr, -1, true); + io::write_tag(orig_pair.first, *orig_pair.second, ogzs); + ogzs.close(); + TS_ASSERT(ogzs); + TS_ASSERT(sstr); + //Read and compare + zlib::izlibstream igzs(sstr); + written_pair = io::read_compound(igzs); + TS_ASSERT(igzs); + TS_ASSERT_EQUALS(orig_pair.first, written_pair.first); + TS_ASSERT(*orig_pair.second == *written_pair.second); +#endif + } +}; diff --git a/libraries/libnbtplusplus/test/zlibstream_test.h b/libraries/libnbtplusplus/test/zlibstream_test.h new file mode 100644 index 0000000..26f86e0 --- /dev/null +++ b/libraries/libnbtplusplus/test/zlibstream_test.h @@ -0,0 +1,277 @@ +/* + * libnbt++ - A library for the Minecraft Named Binary Tag format. + * Copyright (C) 2013, 2015 ljfa-ag + * + * This file is part of libnbt++. + * + * libnbt++ is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * libnbt++ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with libnbt++. If not, see . + */ +#include +#include "io/izlibstream.h" +#include "io/ozlibstream.h" +#include +#include + +#include "data.h" + +using namespace zlib; + +class zlibstream_test : public CxxTest::TestSuite +{ +private: + std::string bigtest; + +public: + zlibstream_test() + { + std::string input(__binary_bigtest_uncompr_start, __binary_bigtest_uncompr_end); + std::istringstream bigtest_f(input, std::ios::binary); + std::stringbuf bigtest_b; + bigtest_f >> &bigtest_b; + bigtest = bigtest_b.str(); + if(!bigtest_f || bigtest.size() == 0) + throw std::runtime_error("Could not read bigtest_uncompr file"); + } + + void test_inflate_gzip() + { + std::string input(__binary_bigtest_nbt_start, __binary_bigtest_nbt_end); + std::istringstream gzip_in(input, std::ios::binary); + TS_ASSERT(gzip_in); + + std::stringbuf data; + //Small buffer so not all fits at once (the compressed file is 561 bytes) + { + izlibstream igzs(gzip_in, 256); + igzs.exceptions(std::ios::failbit | std::ios::badbit); + TS_ASSERT(igzs.good()); + + TS_ASSERT_THROWS_NOTHING(igzs >> &data); + TS_ASSERT(igzs); + TS_ASSERT(igzs.eof()); + TS_ASSERT_EQUALS(data.str(), bigtest); + } + + //Clear and reuse buffers + data.str(""); + gzip_in.clear(); + gzip_in.seekg(0); + //Now try the same with larger buffer (but not large enough for all output, uncompressed size 1561 bytes) + { + izlibstream igzs(gzip_in, 1000); + igzs.exceptions(std::ios::failbit | std::ios::badbit); + TS_ASSERT(igzs.good()); + + TS_ASSERT_THROWS_NOTHING(igzs >> &data); + TS_ASSERT(igzs); + TS_ASSERT(igzs.eof()); + TS_ASSERT_EQUALS(data.str(), bigtest); + } + + data.str(""); + gzip_in.clear(); + gzip_in.seekg(0); + //Now with large buffer + { + izlibstream igzs(gzip_in, 4000); + igzs.exceptions(std::ios::failbit | std::ios::badbit); + TS_ASSERT(igzs.good()); + + TS_ASSERT_THROWS_NOTHING(igzs >> &data); + TS_ASSERT(igzs); + TS_ASSERT(igzs.eof()); + TS_ASSERT_EQUALS(data.str(), bigtest); + } + } + + void test_inflate_zlib() + { + std::string input(__binary_bigtest_zlib_start, __binary_bigtest_zlib_end); + std::istringstream zlib_in(input, std::ios::binary); + TS_ASSERT(zlib_in); + + std::stringbuf data; + izlibstream izls(zlib_in, 256); + izls.exceptions(std::ios::failbit | std::ios::badbit); + TS_ASSERT(izls.good()); + + TS_ASSERT_THROWS_NOTHING(izls >> &data); + TS_ASSERT(izls); + TS_ASSERT(izls.eof()); + TS_ASSERT_EQUALS(data.str(), bigtest); + } + + void test_inflate_corrupt() + { + std::string input(__binary_bigtest_corrupt_nbt_start, __binary_bigtest_corrupt_nbt_end); + std::istringstream gzip_in(input, std::ios::binary); + TS_ASSERT(gzip_in); + + std::vector buf(bigtest.size()); + izlibstream igzs(gzip_in); + igzs.exceptions(std::ios::failbit | std::ios::badbit); + TS_ASSERT_THROWS(igzs.read(buf.data(), buf.size()), zlib_error); + TS_ASSERT(igzs.bad()); + } + + void test_inflate_eof() + { + std::string input(__binary_bigtest_eof_nbt_start, __binary_bigtest_eof_nbt_end); + std::istringstream gzip_in(input, std::ios::binary); + TS_ASSERT(gzip_in); + + std::vector buf(bigtest.size()); + izlibstream igzs(gzip_in); + igzs.exceptions(std::ios::failbit | std::ios::badbit); + TS_ASSERT_THROWS(igzs.read(buf.data(), buf.size()), zlib_error); + TS_ASSERT(igzs.bad()); + } + + void test_inflate_trailing() + { + //This file contains additional uncompressed data after the zlib-compressed data + std::string input(__binary_trailing_data_zlib_start, __binary_trailing_data_zlib_end); + std::istringstream file(input, std::ios::binary); + izlibstream izls(file, 32); + TS_ASSERT(file && izls); + + std::string str; + izls >> str; + TS_ASSERT(izls); + TS_ASSERT(izls.eof()); + TS_ASSERT_EQUALS(str, "foobar"); + + //Now read the uncompressed data + TS_ASSERT(file); + TS_ASSERT(!file.eof()); + file >> str; + TS_ASSERT(!file.bad()); + TS_ASSERT_EQUALS(str, "barbaz"); + } + + void test_deflate_zlib() + { + //Here we assume that inflating works and has already been tested + std::stringstream str; + std::stringbuf output; + //Small buffer + { + ozlibstream ozls(str, -1, false, 256); + ozls.exceptions(std::ios::failbit | std::ios::badbit); + TS_ASSERT_THROWS_NOTHING(ozls << bigtest); + TS_ASSERT(ozls.good()); + TS_ASSERT_THROWS_NOTHING(ozls.close()); + TS_ASSERT(ozls.good()); + } + TS_ASSERT(str.good()); + { + izlibstream izls(str); + TS_ASSERT_THROWS_NOTHING(izls >> &output); + TS_ASSERT(izls); + } + TS_ASSERT_EQUALS(output.str(), bigtest); + + str.clear(); str.str(""); + output.str(""); + //Medium sized buffer + //Write first half, then flush and write second half + { + ozlibstream ozls(str, 9, false, 512); + ozls.exceptions(std::ios::failbit | std::ios::badbit); + + std::string half1 = bigtest.substr(0, bigtest.size()/2); + std::string half2 = bigtest.substr(bigtest.size()/2); + TS_ASSERT_THROWS_NOTHING(ozls << half1 << std::flush << half2); + TS_ASSERT(ozls.good()); + TS_ASSERT_THROWS_NOTHING(ozls.close()); + TS_ASSERT(ozls.good()); + } + TS_ASSERT(str.good()); + { + izlibstream izls(str); + izls >> &output; + TS_ASSERT(izls); + } + TS_ASSERT_EQUALS(output.str(), bigtest); + + str.clear(); str.str(""); + output.str(""); + //Large buffer + { + ozlibstream ozls(str, 1, false, 4000); + ozls.exceptions(std::ios::failbit | std::ios::badbit); + TS_ASSERT_THROWS_NOTHING(ozls << bigtest); + TS_ASSERT(ozls.good()); + TS_ASSERT_THROWS_NOTHING(ozls.close()); + TS_ASSERT_THROWS_NOTHING(ozls.close()); //closing twice shouldn't be a problem + TS_ASSERT(ozls.good()); + } + TS_ASSERT(str.good()); + { + izlibstream izls(str); + izls >> &output; + TS_ASSERT(izls); + } + TS_ASSERT_EQUALS(output.str(), bigtest); + } + + void test_deflate_gzip() + { + std::stringstream str; + std::stringbuf output; + { + ozlibstream ozls(str, -1, true); + ozls.exceptions(std::ios::failbit | std::ios::badbit); + TS_ASSERT_THROWS_NOTHING(ozls << bigtest); + TS_ASSERT(ozls.good()); + TS_ASSERT_THROWS_NOTHING(ozls.close()); + TS_ASSERT(ozls.good()); + } + TS_ASSERT(str.good()); + { + izlibstream izls(str); + izls >> &output; + TS_ASSERT(izls); + } + TS_ASSERT_EQUALS(output.str(), bigtest); + } + + void test_deflate_closed() + { + std::stringstream str; + { + ozlibstream ozls(str); + ozls.exceptions(std::ios::failbit | std::ios::badbit); + TS_ASSERT_THROWS_NOTHING(ozls << bigtest); + TS_ASSERT_THROWS_NOTHING(ozls.close()); + TS_ASSERT_THROWS_NOTHING(ozls << "foo"); + TS_ASSERT_THROWS_ANYTHING(ozls.close()); + TS_ASSERT(ozls.bad()); + TS_ASSERT(!str); + } + str.clear(); + str.seekp(0); + { + ozlibstream ozls(str); + //this time without exceptions + TS_ASSERT_THROWS_NOTHING(ozls << bigtest); + TS_ASSERT_THROWS_NOTHING(ozls.close()); + TS_ASSERT_THROWS_NOTHING(ozls << "foo" << std::flush); + TS_ASSERT(ozls.bad()); + TS_ASSERT_THROWS_NOTHING(ozls.close()); + TS_ASSERT(ozls.bad()); + TS_ASSERT(!str); + } + } +}; diff --git a/libraries/murmur2/CMakeLists.txt b/libraries/murmur2/CMakeLists.txt new file mode 100644 index 0000000..be989ee --- /dev/null +++ b/libraries/murmur2/CMakeLists.txt @@ -0,0 +1,12 @@ +cmake_minimum_required(VERSION 3.15) +project(murmur2) + +set(MURMUR_SOURCES + src/MurmurHash2.h + src/MurmurHash2.cpp +) + +add_library(Launcher_murmur2 STATIC ${MURMUR_SOURCES}) +target_include_directories(Launcher_murmur2 PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} "src" ) + +generate_export_header(Launcher_murmur2) diff --git a/libraries/murmur2/src/MurmurHash2.cpp b/libraries/murmur2/src/MurmurHash2.cpp new file mode 100644 index 0000000..99befd1 --- /dev/null +++ b/libraries/murmur2/src/MurmurHash2.cpp @@ -0,0 +1,108 @@ +//----------------------------------------------------------------------------- +// MurmurHash2 was written by Austin Appleby, and is placed in the public +// domain. The author hereby disclaims copyright to this source code. +// +// This was modified as to possibilitate it's usage incrementally. +// Those modifications are also placed in the public domain, and the author of +// such modifications hereby disclaims copyright to this source code. + +#include "MurmurHash2.h" + +namespace Murmur2 { + +// 'm' and 'r' are mixing constants generated offline. +// They're not really 'magic', they just happen to work well. +const uint32_t m = 0x5bd1e995; +const int r = 24; + +uint32_t hash(Reader* file_stream, std::size_t buffer_size, std::function filter_out) +{ + auto* buffer = new char[buffer_size]; + char data[4]; + + int read = 0; + uint32_t size = 0; + + // We need the size without the filtered out characters before actually calculating the hash, + // to setup the initial value for the hash. + do { + read = file_stream->read(buffer, buffer_size); + for (int i = 0; i < read; i++) { + if (!filter_out(buffer[i])) + size += 1; + } + } while (!file_stream->eof()); + + file_stream->goToBeginning(); + + int index = 0; + + // This forces a seed of 1. + IncrementalHashInfo info{ (uint32_t)1 ^ size, (uint32_t)size }; + do { + read = file_stream->read(buffer, buffer_size); + for (int i = 0; i < read; i++) { + char c = buffer[i]; + + if (filter_out(c)) + continue; + + data[index] = c; + index = (index + 1) % 4; + + // Mix 4 bytes at a time into the hash + if (index == 0) + FourBytes_MurmurHash2(reinterpret_cast(&data), info); + } + } while (!file_stream->eof()); + + // Do one last bit shuffle in the hash + FourBytes_MurmurHash2(reinterpret_cast(&data), info); + + delete[] buffer; + + return info.h; +} + +void FourBytes_MurmurHash2(const unsigned char* data, IncrementalHashInfo& prev) +{ + if (prev.len >= 4) { + // Not the final mix + uint32_t k = *reinterpret_cast(data); + + k *= m; + k ^= k >> r; + k *= m; + + prev.h *= m; + prev.h ^= k; + + prev.len -= 4; + } else { + // The final mix + + // Handle the last few bytes of the input array + switch (prev.len) { + case 3: + prev.h ^= data[2] << 16; + /* fall through */ + case 2: + prev.h ^= data[1] << 8; + /* fall through */ + case 1: + prev.h ^= data[0]; + prev.h *= m; + }; + + // Do a few final mixes of the hash to ensure the last few + // bytes are well-incorporated. + + prev.h ^= prev.h >> 13; + prev.h *= m; + prev.h ^= prev.h >> 15; + + prev.len = 0; + } +} + +} // namespace Murmur2 \ No newline at end of file diff --git a/libraries/murmur2/src/MurmurHash2.h b/libraries/murmur2/src/MurmurHash2.h new file mode 100644 index 0000000..e6c196f --- /dev/null +++ b/libraries/murmur2/src/MurmurHash2.h @@ -0,0 +1,35 @@ +//----------------------------------------------------------------------------- +// The original MurmurHash2 was written by Austin Appleby, and is placed in the +// public domain. The author hereby disclaims copyright to this source code. +// +// This was modified as to possibilitate it's usage incrementally. +// Those modifications are also placed in the public domain, and the author of +// such modifications hereby disclaims copyright to this source code. + +#pragma once + +#include +#include + +namespace Murmur2 { + +#define KiB 1024 +#define MiB 1024 * KiB + +class Reader { + public: + virtual ~Reader() = default; + virtual int read(char* s, int n) = 0; + virtual bool eof() = 0; + virtual void goToBeginning() = 0; +}; + +uint32_t hash(Reader* file_stream, std::size_t buffer_size = 4 * MiB, std::function filter_out = [](char) { return false; }); + +struct IncrementalHashInfo { + uint32_t h; + uint32_t len; +}; + +void FourBytes_MurmurHash2(const unsigned char* data, IncrementalHashInfo& prev); +} // namespace Murmur2 diff --git a/libraries/qdcss/CMakeLists.txt b/libraries/qdcss/CMakeLists.txt new file mode 100644 index 0000000..d1c1078 --- /dev/null +++ b/libraries/qdcss/CMakeLists.txt @@ -0,0 +1,15 @@ +cmake_minimum_required(VERSION 3.15) +project(qdcss) + +if(Launcher_QT_VERSION_MAJOR EQUAL 6) + find_package(Qt6 COMPONENTS Core REQUIRED) +endif() + +set(QDCSS_SOURCES + include/qdcss.h + src/qdcss.cpp +) + +add_library(qdcss STATIC ${QDCSS_SOURCES}) +target_include_directories(qdcss PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include) +target_link_libraries(qdcss Qt${QT_VERSION_MAJOR}::Core ${qdcss_LIBS}) diff --git a/libraries/qdcss/LICENSE b/libraries/qdcss/LICENSE new file mode 100644 index 0000000..0e54702 --- /dev/null +++ b/libraries/qdcss/LICENSE @@ -0,0 +1,72 @@ +GNU LESSER GENERAL PUBLIC LICENSE +Version 3, 29 June 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. + +Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. + +This version of the GNU Lesser General Public License incorporates the terms and conditions of version 3 of the GNU General Public License, supplemented by the additional permissions listed below. + +0. Additional Definitions. + +As used herein, "this License" refers to version 3 of the GNU Lesser General Public License, and the "GNU GPL" refers to version 3 of the GNU General Public License. + +"The Library" refers to a covered work governed by this License, other than an Application or a Combined Work as defined below. + +An "Application" is any work that makes use of an interface provided by the Library, but which is not otherwise based on the Library. Defining a subclass of a class defined by the Library is deemed a mode of using an interface provided by the Library. + +A "Combined Work" is a work produced by combining or linking an Application with the Library. The particular version of the Library with which the Combined Work was made is also called the "Linked Version". + +The "Minimal Corresponding Source" for a Combined Work means the Corresponding Source for the Combined Work, excluding any source code for portions of the Combined Work that, considered in isolation, are based on the Application, and not on the Linked Version. + +The "Corresponding Application Code" for a Combined Work means the object code and/or source code for the Application, including any data and utility programs needed for reproducing the Combined Work from the Application, but excluding the System Libraries of the Combined Work. + +1. Exception to Section 3 of the GNU GPL. +You may convey a covered work under sections 3 and 4 of this License without being bound by section 3 of the GNU GPL. + +2. Conveying Modified Versions. +If you modify a copy of the Library, and, in your modifications, a facility refers to a function or data to be supplied by an Application that uses the facility (other than as an argument passed when the facility is invoked), then you may convey a copy of the modified version: + + a) under this License, provided that you make a good faith effort to ensure that, in the event an Application does not supply the function or data, the facility still operates, and performs whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of this License applicable to that copy. + +3. Object Code Incorporating Material from Library Header Files. +The object code form of an Application may incorporate material from a header file that is part of the Library. You may convey such object code under terms of your choice, provided that, if the incorporated material is not limited to numerical parameters, data structure layouts and accessors, or small macros, inline functions and templates (ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the Library is used in it and that the Library and its use are covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license document. + +4. Combined Works. +You may convey a Combined Work under terms of your choice that, taken together, effectively do not restrict modification of the portions of the Library contained in the Combined Work and reverse engineering for debugging such modifications, if you also do each of the following: + + a) Give prominent notice with each copy of the Combined Work that the Library is used in it and that the Library and its use are covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license document. + + c) For a Combined Work that displays copyright notices during execution, include the copyright notice for the Library among these notices, as well as a reference directing the user to the copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this License, and the Corresponding Application Code in a form suitable for, and under terms that permit, the user to recombine or relink the Application with a modified version of the Linked Version to produce a modified Combined Work, in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (a) uses at run time a copy of the Library already present on the user's computer system, and (b) will operate properly with a modified version of the Library that is interface-compatible with the Linked Version. + + e) Provide Installation Information, but only if you would otherwise be required to provide such information under section 6 of the GNU GPL, and only to the extent that such information is necessary to install and execute a modified version of the Combined Work produced by recombining or relinking the Application with a modified version of the Linked Version. (If you use option 4d0, the Installation Information must accompany the Minimal Corresponding Source and Corresponding Application Code. If you use option 4d1, you must provide the Installation Information in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source.) + +5. Combined Libraries. +You may place library facilities that are a work based on the Library side by side in a single library together with other library facilities that are not Applications and are not covered by this License, and convey such a combined library under terms of your choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities, conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. + +6. Revised Versions of the GNU Lesser General Public License. +The Free Software Foundation may publish revised and/or new versions of the GNU Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library as you received it specifies that a certain numbered version of the GNU Lesser General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that published version or of any later version published by the Free Software Foundation. If the Library as you received it does not specify a version number of the GNU Lesser General Public License, you may choose any version of the GNU Lesser General Public License ever published by the Free Software Foundation. + +If the Library as you received it specifies that a proxy can decide whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is permanent authorization for you to choose that version for the Library. + diff --git a/libraries/qdcss/include/qdcss.h b/libraries/qdcss/include/qdcss.h new file mode 100644 index 0000000..a7fac34 --- /dev/null +++ b/libraries/qdcss/include/qdcss.h @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2023 kumquat-ir 66188216+kumquat-ir@users.noreply.github.com +// +// SPDX-License-Identifier: LGPL-3.0-only + +#ifndef QDCSS_H +#define QDCSS_H + +#include +#include +#include +#include + +class QDCSS { + // these are all we need to parse a couple string values out of a css string + // lots more in the original code, yet to be ported + // https://github.com/unascribed/NilLoader/blob/trunk/src/main/java/nilloader/api/lib/qdcss/QDCSS.java + public: + QDCSS(QString); + std::optional* get(QString); + + private: + QMap m_data; +}; + +#endif // QDCSS_H diff --git a/libraries/qdcss/src/qdcss.cpp b/libraries/qdcss/src/qdcss.cpp new file mode 100644 index 0000000..bf0ef63 --- /dev/null +++ b/libraries/qdcss/src/qdcss.cpp @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2023 kumquat-ir 66188216+kumquat-ir@users.noreply.github.com +// +// SPDX-License-Identifier: LGPL-3.0-only + +#include "qdcss.h" + +#include +#include +#include + +static const QRegularExpression s_rulesetRe(R"([#.]?(@?\w+?)\s*\{(.*?)\})", QRegularExpression::DotMatchesEverythingOption); +static const QRegularExpression s_ruleRe(R"((\S+?)\s*:\s*(?:\"(.*?)(?append(value); + } + } +} + +std::optional* QDCSS::get(QString key) +{ + auto found = m_data.find(key); + + if (found == m_data.end() || found->empty()) { + return new std::optional; + } + + return new std::optional(found->back()); +} diff --git a/libraries/rainbow/CMakeLists.txt b/libraries/rainbow/CMakeLists.txt new file mode 100644 index 0000000..c971889 --- /dev/null +++ b/libraries/rainbow/CMakeLists.txt @@ -0,0 +1,15 @@ +cmake_minimum_required(VERSION 3.15) +project(rainbow) + +if(Launcher_QT_VERSION_MAJOR EQUAL 6) + find_package(Qt6 COMPONENTS Core Gui REQUIRED) +endif() + +set(RAINBOW_SOURCES +src/rainbow.cpp +) + +add_library(Launcher_rainbow STATIC ${RAINBOW_SOURCES}) +target_include_directories(Launcher_rainbow PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/include") + +target_link_libraries(Launcher_rainbow Qt${QT_VERSION_MAJOR}::Core Qt${QT_VERSION_MAJOR}::Gui) diff --git a/libraries/rainbow/COPYING.LIB b/libraries/rainbow/COPYING.LIB new file mode 100644 index 0000000..e69de29 diff --git a/libraries/rainbow/include/rainbow.h b/libraries/rainbow/include/rainbow.h new file mode 100644 index 0000000..a75e53e --- /dev/null +++ b/libraries/rainbow/include/rainbow.h @@ -0,0 +1,153 @@ +/* This was part of the KDE project - see KGuiAddons + * Copyright (C) 2007 Matthew Woehlke + * Copyright (C) 2007 Olaf Schmidt + * Copyright (C) 2007 Thomas Zander + * Copyright (C) 2007 Zack Rusin + * Copyright (C) 2015 Petr Mrazek + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Library General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Library General Public License for more details. + * + * You should have received a copy of the GNU Library General Public License + * along with this library; see the file COPYING.LIB. If not, write to + * the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301, USA. + */ + +#pragma once + +#include +class QColor; + +/** + * A set of methods used to work with colors. + */ +namespace Rainbow { +/** + * Calculate the luma of a color. Luma is weighted sum of gamma-adjusted + * R'G'B' components of a color. The result is similar to qGray. The range + * is from 0.0 (black) to 1.0 (white). + * + * Rainbow::darken(), Rainbow::lighten() and Rainbow::shade() + * operate on the luma of a color. + * + * @see http://en.wikipedia.org/wiki/Luma_(video) + */ +qreal luma(const QColor&); + +/** + * Calculate hue, chroma and luma of a color in one call. + * @since 5.0 + */ +void getHcy(const QColor&, qreal* hue, qreal* chroma, qreal* luma, qreal* alpha = 0); + +/** + * Calculate the contrast ratio between two colors, according to the + * W3C/WCAG2.0 algorithm, (Lmax + 0.05)/(Lmin + 0.05), where Lmax and Lmin + * are the luma values of the lighter color and the darker color, + * respectively. + * + * A contrast ration of 5:1 (result == 5.0) is the minimum for "normal" + * text to be considered readable (large text can go as low as 3:1). The + * ratio ranges from 1:1 (result == 1.0) to 21:1 (result == 21.0). + * + * @see Rainbow::luma + */ +qreal contrastRatio(const QColor&, const QColor&); + +/** + * Adjust the luma of a color by changing its distance from white. + * + * @li amount == 1.0 gives white + * @li amount == 0.5 results in a color whose luma is halfway between 1.0 + * and that of the original color + * @li amount == 0.0 gives the original color + * @li amount == -1.0 gives a color that is 'twice as far from white' as + * the original color, that is luma(result) == 1.0 - 2*(1.0 - luma(color)) + * + * @param amount factor by which to adjust the luma component of the color + * @param chromaInverseGain (optional) factor by which to adjust the chroma + * component of the color; 1.0 means no change, 0.0 maximizes chroma + * @see Rainbow::shade + */ +QColor lighten(const QColor&, qreal amount = 0.5, qreal chromaInverseGain = 1.0); + +/** + * Adjust the luma of a color by changing its distance from black. + * + * @li amount == 1.0 gives black + * @li amount == 0.5 results in a color whose luma is halfway between 0.0 + * and that of the original color + * @li amount == 0.0 gives the original color + * @li amount == -1.0 gives a color that is 'twice as far from black' as + * the original color, that is luma(result) == 2*luma(color) + * + * @param amount factor by which to adjust the luma component of the color + * @param chromaGain (optional) factor by which to adjust the chroma + * component of the color; 1.0 means no change, 0.0 minimizes chroma + * @see Rainbow::shade + */ +QColor darken(const QColor&, qreal amount = 0.5, qreal chromaGain = 1.0); + +/** + * Adjust the luma and chroma components of a color. The amount is added + * to the corresponding component. + * + * @param lumaAmount amount by which to adjust the luma component of the + * color; 0.0 results in no change, -1.0 turns anything black, 1.0 turns + * anything white + * @param chromaAmount (optional) amount by which to adjust the chroma + * component of the color; 0.0 results in no change, -1.0 minimizes chroma, + * 1.0 maximizes chroma + * @see Rainbow::luma + */ +QColor shade(const QColor&, qreal lumaAmount, qreal chromaAmount = 0.0); + +/** + * Create a new color by tinting one color with another. This function is + * meant for creating additional colors withings the same class (background, + * foreground) from colors in a different class. Therefore when @p amount + * is low, the luma of @p base is mostly preserved, while the hue and + * chroma of @p color is mostly inherited. + * + * @param base color to be tinted + * @param color color with which to tint + * @param amount how strongly to tint the base; 0.0 gives @p base, + * 1.0 gives @p color + */ +QColor tint(const QColor& base, const QColor& color, qreal amount = 0.3); + +/** + * Blend two colors into a new color by linear combination. + * @code + QColor lighter = Rainbow::mix(myColor, Qt::white) + * @endcode + * @param c1 first color. + * @param c2 second color. + * @param bias weight to be used for the mix. @p bias <= 0 gives @p c1, + * @p bias >= 1 gives @p c2. @p bias == 0.5 gives a 50% blend of @p c1 + * and @p c2. + */ +QColor mix(const QColor& c1, const QColor& c2, qreal bias = 0.5); + +/** + * Blend two colors into a new color by painting the second color over the + * first using the specified composition mode. + * @code + QColor white(Qt::white); + white.setAlphaF(0.5); + QColor lighter = Rainbow::overlayColors(myColor, white); + @endcode + * @param base the base color (alpha channel is ignored). + * @param paint the color to be overlayed onto the base color. + * @param comp the CompositionMode used to do the blending. + */ +QColor overlayColors(const QColor& base, const QColor& paint, QPainter::CompositionMode comp = QPainter::CompositionMode_SourceOver); +} // namespace Rainbow diff --git a/libraries/rainbow/src/rainbow.cpp b/libraries/rainbow/src/rainbow.cpp new file mode 100644 index 0000000..5ef1965 --- /dev/null +++ b/libraries/rainbow/src/rainbow.cpp @@ -0,0 +1,300 @@ +/* This was part of the KDE project - see KGuiAddons + * Copyright (C) 2007 Matthew Woehlke + * Copyright (C) 2007 Olaf Schmidt + * Copyright (C) 2007 Thomas Zander + * Copyright (C) 2007 Zack Rusin + * Copyright (C) 2015 Petr Mrazek + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Library General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Library General Public License for more details. + * + * You should have received a copy of the GNU Library General Public License + * along with this library; see the file COPYING.LIB. If not, write to + * the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301, USA. + */ + +#include "../include/rainbow.h" + +#include +#include +#include // qIsNaN + +#include + +// BEGIN internal helper functions + +static inline qreal wrap(qreal a, qreal d = 1.0) +{ + qreal r = fmod(a, d); + return (r < 0.0 ? d + r : (r > 0.0 ? r : 0.0)); +} + +// normalize: like qBound(a, 0.0, 1.0) but without needing the args and with +// "safer" behavior on NaN (isnan(a) -> return 0.0) +static inline qreal normalize(qreal a) +{ + return (a < 1.0 ? (a > 0.0 ? a : 0.0) : 1.0); +} + +/////////////////////////////////////////////////////////////////////////////// +// HCY color space + +#define HCY_REC 709 // use 709 for now +#if HCY_REC == 601 +static const qreal yc[3] = { 0.299, 0.587, 0.114 }; +#elif HCY_REC == 709 +static const qreal yc[3] = { 0.2126, 0.7152, 0.0722 }; +#else // use Qt values +static const qreal yc[3] = { 0.34375, 0.5, 0.15625 }; +#endif + +class KHCY { + public: + explicit KHCY(const QColor& color) + { + qreal r = gamma(color.redF()); + qreal g = gamma(color.greenF()); + qreal b = gamma(color.blueF()); + a = color.alphaF(); + + // luma component + y = lumag(r, g, b); + + // hue component + qreal p = qMax(qMax(r, g), b); + qreal n = qMin(qMin(r, g), b); + qreal d = 6.0 * (p - n); + if (n == p) { + h = 0.0; + } else if (r == p) { + h = ((g - b) / d); + } else if (g == p) { + h = ((b - r) / d) + (1.0 / 3.0); + } else { + h = ((r - g) / d) + (2.0 / 3.0); + } + + // chroma component + if (r == g && g == b) { + c = 0.0; + } else { + c = qMax((y - n) / y, (p - y) / (1 - y)); + } + } + explicit KHCY(qreal h_, qreal c_, qreal y_, qreal a_ = 1.0) + { + h = h_; + c = c_; + y = y_; + a = a_; + } + + QColor qColor() const + { + // start with sane component values + qreal _h = wrap(h); + qreal _c = normalize(c); + qreal _y = normalize(y); + + // calculate some needed variables + qreal _hs = _h * 6.0, th, tm; + if (_hs < 1.0) { + th = _hs; + tm = yc[0] + yc[1] * th; + } else if (_hs < 2.0) { + th = 2.0 - _hs; + tm = yc[1] + yc[0] * th; + } else if (_hs < 3.0) { + th = _hs - 2.0; + tm = yc[1] + yc[2] * th; + } else if (_hs < 4.0) { + th = 4.0 - _hs; + tm = yc[2] + yc[1] * th; + } else if (_hs < 5.0) { + th = _hs - 4.0; + tm = yc[2] + yc[0] * th; + } else { + th = 6.0 - _hs; + tm = yc[0] + yc[2] * th; + } + + // calculate RGB channels in sorted order + qreal tn, to, tp; + if (tm >= _y) { + tp = _y + _y * _c * (1.0 - tm) / tm; + to = _y + _y * _c * (th - tm) / tm; + tn = _y - (_y * _c); + } else { + tp = _y + (1.0 - _y) * _c; + to = _y + (1.0 - _y) * _c * (th - tm) / (1.0 - tm); + tn = _y - (1.0 - _y) * _c * tm / (1.0 - tm); + } + + // return RGB channels in appropriate order + if (_hs < 1.0) { + return QColor::fromRgbF(igamma(tp), igamma(to), igamma(tn), a); + } else if (_hs < 2.0) { + return QColor::fromRgbF(igamma(to), igamma(tp), igamma(tn), a); + } else if (_hs < 3.0) { + return QColor::fromRgbF(igamma(tn), igamma(tp), igamma(to), a); + } else if (_hs < 4.0) { + return QColor::fromRgbF(igamma(tn), igamma(to), igamma(tp), a); + } else if (_hs < 5.0) { + return QColor::fromRgbF(igamma(to), igamma(tn), igamma(tp), a); + } else { + return QColor::fromRgbF(igamma(tp), igamma(tn), igamma(to), a); + } + } + + qreal h, c, y, a; + static qreal luma(const QColor& color) { return lumag(gamma(color.redF()), gamma(color.greenF()), gamma(color.blueF())); } + + private: + static qreal gamma(qreal n) { return pow(normalize(n), 2.2); } + static qreal igamma(qreal n) { return pow(normalize(n), 1.0 / 2.2); } + static qreal lumag(qreal r, qreal g, qreal b) { return r * yc[0] + g * yc[1] + b * yc[2]; } +}; + +static inline qreal mixQreal(qreal a, qreal b, qreal bias) +{ + return a + (b - a) * bias; +} +// END internal helper functions + +qreal Rainbow::luma(const QColor& color) +{ + return KHCY::luma(color); +} + +void Rainbow::getHcy(const QColor& color, qreal* h, qreal* c, qreal* y, qreal* a) +{ + if (!c || !h || !y) { + return; + } + KHCY khcy(color); + *c = khcy.c; + *h = khcy.h; + *y = khcy.y; + if (a) { + *a = khcy.a; + } +} + +static qreal contrastRatioForLuma(qreal y1, qreal y2) +{ + if (y1 > y2) { + return (y1 + 0.05) / (y2 + 0.05); + } else { + return (y2 + 0.05) / (y1 + 0.05); + } +} + +qreal Rainbow::contrastRatio(const QColor& c1, const QColor& c2) +{ + return contrastRatioForLuma(luma(c1), luma(c2)); +} + +QColor Rainbow::lighten(const QColor& color, qreal ky, qreal kc) +{ + KHCY c(color); + c.y = 1.0 - normalize((1.0 - c.y) * (1.0 - ky)); + c.c = 1.0 - normalize((1.0 - c.c) * kc); + return c.qColor(); +} + +QColor Rainbow::darken(const QColor& color, qreal ky, qreal kc) +{ + KHCY c(color); + c.y = normalize(c.y * (1.0 - ky)); + c.c = normalize(c.c * kc); + return c.qColor(); +} + +QColor Rainbow::shade(const QColor& color, qreal ky, qreal kc) +{ + KHCY c(color); + c.y = normalize(c.y + ky); + c.c = normalize(c.c + kc); + return c.qColor(); +} + +static QColor tintHelper(const QColor& base, qreal baseLuma, const QColor& color, qreal amount) +{ + KHCY result(Rainbow::mix(base, color, pow(amount, 0.3))); + result.y = mixQreal(baseLuma, result.y, amount); + + return result.qColor(); +} + +QColor Rainbow::tint(const QColor& base, const QColor& color, qreal amount) +{ + if (amount <= 0.0) { + return base; + } + if (amount >= 1.0) { + return color; + } + if (qIsNaN(amount)) { + return base; + } + + qreal baseLuma = luma(base); // cache value because luma call is expensive + double ri = contrastRatioForLuma(baseLuma, luma(color)); + double rg = 1.0 + ((ri + 1.0) * amount * amount * amount); + double u = 1.0, l = 0.0; + QColor result; + for (int i = 12; i; --i) { + double a = 0.5 * (l + u); + result = tintHelper(base, baseLuma, color, a); + double ra = contrastRatioForLuma(baseLuma, luma(result)); + if (ra > rg) { + u = a; + } else { + l = a; + } + } + return result; +} + +QColor Rainbow::mix(const QColor& c1, const QColor& c2, qreal bias) +{ + if (bias <= 0.0) { + return c1; + } + if (bias >= 1.0) { + return c2; + } + if (qIsNaN(bias)) { + return c1; + } + + qreal r = mixQreal(c1.redF(), c2.redF(), bias); + qreal g = mixQreal(c1.greenF(), c2.greenF(), bias); + qreal b = mixQreal(c1.blueF(), c2.blueF(), bias); + qreal a = mixQreal(c1.alphaF(), c2.alphaF(), bias); + + return QColor::fromRgbF(r, g, b, a); +} + +QColor Rainbow::overlayColors(const QColor& base, const QColor& paint, QPainter::CompositionMode comp) +{ + // This isn't the fastest way, but should be "fast enough". + // It's also the only safe way to use QPainter::CompositionMode + QImage img(1, 1, QImage::Format_ARGB32_Premultiplied); + QPainter p(&img); + QColor start = base; + start.setAlpha(255); // opaque + p.fillRect(0, 0, 1, 1, start); + p.setCompositionMode(comp); + p.fillRect(0, 0, 1, 1, paint); + p.end(); + return img.pixel(0, 0); +} diff --git a/program_info/AdhocSignedApp.entitlements b/program_info/AdhocSignedApp.entitlements new file mode 100644 index 0000000..032308a --- /dev/null +++ b/program_info/AdhocSignedApp.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.cs.disable-library-validation + + com.apple.security.device.audio-input + + com.apple.security.device.camera + + + diff --git a/program_info/App.entitlements b/program_info/App.entitlements new file mode 100644 index 0000000..73bf832 --- /dev/null +++ b/program_info/App.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.device.audio-input + + com.apple.security.device.camera + + + diff --git a/program_info/CMakeLists.txt b/program_info/CMakeLists.txt new file mode 100644 index 0000000..a33a6ed --- /dev/null +++ b/program_info/CMakeLists.txt @@ -0,0 +1,94 @@ +if(UNIX) + find_package(PkgConfig) + if(PkgConfig_FOUND) + pkg_search_module(SCDOC scdoc) + if(SCDOC_FOUND) + pkg_get_variable(SCDOC_SCDOC scdoc scdoc) + endif() + endif() +endif() + +set(Launcher_CommonName "PrismLauncher") +set(Launcher_DisplayName "racked.ru | launcher") +set(Launcher_AppID "org.prismlauncher.PrismLauncher") +set(Launcher_Domain "prismlauncher.org") +set(Launcher_Git "https://github.com/s8n-ru/minecraft-launcher") + +set(Launcher_Name "${Launcher_CommonName}" PARENT_SCOPE) +set(Launcher_DisplayName "${Launcher_DisplayName}" PARENT_SCOPE) +set(Launcher_ENVName "PRISMLAUNCHER" PARENT_SCOPE) +set(Launcher_Domain "${Launcher_Domain}" PARENT_SCOPE) +set(Launcher_Git "${Launcher_Git}" PARENT_SCOPE) + +set(Launcher_SVGFileName "${Launcher_AppID}.svg") +set(Launcher_Copyright "© 2022-2026 Prism Launcher Contributors\\n© 2021-2022 PolyMC Contributors\\n© 2012-2021 MultiMC Contributors") +set(Launcher_Copyright_Mac "© 2022-2026 Prism Launcher Contributors, © 2021-2022 PolyMC Contributors and © 2012-2021 MultiMC Contributors" PARENT_SCOPE) +set(Launcher_Copyright "${Launcher_Copyright}" PARENT_SCOPE) +set(Launcher_UserAgent "${Launcher_CommonName}/${Launcher_VERSION_NAME}" PARENT_SCOPE) +set(Launcher_ConfigFile "${Launcher_APP_BINARY_NAME}.cfg" PARENT_SCOPE) +set(Launcher_AppID "${Launcher_AppID}" PARENT_SCOPE) +set(Launcher_SVGFileName "${Launcher_SVGFileName}" PARENT_SCOPE) + +set(Launcher_Desktop "program_info/${Launcher_AppID}.desktop" PARENT_SCOPE) +set(Launcher_MIMEInfo "program_info/${Launcher_AppID}.xml" PARENT_SCOPE) +set(Launcher_MetaInfo "program_info/${Launcher_AppID}.metainfo.xml" PARENT_SCOPE) +set(Launcher_PNG_256 "program_info/${Launcher_AppID}_256.png" PARENT_SCOPE) +set(Launcher_SVG "program_info/${Launcher_SVGFileName}" PARENT_SCOPE) +set(Launcher_Branding_ICNS "program_info/${Launcher_APP_BINARY_NAME}.icns" PARENT_SCOPE) +set(Launcher_Branding_MAC_ICON "program_info/${Launcher_CommonName}.icon" PARENT_SCOPE) +set(Launcher_Branding_ICO "program_info/${Launcher_APP_BINARY_NAME}.ico") +set(Launcher_Branding_ICO "${Launcher_Branding_ICO}" PARENT_SCOPE) +set(Launcher_Branding_WindowsRC "program_info/${Launcher_APP_BINARY_NAME}.rc" PARENT_SCOPE) +set(Launcher_Branding_LogoQRC "program_info/${Launcher_APP_BINARY_NAME}.qrc" PARENT_SCOPE) +set(Launcher_Authors "MultiMC & Prism Launcher Contributors") + +set(Launcher_Portable_File "program_info/portable.txt" PARENT_SCOPE) + +configure_file(${Launcher_AppID}.desktop.in ${Launcher_AppID}.desktop) +configure_file(${Launcher_AppID}.metainfo.xml.in ${Launcher_AppID}.metainfo.xml) +configure_file(${Launcher_APP_BINARY_NAME}.rc.in ${Launcher_APP_BINARY_NAME}.rc @ONLY) +configure_file(${Launcher_APP_BINARY_NAME}.qrc.in ${Launcher_APP_BINARY_NAME}.qrc @ONLY) +configure_file(${Launcher_APP_BINARY_NAME}.manifest.in ${Launcher_APP_BINARY_NAME}.manifest @ONLY) +configure_file(${Launcher_APP_BINARY_NAME}.ico ${Launcher_APP_BINARY_NAME}.ico COPYONLY) +configure_file(${Launcher_SVGFileName} ${Launcher_SVGFileName} COPYONLY) +configure_file(${Launcher_AppID}.mime.xml ${Launcher_AppID}.xml COPYONLY) + +if(MSVC) + set(Launcher_MSVC_Redist_NSIS_Section [=[ +!ifdef haveNScurl +Section "Visual Studio Runtime" + Var /GLOBAL vc_redist_exe + ${If} ${IsNativeARM64} + StrCpy $vc_redist_exe "vc_redist.arm64.exe" + ${Else} + StrCpy $vc_redist_exe "vc_redist.x64.exe" + ${EndIf} + DetailPrint 'Downloading Microsoft Visual C++ Redistributable...' + NScurl::http GET "https://aka.ms/vs/17/release/$vc_redist_exe" "$INSTDIR\vc_redist\$vc_redist_exe" /INSIST /CANCEL /Zone.Identifier /END + Pop $0 + ${If} $0 == "OK" + DetailPrint "Download successful" + ExecWait "$INSTDIR\vc_redist\$vc_redist_exe /install /passive /norestart" + ${Else} + DetailPrint "Download failed with error $0" + ${EndIf} +SectionEnd +!endif +]=]) +endif() + +configure_file(win_install.nsi.in win_install.nsi @ONLY) + +if(SCDOC_FOUND) + configure_file(${Launcher_APP_BINARY_NAME}.6.scd.in ${Launcher_APP_BINARY_NAME}.6.scd @ONLY) + + set(in_scd "${CMAKE_CURRENT_BINARY_DIR}/${Launcher_APP_BINARY_NAME}.6.scd") + set(out_man "${CMAKE_CURRENT_BINARY_DIR}/${Launcher_APP_BINARY_NAME}.6") + add_custom_command( + DEPENDS "${in_scd}" + OUTPUT "${out_man}" + COMMAND ${SCDOC_SCDOC} < "${in_scd}" > "${out_man}" + ) + add_custom_target(man ALL DEPENDS ${out_man}) + set(Launcher_ManPage "program_info/${Launcher_APP_BINARY_NAME}.6" PARENT_SCOPE) +endif() diff --git a/program_info/LICENSE b/program_info/LICENSE new file mode 100644 index 0000000..c68207d --- /dev/null +++ b/program_info/LICENSE @@ -0,0 +1,430 @@ +Attribution-ShareAlike 4.0 International + +This license only applies to the logos and branding in this folder. + +======================================================================= + +Creative Commons Corporation ("Creative Commons") is not a law firm and +does not provide legal services or legal advice. Distribution of +Creative Commons public licenses does not create a lawyer-client or +other relationship. Creative Commons makes its licenses and related +information available on an "as-is" basis. Creative Commons gives no +warranties regarding its licenses, any material licensed under their +terms and conditions, or any related information. Creative Commons +disclaims all liability for damages resulting from their use to the +fullest extent possible. + +Using Creative Commons Public Licenses + +Creative Commons public licenses provide a standard set of terms and +conditions that creators and other rights holders may use to share +original works of authorship and other material subject to copyright +and certain other rights specified in the public license below. The +following considerations are for informational purposes only, are not +exhaustive, and do not form part of our licenses. + + Considerations for licensors: Our public licenses are + intended for use by those authorized to give the public + permission to use material in ways otherwise restricted by + copyright and certain other rights. Our licenses are + irrevocable. Licensors should read and understand the terms + and conditions of the license they choose before applying it. + Licensors should also secure all rights necessary before + applying our licenses so that the public can reuse the + material as expected. Licensors should clearly mark any + material not subject to the license. This includes other CC- + licensed material, or material used under an exception or + limitation to copyright. More considerations for licensors: + wiki.creativecommons.org/Considerations_for_licensors + + Considerations for the public: By using one of our public + licenses, a licensor grants the public permission to use the + licensed material under specified terms and conditions. If + the licensor's permission is not necessary for any reason--for + example, because of any applicable exception or limitation to + copyright--then that use is not regulated by the license. Our + licenses grant only permissions under copyright and certain + other rights that a licensor has authority to grant. Use of + the licensed material may still be restricted for other + reasons, including because others have copyright or other + rights in the material. A licensor may make special requests, + such as asking that all changes be marked or described. + Although not required by our licenses, you are encouraged to + respect those requests where reasonable. More_considerations + for the public: + wiki.creativecommons.org/Considerations_for_licensees + +======================================================================= + +Creative Commons Attribution-ShareAlike 4.0 International Public +License + +By exercising the Licensed Rights (defined below), You accept and agree +to be bound by the terms and conditions of this Creative Commons +Attribution-ShareAlike 4.0 International Public License ("Public +License"). To the extent this Public License may be interpreted as a +contract, You are granted the Licensed Rights in consideration of Your +acceptance of these terms and conditions, and the Licensor grants You +such rights in consideration of benefits the Licensor receives from +making the Licensed Material available under these terms and +conditions. + + +Section 1 -- Definitions. + + a. Adapted Material means material subject to Copyright and Similar + Rights that is derived from or based upon the Licensed Material + and in which the Licensed Material is translated, altered, + arranged, transformed, or otherwise modified in a manner requiring + permission under the Copyright and Similar Rights held by the + Licensor. For purposes of this Public License, where the Licensed + Material is a musical work, performance, or sound recording, + Adapted Material is always produced where the Licensed Material is + synched in timed relation with a moving image. + + b. Adapter's License means the license You apply to Your Copyright + and Similar Rights in Your contributions to Adapted Material in + accordance with the terms and conditions of this Public License. + + c. BY-SA Compatible License means a license listed at + creativecommons.org/compatiblelicenses, approved by Creative + Commons as essentially the equivalent of this Public License. + + d. Copyright and Similar Rights means copyright and/or similar rights + closely related to copyright including, without limitation, + performance, broadcast, sound recording, and Sui Generis Database + Rights, without regard to how the rights are labeled or + categorized. For purposes of this Public License, the rights + specified in Section 2(b)(1)-(2) are not Copyright and Similar + Rights. + + e. Effective Technological Measures means those measures that, in the + absence of proper authority, may not be circumvented under laws + fulfilling obligations under Article 11 of the WIPO Copyright + Treaty adopted on December 20, 1996, and/or similar international + agreements. + + f. Exceptions and Limitations means fair use, fair dealing, and/or + any other exception or limitation to Copyright and Similar Rights + that applies to Your use of the Licensed Material. + + g. License Elements means the license attributes listed in the name + of a Creative Commons Public License. The License Elements of this + Public License are Attribution and ShareAlike. + + h. Licensed Material means the artistic or literary work, database, + or other material to which the Licensor applied this Public + License. + + i. Licensed Rights means the rights granted to You subject to the + terms and conditions of this Public License, which are limited to + all Copyright and Similar Rights that apply to Your use of the + Licensed Material and that the Licensor has authority to license. + + j. Licensor means the individual(s) or entity(ies) granting rights + under this Public License. + + k. Share means to provide material to the public by any means or + process that requires permission under the Licensed Rights, such + as reproduction, public display, public performance, distribution, + dissemination, communication, or importation, and to make material + available to the public including in ways that members of the + public may access the material from a place and at a time + individually chosen by them. + + l. Sui Generis Database Rights means rights other than copyright + resulting from Directive 96/9/EC of the European Parliament and of + the Council of 11 March 1996 on the legal protection of databases, + as amended and/or succeeded, as well as other essentially + equivalent rights anywhere in the world. + + m. You means the individual or entity exercising the Licensed Rights + under this Public License. Your has a corresponding meaning. + + +Section 2 -- Scope. + + a. License grant. + + 1. Subject to the terms and conditions of this Public License, + the Licensor hereby grants You a worldwide, royalty-free, + non-sublicensable, non-exclusive, irrevocable license to + exercise the Licensed Rights in the Licensed Material to: + + a. reproduce and Share the Licensed Material, in whole or + in part; and + + b. produce, reproduce, and Share Adapted Material. + + 2. Exceptions and Limitations. For the avoidance of doubt, where + Exceptions and Limitations apply to Your use, this Public + License does not apply, and You do not need to comply with + its terms and conditions. + + 3. Term. The term of this Public License is specified in Section + 6(a). + + 4. Media and formats; technical modifications allowed. The + Licensor authorizes You to exercise the Licensed Rights in + all media and formats whether now known or hereafter created, + and to make technical modifications necessary to do so. The + Licensor waives and/or agrees not to assert any right or + authority to forbid You from making technical modifications + necessary to exercise the Licensed Rights, including + technical modifications necessary to circumvent Effective + Technological Measures. For purposes of this Public License, + simply making modifications authorized by this Section 2(a) + (4) never produces Adapted Material. + + 5. Downstream recipients. + + a. Offer from the Licensor -- Licensed Material. Every + recipient of the Licensed Material automatically + receives an offer from the Licensor to exercise the + Licensed Rights under the terms and conditions of this + Public License. + + b. Additional offer from the Licensor -- Adapted Material. + Every recipient of Adapted Material from You + automatically receives an offer from the Licensor to + exercise the Licensed Rights in the Adapted Material + under the conditions of the Adapter's License You apply. + + c. No downstream restrictions. You may not offer or impose + any additional or different terms or conditions on, or + apply any Effective Technological Measures to, the + Licensed Material if doing so restricts exercise of the + Licensed Rights by any recipient of the Licensed + Material. + + 6. No endorsement. Nothing in this Public License constitutes or + may be construed as permission to assert or imply that You + are, or that Your use of the Licensed Material is, connected + with, or sponsored, endorsed, or granted official status by, + the Licensor or others designated to receive attribution as + provided in Section 3(a)(1)(A)(i). + + b. Other rights. + + 1. Moral rights, such as the right of integrity, are not + licensed under this Public License, nor are publicity, + privacy, and/or other similar personality rights; however, to + the extent possible, the Licensor waives and/or agrees not to + assert any such rights held by the Licensor to the limited + extent necessary to allow You to exercise the Licensed + Rights, but not otherwise. + + 2. Patent and trademark rights are not licensed under this + Public License. + + 3. To the extent possible, the Licensor waives any right to + collect royalties from You for the exercise of the Licensed + Rights, whether directly or through a collecting society + under any voluntary or waivable statutory or compulsory + licensing scheme. In all other cases the Licensor expressly + reserves any right to collect such royalties. + + +Section 3 -- License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the +following conditions. + + a. Attribution. + + 1. If You Share the Licensed Material (including in modified + form), You must: + + a. retain the following if it is supplied by the Licensor + with the Licensed Material: + + i. identification of the creator(s) of the Licensed + Material and any others designated to receive + attribution, in any reasonable manner requested by + the Licensor (including by pseudonym if + designated); + + ii. a copyright notice; + + iii. a notice that refers to this Public License; + + iv. a notice that refers to the disclaimer of + warranties; + + v. a URI or hyperlink to the Licensed Material to the + extent reasonably practicable; + + b. indicate if You modified the Licensed Material and + retain an indication of any previous modifications; and + + c. indicate the Licensed Material is licensed under this + Public License, and include the text of, or the URI or + hyperlink to, this Public License. + + 2. You may satisfy the conditions in Section 3(a)(1) in any + reasonable manner based on the medium, means, and context in + which You Share the Licensed Material. For example, it may be + reasonable to satisfy the conditions by providing a URI or + hyperlink to a resource that includes the required + information. + + 3. If requested by the Licensor, You must remove any of the + information required by Section 3(a)(1)(A) to the extent + reasonably practicable. + + b. ShareAlike. + + In addition to the conditions in Section 3(a), if You Share + Adapted Material You produce, the following conditions also apply. + + 1. The Adapter's License You apply must be a Creative Commons + license with the same License Elements, this version or + later, or a BY-SA Compatible License. + + 2. You must include the text of, or the URI or hyperlink to, the + Adapter's License You apply. You may satisfy this condition + in any reasonable manner based on the medium, means, and + context in which You Share Adapted Material. + + 3. You may not offer or impose any additional or different terms + or conditions on, or apply any Effective Technological + Measures to, Adapted Material that restrict exercise of the + rights granted under the Adapter's License You apply. + + +Section 4 -- Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that +apply to Your use of the Licensed Material: + + a. for the avoidance of doubt, Section 2(a)(1) grants You the right + to extract, reuse, reproduce, and Share all or a substantial + portion of the contents of the database; + + b. if You include all or a substantial portion of the database + contents in a database in which You have Sui Generis Database + Rights, then the database in which You have Sui Generis Database + Rights (but not its individual contents) is Adapted Material, + + including for purposes of Section 3(b); and + c. You must comply with the conditions in Section 3(a) if You Share + all or a substantial portion of the contents of the database. + +For the avoidance of doubt, this Section 4 supplements and does not +replace Your obligations under this Public License where the Licensed +Rights include other Copyright and Similar Rights. + + +Section 5 -- Disclaimer of Warranties and Limitation of Liability. + + a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE + EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS + AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF + ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, + IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, + WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR + PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, + ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT + KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT + ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. + + b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE + TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, + NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, + INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, + COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR + USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN + ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR + DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR + IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. + + c. The disclaimer of warranties and limitation of liability provided + above shall be interpreted in a manner that, to the extent + possible, most closely approximates an absolute disclaimer and + waiver of all liability. + + +Section 6 -- Term and Termination. + + a. This Public License applies for the term of the Copyright and + Similar Rights licensed here. However, if You fail to comply with + this Public License, then Your rights under this Public License + terminate automatically. + + b. Where Your right to use the Licensed Material has terminated under + Section 6(a), it reinstates: + + 1. automatically as of the date the violation is cured, provided + it is cured within 30 days of Your discovery of the + violation; or + + 2. upon express reinstatement by the Licensor. + + For the avoidance of doubt, this Section 6(b) does not affect any + right the Licensor may have to seek remedies for Your violations + of this Public License. + + c. For the avoidance of doubt, the Licensor may also offer the + Licensed Material under separate terms or conditions or stop + distributing the Licensed Material at any time; however, doing so + will not terminate this Public License. + + d. Sections 1, 5, 6, 7, and 8 survive termination of this Public + License. + + +Section 7 -- Other Terms and Conditions. + + a. The Licensor shall not be bound by any additional or different + terms or conditions communicated by You unless expressly agreed. + + b. Any arrangements, understandings, or agreements regarding the + Licensed Material not stated herein are separate from and + independent of the terms and conditions of this Public License. + + +Section 8 -- Interpretation. + + a. For the avoidance of doubt, this Public License does not, and + shall not be interpreted to, reduce, limit, restrict, or impose + conditions on any use of the Licensed Material that could lawfully + be made without permission under this Public License. + + b. To the extent possible, if any provision of this Public License is + deemed unenforceable, it shall be automatically reformed to the + minimum extent necessary to make it enforceable. If the provision + cannot be reformed, it shall be severed from this Public License + without affecting the enforceability of the remaining terms and + conditions. + + c. No term or condition of this Public License will be waived and no + failure to comply consented to unless expressly agreed to by the + Licensor. + + d. Nothing in this Public License constitutes or may be interpreted + as a limitation upon, or waiver of, any privileges and immunities + that apply to the Licensor or You, including from the legal + processes of any jurisdiction or authority. + + +======================================================================= + +Creative Commons is not a party to its public +licenses. Notwithstanding, Creative Commons may elect to apply one of +its public licenses to material it publishes and in those instances +will be considered the “Licensor.” The text of the Creative Commons +public licenses is dedicated to the public domain under the CC0 Public +Domain Dedication. Except for the limited purpose of indicating that +material is shared under a Creative Commons public license or as +otherwise permitted by the Creative Commons policies published at +creativecommons.org/policies, Creative Commons does not authorize the +use of the trademark "Creative Commons" or any other trademark or logo +of Creative Commons without its prior written consent including, +without limitation, in connection with any unauthorized modifications +to any of its public licenses or any other arrangements, +understandings, or agreements concerning use of licensed material. For +the avoidance of doubt, this paragraph does not form part of the +public licenses. + +Creative Commons may be contacted at creativecommons.org. + diff --git a/program_info/PrismLauncher.icon/Assets/block.svg b/program_info/PrismLauncher.icon/Assets/block.svg new file mode 100644 index 0000000..08a80d8 --- /dev/null +++ b/program_info/PrismLauncher.icon/Assets/block.svg @@ -0,0 +1,91 @@ + + + Prism Launcher Logo + + + + + + + + + + + + + Prism Launcher Logo + 19/10/2022 + + + Prism Launcher + + + + + AutiOne, Boba, ely, Fulmine, gon sawa, Pankakes, tobimori, Zeke + + + https://github.com/PrismLauncher/PrismLauncher + + + Prism Launcher + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/program_info/PrismLauncher.icon/Assets/rainbow.svg b/program_info/PrismLauncher.icon/Assets/rainbow.svg new file mode 100644 index 0000000..d47bb61 --- /dev/null +++ b/program_info/PrismLauncher.icon/Assets/rainbow.svg @@ -0,0 +1,95 @@ + + + Prism Launcher Logo + + + + + + + + + + + + + + + Prism Launcher Logo + 19/10/2022 + + + Prism Launcher + + + + + AutiOne, Boba, ely, Fulmine, gon sawa, Pankakes, tobimori, Zeke + + + https://github.com/PrismLauncher/PrismLauncher + + + Prism Launcher + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/program_info/PrismLauncher.icon/icon.json b/program_info/PrismLauncher.icon/icon.json new file mode 100644 index 0000000..23d8b18 --- /dev/null +++ b/program_info/PrismLauncher.icon/icon.json @@ -0,0 +1,72 @@ +{ + "color-space-for-untagged-svg-colors" : "display-p3", + "fill" : { + "solid" : "extended-gray:1.00000,1.00000" + }, + "groups" : [ + { + "layers" : [ + { + "image-name" : "block.svg", + "name" : "block", + "position" : { + "scale" : 19.28, + "translation-in-points" : [ + 0, + 0 + ] + } + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + }, + { + "blur-material-specializations" : [ + { + "value" : 0.5 + }, + { + "appearance" : "dark", + "value" : null + } + ], + "layers" : [ + { + "blend-mode" : "normal", + "fill" : "automatic", + "glass" : true, + "hidden" : false, + "image-name" : "rainbow.svg", + "name" : "rainbow", + "position" : { + "scale" : 19.28, + "translation-in-points" : [ + 0, + 0 + ] + } + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : false, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "squares" : [ + "macOS" + ] + } +} diff --git a/program_info/README.md b/program_info/README.md new file mode 100644 index 0000000..5ba2fa3 --- /dev/null +++ b/program_info/README.md @@ -0,0 +1,7 @@ +# Prism Launcher Program Info + +This is Prism Launcher's program info which contains information about: + +- Application name and logo (and branding in general) +- Various URLs and API endpoints +- Desktop file diff --git a/program_info/genicons.sh b/program_info/genicons.sh new file mode 100644 index 0000000..b2ba732 --- /dev/null +++ b/program_info/genicons.sh @@ -0,0 +1,70 @@ +#!/bin/bash + +LAUNCHER_APPID="org.prismlauncher.PrismLauncher" + +svg2png() { + input_file="$1" + output_file="$2" + width="$3" + height="$4" + + inkscape -w "$width" -h "$height" -o "$output_file" "$input_file" +} + +if command -v "inkscape" && command -v "icotool" && command -v "oxipng"; then + # Windows ICO + d=$(mktemp -d) + + svg2png ${LAUNCHER_APPID}.svg "$d/prismlauncher_16.png" 16 16 + svg2png ${LAUNCHER_APPID}.svg "$d/prismlauncher_24.png" 24 24 + svg2png ${LAUNCHER_APPID}.svg "$d/prismlauncher_32.png" 32 32 + svg2png ${LAUNCHER_APPID}.svg "$d/prismlauncher_48.png" 48 48 + svg2png ${LAUNCHER_APPID}.svg "$d/prismlauncher_64.png" 64 64 + svg2png ${LAUNCHER_APPID}.svg "$d/prismlauncher_128.png" 128 128 + svg2png ${LAUNCHER_APPID}.svg "$d/prismlauncher_256.png" 256 256 + + oxipng --opt max --strip all --alpha --interlace 0 "$d/prismlauncher_"*".png" + + rm prismlauncher.ico && icotool -o prismlauncher.ico -c \ + "$d/prismlauncher_256.png" \ + "$d/prismlauncher_128.png" \ + "$d/prismlauncher_64.png" \ + "$d/prismlauncher_48.png" \ + "$d/prismlauncher_32.png" \ + "$d/prismlauncher_24.png" \ + "$d/prismlauncher_16.png" +else + echo "ERROR: Windows icons were NOT generated!" >&2 + echo "ERROR: requires inkscape, icotool and oxipng in PATH" +fi + +if command -v "inkscape" && command -v "iconutil" && command -v "oxipng"; then + # macOS ICNS + d=$(mktemp -d) + + d="$d/prismlauncher.iconset" + + mkdir -p "$d" + + svg2png ${LAUNCHER_APPID}.bigsur.svg "$d/icon_16x16.png" 16 16 + svg2png ${LAUNCHER_APPID}.bigsur.svg "$d/icon_16x16@2x.png" 32 32 + svg2png ${LAUNCHER_APPID}.bigsur.svg "$d/icon_32x32.png" 32 32 + svg2png ${LAUNCHER_APPID}.bigsur.svg "$d/icon_32x32@2x.png" 64 64 + svg2png ${LAUNCHER_APPID}.bigsur.svg "$d/icon_128x128.png" 128 128 + svg2png ${LAUNCHER_APPID}.bigsur.svg "$d/icon_128x128@2x.png" 256 256 + svg2png ${LAUNCHER_APPID}.bigsur.svg "$d/icon_256x256.png" 256 256 + svg2png ${LAUNCHER_APPID}.bigsur.svg "$d/icon_256x256@2x.png" 512 512 + svg2png ${LAUNCHER_APPID}.bigsur.svg "$d/icon_512x512.png" 512 512 + svg2png ${LAUNCHER_APPID}.bigsur.svg "$d/icon_512x512@2x.png" 1024 1024 + + oxipng --opt max --strip all --alpha --interlace 0 "$d/icon_"*".png" + + iconutil -c icns "$d" + cp -v "$d/prismlauncher.icns" . +else + echo "ERROR: macOS icons were NOT generated!" >&2 + echo "ERROR: requires inkscape, iconutil and oxipng in PATH" +fi + +# replace icon in themes +cp -v ${LAUNCHER_APPID}.svg "../launcher/resources/multimc/scalable/launcher.svg" diff --git a/program_info/instance_icons.svg b/program_info/instance_icons.svg new file mode 100644 index 0000000..84c0ef6 --- /dev/null +++ b/program_info/instance_icons.svg @@ -0,0 +1,2720 @@ + + + +Prism Launcher LogoPrism Launcher Logo19/10/2022Prism LauncherAutiOne, Boba, ely, Fulmine, gon sawa, Pankakes, tobimori, Zekehttps://github.com/PrismLauncher/PrismLauncherCC BY-SA 4.0Prism Launcher diff --git a/program_info/org.prismlauncher.PrismLauncher.Social.svg b/program_info/org.prismlauncher.PrismLauncher.Social.svg new file mode 100644 index 0000000..6635bb0 --- /dev/null +++ b/program_info/org.prismlauncher.PrismLauncher.Social.svg @@ -0,0 +1,60 @@ + + + + Prism Launcher Logo + + + + + + + + + + + + + + + + + + + + + + + + + + Prism Launcher Logo + 19/10/2022 + + + Prism Launcher + + + + + AutiOne, Boba, ely, Fulmine, gon sawa, Pankakes, tobimori, Zeke + + + https://github.com/PrismLauncher/PrismLauncher + + + Prism Launcher + + + + + + + + + + + + + + + diff --git a/program_info/org.prismlauncher.PrismLauncher.Source.svg b/program_info/org.prismlauncher.PrismLauncher.Source.svg new file mode 100644 index 0000000..d7af61f --- /dev/null +++ b/program_info/org.prismlauncher.PrismLauncher.Source.svg @@ -0,0 +1,203 @@ + + + + + Prism Launcher Logo + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Prism Launcher Logo + 19/10/2022 + + + Prism Launcher + + + + + AutiOne, Boba, ely, Fulmine, gon sawa, Pankakes, tobimori, Zeke + + + https://github.com/PrismLauncher/PrismLauncher + + + Prism Launcher + + + + + + + + + + + + + + + diff --git a/program_info/org.prismlauncher.PrismLauncher.bigsur.svg b/program_info/org.prismlauncher.PrismLauncher.bigsur.svg new file mode 100644 index 0000000..2ec4999 --- /dev/null +++ b/program_info/org.prismlauncher.PrismLauncher.bigsur.svg @@ -0,0 +1,272 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/program_info/org.prismlauncher.PrismLauncher.desktop.in b/program_info/org.prismlauncher.PrismLauncher.desktop.in new file mode 100644 index 0000000..416ca1b --- /dev/null +++ b/program_info/org.prismlauncher.PrismLauncher.desktop.in @@ -0,0 +1,13 @@ +[Desktop Entry] +Version=1.0 +Name=@Launcher_DisplayName@ +Comment=Discover, manage, and play Minecraft instances +Type=Application +Terminal=false +Exec=@Launcher_APP_BINARY_NAME@ %U +StartupNotify=true +Icon=@Launcher_AppID@ +Categories=Game;ActionGame;AdventureGame;Simulation;PackageManager; +Keywords=game;minecraft;mc; +StartupWMClass=@Launcher_CommonName@ +MimeType=application/zip;application/x-modrinth-modpack+zip;x-scheme-handler/curseforge;x-scheme-handler/prismlauncher;x-scheme-handler/@Launcher_APP_BINARY_NAME@; diff --git a/program_info/org.prismlauncher.PrismLauncher.logo-darkmode.svg b/program_info/org.prismlauncher.PrismLauncher.logo-darkmode.svg new file mode 100644 index 0000000..2c40f57 --- /dev/null +++ b/program_info/org.prismlauncher.PrismLauncher.logo-darkmode.svg @@ -0,0 +1,212 @@ + + + + + + + Prism Launcher Logo + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Prism Launcher Logo + 19/10/2022 + + + Prism Launcher + + + + + AutiOne, Boba, ely, Fulmine, gon sawa, Pankakes, tobimori, Zeke + + + https://github.com/PrismLauncher/PrismLauncher + + + Prism Launcher + + + + + + + + + + + + + + + diff --git a/program_info/org.prismlauncher.PrismLauncher.logo.source.svg b/program_info/org.prismlauncher.PrismLauncher.logo.source.svg new file mode 100644 index 0000000..4bc81cf --- /dev/null +++ b/program_info/org.prismlauncher.PrismLauncher.logo.source.svg @@ -0,0 +1,256 @@ + + + + + Prism Launcher Logo + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Prism Launcher Logo + 19/10/2022 + + + Prism Launcher + + + + + AutiOne, Boba, ely, Fulmine, gon sawa, Pankakes, tobimori, Zeke + + + https://github.com/PrismLauncher/PrismLauncher + + + Prism Launcher + + + + + + + + + + + + + + + diff --git a/program_info/org.prismlauncher.PrismLauncher.logo.svg b/program_info/org.prismlauncher.PrismLauncher.logo.svg new file mode 100644 index 0000000..eda077c --- /dev/null +++ b/program_info/org.prismlauncher.PrismLauncher.logo.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/program_info/org.prismlauncher.PrismLauncher.metainfo.xml.in b/program_info/org.prismlauncher.PrismLauncher.metainfo.xml.in new file mode 100644 index 0000000..f17d5cc --- /dev/null +++ b/program_info/org.prismlauncher.PrismLauncher.metainfo.xml.in @@ -0,0 +1,78 @@ + + + @Launcher_AppID@ + Prism Launcher + Custom Minecraft Launcher to easily manage multiple Minecraft installations at once + + Prism Launcher Contributors + + CC0-1.0 + GPL-3.0-only + +

    Prism Launcher is a custom launcher for Minecraft that focuses on predictability, long term stability and simplicity.

    +

    Features:

    +
      +
    • Easily install game modifications, such as Fabric, Forge and Quilt
    • +
    • Easily install and update modpacks from the Launcher
    • +
    • Control your Java settings, and enable Mangohud or Gamemode with a toggle
    • +
    • Manage worlds and resource packs from the launcher
    • +
    • See logs and other details easily through a dashboard
    • +
    • Kill Minecraft in case of a crash/freeze
    • +
    • Isolate Minecraft instances to keep everything clean
    • +
    • Install and update mods directly from the launcher
    • +
    • Customize the launcher with themes, and more
    • +
    • And cat :3
    • +
    +
    + + + The main Prism Launcher window + https://prismlauncher.org/img/screenshots/LauncherDark.png + + + Modpack installation + https://prismlauncher.org/img/screenshots/ModpackInstallDark.png + + + Modpack updating + https://prismlauncher.org/img/screenshots/ModpackUpdateDark.png + + + Mod installation + https://prismlauncher.org/img/screenshots/ModInstallDark.png + + + Mod updating + https://prismlauncher.org/img/screenshots/ModUpdateDark.png + + + Instance management + https://prismlauncher.org/img/screenshots/PropertiesDark.png + + + Cat :3 + https://prismlauncher.org/img/screenshots/LauncherCatDark.png + + + Customization + https://prismlauncher.org/img/screenshots/CustomizeDark.png + + + + + + https://prismlauncher.org/ + https://github.com/s8n-ru/minecraft-launcher/issues + https://prismlauncher.org/wiki/overview/faq/ + https://prismlauncher.org/wiki/ + https://opencollective.com/prismlauncher + https://hosted.weblate.org/projects/prismlauncher/launcher + https://prismlauncher.org/discord + https://github.com/s8n-ru/minecraft-launcher + https://github.com/s8n-ru/minecraft-launcher/blob/main/CONTRIBUTING.md + + moderate + intense + + @Launcher_AppID@.desktop +
    diff --git a/program_info/org.prismlauncher.PrismLauncher.mime.xml b/program_info/org.prismlauncher.PrismLauncher.mime.xml new file mode 100644 index 0000000..5001e5e --- /dev/null +++ b/program_info/org.prismlauncher.PrismLauncher.mime.xml @@ -0,0 +1,9 @@ + + + + Modrinth Modpack File + + + + + diff --git a/program_info/org.prismlauncher.PrismLauncher.svg b/program_info/org.prismlauncher.PrismLauncher.svg new file mode 100644 index 0000000..3071363 --- /dev/null +++ b/program_info/org.prismlauncher.PrismLauncher.svg @@ -0,0 +1,57 @@ + + + + Prism Launcher Logo + + + + + + + + + + + + + + + + + + + + + + + Prism Launcher Logo + 19/10/2022 + + + Prism Launcher + + + + + AutiOne, Boba, ely, Fulmine, gon sawa, Pankakes, tobimori, Zeke + + + https://github.com/PrismLauncher/PrismLauncher + + + Prism Launcher + + + + + + + + + + + + + + + diff --git a/program_info/org.prismlauncher.PrismLauncher_256.png b/program_info/org.prismlauncher.PrismLauncher_256.png new file mode 100755 index 0000000..6f164fe Binary files /dev/null and b/program_info/org.prismlauncher.PrismLauncher_256.png differ diff --git a/program_info/portable.txt b/program_info/portable.txt new file mode 100644 index 0000000..b7e256a --- /dev/null +++ b/program_info/portable.txt @@ -0,0 +1,4 @@ +This file enables the portable mode for the launcher. + +If this file is present in the root directory of the launcher, it will store all data here. Otherwise it will store your data in your appdata directory. +You can safely delete this file, if you don't want the launcher to store your data here. diff --git a/program_info/prismlauncher-monochrome.Source.svg b/program_info/prismlauncher-monochrome.Source.svg new file mode 100644 index 0000000..7e8c879 --- /dev/null +++ b/program_info/prismlauncher-monochrome.Source.svg @@ -0,0 +1,207 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/program_info/prismlauncher.6.scd.in b/program_info/prismlauncher.6.scd.in new file mode 100644 index 0000000..2b5f3e4 --- /dev/null +++ b/program_info/prismlauncher.6.scd.in @@ -0,0 +1,82 @@ +@Launcher_APP_BINARY_NAME@(6) + + +# NAME + +@Launcher_APP_BINARY_NAME@ - a launcher and instance manager for Minecraft. + + +# SYNOPSIS + +*@Launcher_APP_BINARY_NAME@* [OPTIONS...] + + +# DESCRIPTION + +Prism Launcher is a custom launcher for Minecraft that allows you to easily manage +multiple installations of Minecraft at once. It also allows you to easily +install and remove mods by simply dragging and dropping. +Here are the current features of Prism Launcher. + +# OPTIONS + +*-d, --dir*=DIRECTORY + Use DIRECTORY as the Prism Launcher root. + +*-l, --launch*=INSTANCE_ID + Launch the instance specified by INSTANCE_ID. + +*--show*=INSTANCE_ID + Show the configuration window of the instance specified by INSTANCE_ID. + +*--alive* + Write a small 'live.check' file after Prism Launcher starts. + +*-h, --help* + Display help text and exit. + +*-v, --version* + Display program version and exit. + +*-a, --profile*=PROFILE + Use the account specified by PROFILE (only valid in combination with --launch). + +# ENVIRONMENT + +The behavior of the launcher can be customized by the following environment +variables, besides other common Qt variables: + +*QT_LOGGING_RULES* + Specifies which logging categories are shown in the logs. One can + enable/disable multiple categories by separating them with a semicolon (;). + + The specific syntax, and alternatives to this setting, can be found at + https://doc.qt.io/qt-6/qloggingcategory.html#configuring-categories. + +*QT_MESSAGE_PATTERN* + Specifies the format in which the console output will be shown. + + Available options, as well as syntax, can be viewed at + https://doc.qt.io/qt-6/qtglobal.html#qSetMessagePattern. + +# EXIT STATUS + +*0* + Success + +*1* + Failure (syntax or usage error; configuration error; unexpected error). + +# BUGS + +@Launcher_BUG_TRACKER_URL@ + +# RESOURCES + +GitHub: @Launcher_Git@ + +Main website: https://@Launcher_Domain@ + +# AUTHORS + +@Launcher_Authors@ diff --git a/program_info/prismlauncher.icns b/program_info/prismlauncher.icns new file mode 100644 index 0000000..a5e6a8c Binary files /dev/null and b/program_info/prismlauncher.icns differ diff --git a/program_info/prismlauncher.ico b/program_info/prismlauncher.ico new file mode 100644 index 0000000..2f0fa67 Binary files /dev/null and b/program_info/prismlauncher.ico differ diff --git a/program_info/prismlauncher.manifest.in b/program_info/prismlauncher.manifest.in new file mode 100644 index 0000000..f5074ff --- /dev/null +++ b/program_info/prismlauncher.manifest.in @@ -0,0 +1,28 @@ + + + + + true + + + + + + + + + + + + + + + + Custom Minecraft launcher for managing multiple installs. + + + + + + + diff --git a/program_info/prismlauncher.qrc.in b/program_info/prismlauncher.qrc.in new file mode 100644 index 0000000..d1e1cdd --- /dev/null +++ b/program_info/prismlauncher.qrc.in @@ -0,0 +1,6 @@ + + + + @Launcher_AppID@.svg + + diff --git a/program_info/prismlauncher.rc.in b/program_info/prismlauncher.rc.in new file mode 100644 index 0000000..7001431 --- /dev/null +++ b/program_info/prismlauncher.rc.in @@ -0,0 +1,29 @@ +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include + +IDI_ICON1 ICON DISCARDABLE "@Launcher_APP_BINARY_NAME@.ico" +1 RT_MANIFEST "@Launcher_APP_BINARY_NAME@.manifest" + +VS_VERSION_INFO VERSIONINFO +FILEVERSION @Launcher_VERSION_NAME4_COMMA@ +FILEOS VOS_NT_WINDOWS32 +FILETYPE VFT_APP +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "000004b0" + BEGIN + VALUE "CompanyName", "@Launcher_Authors@" + VALUE "FileDescription", "@Launcher_DisplayName@" + VALUE "FileVersion", "@Launcher_VERSION_NAME4@" + VALUE "ProductName", "@Launcher_DisplayName@" + VALUE "ProductVersion", "@Launcher_VERSION_NAME4@" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x0000, 0x04b0 // Unicode + END +END diff --git a/program_info/win_install.nsi.in b/program_info/win_install.nsi.in new file mode 100644 index 0000000..ba6b7e0 --- /dev/null +++ b/program_info/win_install.nsi.in @@ -0,0 +1,516 @@ +!include "FileFunc.nsh" +!include "LogicLib.nsh" +!include "MUI2.nsh" + +!include "x64.nsh" + +Unicode true + +Name "@Launcher_DisplayName@" +InstallDir "$LOCALAPPDATA\Programs\@Launcher_CommonName@" +InstallDirRegKey HKCU "Software\@Launcher_CommonName@" "InstallDir" +RequestExecutionLevel user +OutFile "../@Launcher_CommonName@-Setup.exe" + +!define MUI_ICON "../@Launcher_Branding_ICO@" + +!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\@Launcher_CommonName@" + +;-------------------------------- + +; Pages + +!insertmacro MUI_PAGE_WELCOME +!define MUI_COMPONENTSPAGE_NODESC +!insertmacro MUI_PAGE_COMPONENTS +!insertmacro MUI_PAGE_DIRECTORY +!insertmacro MUI_PAGE_INSTFILES +!define MUI_FINISHPAGE_RUN "$InstDir\@Launcher_APP_BINARY_NAME@.exe" +!insertmacro MUI_PAGE_FINISH + +!insertmacro MUI_UNPAGE_CONFIRM +!insertmacro MUI_UNPAGE_INSTFILES + +;-------------------------------- + +; Languages + +!insertmacro MUI_LANGUAGE "English" +!insertmacro MUI_LANGUAGE "French" +!insertmacro MUI_LANGUAGE "German" +!insertmacro MUI_LANGUAGE "Spanish" +!insertmacro MUI_LANGUAGE "SpanishInternational" +!insertmacro MUI_LANGUAGE "SimpChinese" +!insertmacro MUI_LANGUAGE "TradChinese" +!insertmacro MUI_LANGUAGE "Japanese" +!insertmacro MUI_LANGUAGE "Korean" +!insertmacro MUI_LANGUAGE "Italian" +!insertmacro MUI_LANGUAGE "Dutch" +!insertmacro MUI_LANGUAGE "Danish" +!insertmacro MUI_LANGUAGE "Swedish" +!insertmacro MUI_LANGUAGE "Norwegian" +!insertmacro MUI_LANGUAGE "NorwegianNynorsk" +!insertmacro MUI_LANGUAGE "Finnish" +!insertmacro MUI_LANGUAGE "Greek" +!insertmacro MUI_LANGUAGE "Russian" +!insertmacro MUI_LANGUAGE "Portuguese" +!insertmacro MUI_LANGUAGE "PortugueseBR" +!insertmacro MUI_LANGUAGE "Polish" +!insertmacro MUI_LANGUAGE "Ukrainian" +!insertmacro MUI_LANGUAGE "Czech" +!insertmacro MUI_LANGUAGE "Slovak" +!insertmacro MUI_LANGUAGE "Croatian" +!insertmacro MUI_LANGUAGE "Bulgarian" +!insertmacro MUI_LANGUAGE "Hungarian" +!insertmacro MUI_LANGUAGE "Thai" +!insertmacro MUI_LANGUAGE "Romanian" +!insertmacro MUI_LANGUAGE "Latvian" +!insertmacro MUI_LANGUAGE "Macedonian" +!insertmacro MUI_LANGUAGE "Estonian" +!insertmacro MUI_LANGUAGE "Turkish" +!insertmacro MUI_LANGUAGE "Lithuanian" +!insertmacro MUI_LANGUAGE "Slovenian" +!insertmacro MUI_LANGUAGE "Serbian" +!insertmacro MUI_LANGUAGE "SerbianLatin" +!insertmacro MUI_LANGUAGE "Arabic" +!insertmacro MUI_LANGUAGE "Farsi" +!insertmacro MUI_LANGUAGE "Hebrew" +!insertmacro MUI_LANGUAGE "Indonesian" +!insertmacro MUI_LANGUAGE "Mongolian" +!insertmacro MUI_LANGUAGE "Luxembourgish" +!insertmacro MUI_LANGUAGE "Albanian" +!insertmacro MUI_LANGUAGE "Breton" +!insertmacro MUI_LANGUAGE "Belarusian" +!insertmacro MUI_LANGUAGE "Icelandic" +!insertmacro MUI_LANGUAGE "Malay" +!insertmacro MUI_LANGUAGE "Bosnian" +!insertmacro MUI_LANGUAGE "Kurdish" +!insertmacro MUI_LANGUAGE "Irish" +!insertmacro MUI_LANGUAGE "Uzbek" +!insertmacro MUI_LANGUAGE "Galician" +!insertmacro MUI_LANGUAGE "Afrikaans" +!insertmacro MUI_LANGUAGE "Catalan" +!insertmacro MUI_LANGUAGE "Esperanto" +!insertmacro MUI_LANGUAGE "Asturian" +!insertmacro MUI_LANGUAGE "Basque" +!insertmacro MUI_LANGUAGE "Pashto" +!insertmacro MUI_LANGUAGE "ScotsGaelic" +!insertmacro MUI_LANGUAGE "Georgian" +!insertmacro MUI_LANGUAGE "Vietnamese" +!insertmacro MUI_LANGUAGE "Welsh" +!insertmacro MUI_LANGUAGE "Armenian" +!insertmacro MUI_LANGUAGE "Corsican" +!insertmacro MUI_LANGUAGE "Tatar" +!insertmacro MUI_LANGUAGE "Hindi" + +;-------------------------------- + +; Version info +VIProductVersion "@Launcher_VERSION_NAME4@" +VIFileVersion "@Launcher_VERSION_NAME4@" +VIAddVersionKey /LANG=${LANG_ENGLISH} "ProductName" "@Launcher_DisplayName@" +VIAddVersionKey /LANG=${LANG_ENGLISH} "FileDescription" "@Launcher_DisplayName@ Installer" +VIAddVersionKey /LANG=${LANG_ENGLISH} "LegalCopyright" "@Launcher_Copyright@" +VIAddVersionKey /LANG=${LANG_ENGLISH} "FileVersion" "@Launcher_VERSION_NAME4@" +VIAddVersionKey /LANG=${LANG_ENGLISH} "ProductVersion" "@Launcher_VERSION_NAME4@" + +;-------------------------------- +; Conditional comp with file exist + +!macro CompileTimeIfFileExist path define +!tempfile tmpinc +!system 'IF EXIST "${path}" echo !define ${define} > "${tmpinc}"' +!include "${tmpinc}" +!delfile "${tmpinc}" +!undef tmpinc +!macroend + +;-------------------------------- +; Shell Associate Macros + +!macro APP_SETUP_Def DESCRIPTION ICON APP_ID APP_NAME APP_EXE COMMANDTEXT COMMAND + + ; setup APP_ID + WriteRegStr ShCtx "Software\Classes\${APP_ID}" "" `${DESCRIPTION}` + WriteRegStr ShCtx "Software\Classes\${APP_ID}\DefaultIcon" "" `${ICON}` + ; default open verb + WriteRegStr ShCtx "Software\Classes\${APP_ID}\shell" "" "open" + WriteRegStr ShCtx "Software\Classes\${APP_ID}\shell\open" "" `${COMMANDTEXT}` + WriteRegStr ShCtx "Software\Classes\${APP_ID}\shell\open\command" "" `${COMMAND}` + + WriteRegStr ShCtx "Software\Classes\Applications\${APP_EXE}\shell\open\command" "" `${COMMAND}` + WriteRegStr ShCtx "Software\Classes\Applications\${APP_EXE}" "FriendlyAppName" `${APP_NAME}` ; [Optional] + +!macroend + +!macro APP_SETUP DESCRIPTION ICON APP_ID APP_NAME APP_EXE COMMANDTEXT COMMAND + + !insertmacro APP_SETUP_Def `${DESCRIPTION}` `${ICON}` `${APP_ID}` `${APP_NAME}` `${APP_EXE}` `${COMMANDTEXT}` `${COMMAND}` + +!macroend + +!macro APP_SETUP_DEFAULT DESCRIPTION ICON APP_ID APP_NAME APP_EXE COMMANDTEXT COMMAND + + !insertmacro APP_SETUP_Def `${DESCRIPTION}` `${ICON}` `${APP_ID}` `${APP_NAME}` `${APP_EXE}` `${COMMANDTEXT}` `${COMMAND}` + + # Register "Default Programs" + WriteRegStr ShCtx "Software\Classes\Applications\${APP_EXE}\Capabilities" "ApplicationDescription" `${DESCRIPTION}` + WriteRegStr ShCtx "Software\RegisteredApplications" `${APP_NAME}` "Software\Classes\Applications\${APP_EXE}\Capabilities" + +!macroend + +!macro APP_ASSOCIATE_Def EXT APP_ID APP_EXE OVERWIRTE + ; Backup the previously associated file class + ${If} ${OVERWIRTE} == true + ReadRegStr $R0 ShCtx "Software\Classes\${EXT}" "" + WriteRegStr ShCtx "Software\Classes\${EXT}" "${APP_ID}_backup" "$R0" + WriteRegStr ShCtx "Software\Classes\${EXT}" "" "${APP_ID}" + ${EndIf} + + WriteRegNone ShCtx "Software\Classes\${EXT}\OpenWithList" "${APP_EXE}" ; Win2000+ + WriteRegNone ShCtx "Software\Classes\${EXT}\OpenWithProgids" "${APP_ID}" ; WinXP+ + +!macroend + +!macro APP_ASSOCIATE EXT APP_ID APP_EXE OVERWIRTE + + !insertmacro APP_ASSOCIATE_Def `${EXT}` `${APP_ID}` `${APP_EXE}` `${OVERWIRTE}` + +!macroend + +!macro APP_ASSOCIATE_DEFAULT EXT APP_ID APP_EXE OVERWIRTE + + !insertmacro APP_ASSOCIATE_Def `${EXT}` `${APP_ID}` `${APP_EXE}` `${OVERWIRTE}` + + # Register "Default Programs" + WriteRegStr ShCtx "Software\Classes\Applications\${APP_EXE}\Capabilities\FileAssociations" "${EXT}" "${APP_ID}" + +!macroend + +!macro APP_UNASSOCIATE EXT APP_ID APP_EXE + + # Unregister file type + ClearErrors + ; restore backup + ReadRegStr $R1 ShCtx "Software\Classes\${EXT}" "" + ${If} $R1 == "${APP_ID}" + ReadRegStr $R0 ShCtx "Software\Classes\${EXT}" `${APP_ID}_backup` + WriteRegStr ShCtx "Software\Classes\${EXT}" "" "$R0" + ${Else} + ReadRegStr $R0 ShCtx "Software\Classes\${EXT}" "" + ${EndIf} + + DeleteRegKey /IfEmpty ShCtx "Software\Classes\${APP_ID}" + ${IfNot} ${Errors} + ${AndIf} $R0 == "${APP_ID}" + DeleteRegValue ShCtx "Software\Classes\${EXT}" "" + DeleteRegKey /IfEmpty ShCtx "Software\Classes\${EXT}" + ${EndIf} + + DeleteRegValue ShCtx "Software\Classes\${EXT}\OpenWithList" "${APP_EXE}" + DeleteRegKey /IfEmpty ShCtx "Software\Classes\${EXT}\OpenWithList" + DeleteRegValue ShCtx "Software\Classes\${EXT}\OpenWithProgids" "${APP_ID}" + DeleteRegKey /IfEmpty ShCtx "Software\Classes\${EXT}\OpenWithProgids" + DeleteRegKey /IfEmpty ShCtx "Software\Classes\${EXT}" + + # Attempt to clean up junk left behind by the Windows shell + DeleteRegValue HKCU "Software\Microsoft\Windows\CurrentVersion\ApplicationAssociationToasts" "${APP_ID}_${EXT}" + DeleteRegValue HKCU "Software\Microsoft\Windows\CurrentVersion\ApplicationAssociationToasts" "Applications\${APP_EXE}_${EXT}" + DeleteRegValue HKCU "Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\${EXT}\OpenWithProgids" "${APP_ID}" + DeleteRegKey /IfEmpty HKCU "Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\${EXT}\OpenWithProgids" + DeleteRegKey /IfEmpty HKCU "Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\${EXT}\OpenWithList" + DeleteRegKey /IfEmpty HKCU "Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\${EXT}" + +!macroend + +!macro APP_TEARDOWN_Def APP_ID APP_NAME APP_EXE + + # Unregister file type + ClearErrors + DeleteRegKey /IfEmpty ShCtx "Software\Classes\${APP_ID}\shell" + ${IfNot} ${Errors} + DeleteRegKey ShCtx "Software\Classes\${APP_ID}\DefaultIcon" + ${EndIf} + + # Unregister "Open With" + DeleteRegKey ShCtx "Software\Classes\Applications\${APP_EXE}" + + DeleteRegKey ShCtx `Software\Classes\${APP_ID}` + DeleteRegKey ShCtx "Software\Classes\Applications\${APP_EXE}" + + # Attempt to clean up junk left behind by the Windows shell + DeleteRegValue HKCU "Software\Microsoft\Windows\CurrentVersion\Search\JumplistData" "$INSTDIR\${APP_EXE}" + DeleteRegValue HKCU "Software\Classes\Local Settings\Software\Microsoft\Windows\Shell\MuiCache" "$INSTDIR\${APP_EXE}.FriendlyAppName" + DeleteRegValue HKCU "Software\Classes\Local Settings\Software\Microsoft\Windows\Shell\MuiCache" "$INSTDIR\${APP_EXE}.ApplicationCompany" + DeleteRegValue HKCU "Software\Microsoft\Windows\ShellNoRoam\MUICache" "$INSTDIR\${APP_EXE}" ; WinXP + DeleteRegValue HKCU "Software\Microsoft\Windows NT\CurrentVersion\AppCompatFlags\Compatibility Assistant\Store" "$INSTDIR\${APP_EXE}" + +!macroend + +!macro APP_TEARDOWN APP_ID APP_NAME APP_EXE + + !insertmacro APP_TEARDOWN_Def `${APP_ID}` `${APP_NAME}` `${APP_EXE}` + +!macroend + +!macro APP_TEARDOWN_DEFAULT APP_ID APP_NAME APP_EXE + + !insertmacro APP_TEARDOWN_Def `${APP_ID}` `${APP_NAME}` `${APP_EXE}` + + # Unregister "Default Programs" + DeleteRegValue ShCtx "Software\RegisteredApplications" `${APP_NAME}` + DeleteRegKey ShCtx "Software\Classes\Applications\${APP_EXE}\Capabilities" + DeleteRegKey /IfEmpty ShCtx "Software\Classes\Applications\${APP_EXE}" + +!macroend + +; !defines for use with SHChangeNotify +!ifdef SHCNE_ASSOCCHANGED +!undef SHCNE_ASSOCCHANGED +!endif +!define SHCNE_ASSOCCHANGED 0x08000000 +!ifdef SHCNF_FLUSH +!undef SHCNF_FLUSH +!endif +!define SHCNF_FLUSH 0x1000 + + +# ensure this is called at the end of any section that changes shell keys +!macro NotifyShell_AssocChanged +; Using the system.dll plugin to call the SHChangeNotify Win32 API function so we +; can update the shell. + System::Call "shell32::SHChangeNotify(i,i,i,i) (${SHCNE_ASSOCCHANGED}, ${SHCNF_FLUSH}, 0, 0)" +!macroend + + +;------------------------------------------ +; Uninstall Previous install + +!macro RunUninstall exitcode uninstcommand + Push `${uninstcommand}` + Call RunUninstall + Pop ${exitcode} +!macroend + +; Checks that the uninstaller in the provided command exists and runs it. +Function RunUninstall + Exch $1 ; input uninstcommand + Push $2 ; Uninstaller + Push $3 ; Len + Push $4 ; uninstcommand + StrCpy $4 $1 ; make a copy of the command for later + StrCpy $3 "" + StrCpy $2 $1 1 ; take first char of string + StrCmp $2 '"' quoteloop stringloop + stringloop: ; get string length + StrCpy $2 $1 1 $3 ; get next char + IntOp $3 $3 + 1 ; index += 1 + StrCmp $2 "" +2 stringloop ; if empty exit loop + IntOp $3 $3 - 1 ; index -= 1 + Goto run + quoteloop: ; get string length with quotes removed + StrCmp $3 "" 0 +2 ; if index is set skip quote removal + StrCpy $1 $1 "" 1 ; Remove initial quote + IntOp $3 $3 + 1 ; index += 1 + StrCpy $2 $1 1 $3 ; get next char + StrCmp $2 "" +2 ; if empty exit loop + StrCmp $2 '"' 0 quoteloop ; if ending quote exit loop, else loop + run: + StrCpy $2 $1 $3 ; Path to uninstaller ; (copy string up to ending quote - if it exists) + StrCpy $1 161 ; ERROR_BAD_PATHNAME ; set exit code (it get's overwritten with uninstaller exit code if ExecWait call doesn't error) + GetFullPathName $3 "$2\.." ; $InstDir + IfFileExists "$2" 0 +4 + ExecWait $4 $1 ; The file exists, call the saved command + IntCmp $1 0 "" +2 +2 ; Don't delete the installer if it was aborted ; + Delete "$2" ; Delete the uninstaller + RMDir "$3" ; Try to delete $InstDir + Pop $4 + Pop $3 + Pop $2 + Exch $1 ; exitcode +FunctionEnd + +; The "" makes the section hidden. +Section "" UninstallPrevious + + ReadRegStr $0 HKCU "${UNINST_KEY}" "QuietUninstallString" + ${If} $0 == "" + ReadRegStr $0 HKCU "${UNINST_KEY}" "UninstallString" + ${EndIf} + + ${If} $0 != "" + !insertmacro RunUninstall $0 $0 + ${If} $0 <> 0 + MessageBox MB_YESNO|MB_ICONSTOP "Failed to uninstall, continue anyway?" /SD IDYES IDYES +2 + Abort + ${EndIf} + ${EndIf} + +SectionEnd + +;------------------------------------ +; include nice plugins + +; NScurl - curl in NSIS +; used for MSVS redist download +; extract to ../NSISPlugins/NScurl +; https://github.com/negrutiu/nsis-nscurl/releases/latest/download/NScurl.zip +!insertmacro CompileTimeIfFileExist "../NSISPlugins/NScurl/Plugins/" haveNScurl +!ifdef haveNScurl +!AddPluginDir /x86-unicode "../NSISPlugins/NScurl/Plugins/x86-unicode" +!AddPluginDir /x86-ansi "../NSISPlugins/NScurl/Plugins/x86-ansi" +!AddPluginDir /amd64-unicode "../NSISPlugins/NScurl/Plugins/amd64-unicode" +!endif + +;------------------------------------ + +; The stuff to install +Section "@Launcher_DisplayName@" + + SectionIn RO + + nsExec::Exec /TIMEOUT=2000 'TaskKill /IM @Launcher_APP_BINARY_NAME@.exe /F' + + SetOutPath $INSTDIR + + File "@Launcher_APP_BINARY_NAME@.exe" + File "@Launcher_APP_BINARY_NAME@_filelink.exe" + File "@Launcher_APP_BINARY_NAME@_updater.exe" + File "qt.conf" + File "qtlogging.ini" + File *.dll + File /r "iconengines" + File /r "imageformats" + File /r "jars" + File /r "platforms" + File /r "styles" + File /nonfatal /r "tls" + + ; Write the installation path into the registry + WriteRegStr HKCU Software\@Launcher_CommonName@ "InstallDir" "$INSTDIR" + + ; Write the URL Handler into registry for curseforge + WriteRegStr HKCU Software\Classes\curseforge "URL Protocol" "" + WriteRegStr HKCU Software\Classes\curseforge\shell\open\command "" '"$INSTDIR\@Launcher_APP_BINARY_NAME@.exe" "%1"' + + ; Write the URL Handler into registry for prismlauncher + WriteRegStr HKCU Software\Classes\@Launcher_APP_BINARY_NAME@ "URL Protocol" "" + WriteRegStr HKCU Software\Classes\@Launcher_APP_BINARY_NAME@\shell\open\command "" '"$INSTDIR\@Launcher_APP_BINARY_NAME@.exe" "%1"' + + ; Write the URL Handler into registry for prismlauncher import + WriteRegStr HKCU Software\Classes\prismlauncher "URL Protocol" "" + WriteRegStr HKCU Software\Classes\prismlauncher\shell\open\command "" '"$INSTDIR\@Launcher_APP_BINARY_NAME@.exe" "%1"' + + ; Write the uninstall keys for Windows + ; https://learn.microsoft.com/en-us/windows/win32/msi/uninstall-registry-key + ${GetParameters} $R0 + ${GetOptions} $R0 "/NoUninstaller" $R1 + ${If} ${Errors} + WriteRegStr HKCU "${UNINST_KEY}" "DisplayName" "@Launcher_DisplayName@" + WriteRegStr HKCU "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\@Launcher_APP_BINARY_NAME@.exe" + WriteRegStr HKCU "${UNINST_KEY}" "UninstallString" '"$INSTDIR\uninstall.exe" _?=$INSTDIR' + WriteRegStr HKCU "${UNINST_KEY}" "QuietUninstallString" '"$INSTDIR\uninstall.exe" /S _?=$INSTDIR' + WriteRegStr HKCU "${UNINST_KEY}" "InstallLocation" "$INSTDIR" + WriteRegStr HKCU "${UNINST_KEY}" "Publisher" "@Launcher_DisplayName@ Contributors" + WriteRegStr HKCU "${UNINST_KEY}" "Version" "@Launcher_VERSION_NAME4@" + WriteRegStr HKCU "${UNINST_KEY}" "DisplayVersion" "@Launcher_VERSION_NAME@" + WriteRegStr HKCU "${UNINST_KEY}" "VersionMajor" "@Launcher_VERSION_MAJOR@" + WriteRegStr HKCU "${UNINST_KEY}" "VersionMinor" "@Launcher_VERSION_MINOR@" + ${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2 + IntFmt $0 "0x%08X" $0 + WriteRegDWORD HKCU "${UNINST_KEY}" "EstimatedSize" "$0" + WriteRegDWORD HKCU "${UNINST_KEY}" "NoModify" 1 + WriteRegDWORD HKCU "${UNINST_KEY}" "NoRepair" 1 + WriteUninstaller "$INSTDIR\uninstall.exe" + ${EndIf} + +SectionEnd + +@Launcher_MSVC_Redist_NSIS_Section@ + +Section "Start Menu Shortcut" SM_SHORTCUTS + + CreateShortcut "$SMPROGRAMS\@Launcher_DisplayName@.lnk" "$INSTDIR\@Launcher_APP_BINARY_NAME@.exe" "" "$INSTDIR\@Launcher_APP_BINARY_NAME@.exe" 0 + +SectionEnd + +Section /o "Desktop Shortcut" DESKTOP_SHORTCUTS + + CreateShortcut "$DESKTOP\@Launcher_DisplayName@.lnk" "$INSTDIR\@Launcher_APP_BINARY_NAME@.exe" "" "$INSTDIR\@Launcher_APP_BINARY_NAME@.exe" 0 + +SectionEnd + + +!define APPID "@Launcher_CommonName@.App" +!define APPEXE "@Launcher_APP_BINARY_NAME@.exe" +!define APPICON "$INSTDIR\${APPEXE},0" +!define APPDESCRIPTION "@Launcher_DisplayName@" +!define APPNAME "@Launcher_DisplayName@" +!define APPCMDTEXT "@Launcher_DisplayName@" + +Section /o "Shell Association (Open-With dialog)" SHELL_ASSOC + + !insertmacro APP_SETUP `${APPDESCRIPTION}` `${APPICON}` `${APPID}` `${APPCMDTEXT}` `${APPEXE}` `${APPCMDTEXT}` '$INSTDIR\${APPEXE} -I "%1"' + + !insertmacro APP_ASSOCIATE_DEFAULT ".mrpack" `${APPID}` `${APPEXE}` true + !insertmacro APP_ASSOCIATE ".zip" `${APPID}` `${APPEXE}` false + + !insertmacro NotifyShell_AssocChanged +SectionEnd + + +;-------------------------------- + +; Uninstaller + +Section "Uninstall" + + nsExec::Exec /TIMEOUT=2000 'TaskKill /IM @Launcher_APP_BINARY_NAME@.exe /F' + + DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\@Launcher_CommonName@" + DeleteRegKey HKCU SOFTWARE\@Launcher_CommonName@ + + Delete $INSTDIR\@Launcher_APP_BINARY_NAME@.exe + Delete $INSTDIR\@Launcher_APP_BINARY_NAME@_filelink.exe + Delete $INSTDIR\@Launcher_APP_BINARY_NAME@_updater.exe + Delete $INSTDIR\qt.conf + Delete $INSTDIR\*.dll + + Delete $INSTDIR\uninstall.exe + + RMDir /r $INSTDIR\iconengines + RMDir /r $INSTDIR\imageformats + RMDir /r $INSTDIR\jars + RMDir /r $INSTDIR\platforms + RMDir /r $INSTDIR\styles + RMDir /r $INSTDIR\tls + + Delete "$SMPROGRAMS\@Launcher_DisplayName@.lnk" + Delete "$DESKTOP\@Launcher_DisplayName@.lnk" + + RMDir "$INSTDIR" + +SectionEnd + +Section -un.ShellAssoc + + !insertmacro APP_TEARDOWN_DEFAULT `${APPID}` `${APPNAME}` `${APPEXE}` + + !insertmacro APP_UNASSOCIATE ".zip" `${APPID}` `${APPEXE}` + !insertmacro APP_UNASSOCIATE ".mrpack" `${APPID}` `${APPEXE}` + + !insertmacro NotifyShell_AssocChanged +SectionEnd + +;-------------------------------- + +; Extra command line parameters + +Function .onInit +${GetParameters} $R0 +${GetOptions} $R0 "/NoShortcuts" $R1 +${IfNot} ${Errors} + !insertmacro UnselectSection ${SM_SHORTCUTS} + !insertmacro UnselectSection ${DESKTOP_SHORTCUTS} +${EndIf} +FunctionEnd diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..0a74c6d --- /dev/null +++ b/renovate.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended" + ], + "labels": [ + "area: CI", + "complexity: low", + "priority: low", + "type: robot", + "changelog:omit" + ] +} diff --git a/scripts/build-all-platforms.sh b/scripts/build-all-platforms.sh new file mode 100755 index 0000000..b167d93 --- /dev/null +++ b/scripts/build-all-platforms.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# Master build script for Racked.ru PrismLauncher - All Platforms +# This script orchestrates the build process for Windows, macOS, and Linux + +set -e + +echo "==============================================" +echo "Racked.ru PrismLauncher - Multi-Platform Build" +echo "==============================================" +echo "" + +# Detect OS +case "$(uname -s)" in + Linux*) PLATFORM="linux";; + Darwin*) PLATFORM="macos";; + CYGWIN*|MINGW*|MSYS*|Windows_NT*) PLATFORM="windows";; + *) echo "Unsupported platform!"; exit 1;; +esac + +echo "Detected platform: $PLATFORM" +echo "" + +# Build based on platform +case "$PLATFORM" in + linux) + echo "Building for Linux..." + bash scripts/build-linux-portable.sh + ;; + macos) + echo "Building for macOS..." + bash scripts/build-macos-portable.sh + ;; + windows) + echo "Building for Windows..." + scripts/build-windows-portable.bat + ;; +esac + +if [ $? -ne 0 ]; then + echo "" + echo "Build failed!" + exit 1 +fi + +echo "" +echo "==============================================" +echo "All builds completed successfully!" +echo "Check the release/ directory for output" +echo "==============================================" + +exit 0 diff --git a/scripts/build-linux-portable.sh b/scripts/build-linux-portable.sh new file mode 100755 index 0000000..dacb73b --- /dev/null +++ b/scripts/build-linux-portable.sh @@ -0,0 +1,100 @@ +#!/bin/bash +# Build script for Linux portable version of Racked.ru PrismLauncher +# This creates a portable build that can be run from USB drives + +set -e + +# Configuration +BUILD_TYPE="Release" +BUILD_DIR="build-linux-portable" +INSTALL_DIR="install-linux-portable" +RELEASE_DIR="release/Racked.ru-PrismLauncher-Linux-Portable" + +# Detect Qt6 path +if [ -d "/usr/lib/qt6" ]; then + QT_PATH="/usr/lib/qt6" +elif [ -d "/usr/lib64/qt6" ]; then + QT_PATH="/usr/lib64/qt6" +elif [ -d "/opt/qt6" ]; then + QT_PATH="/opt/qt6" +else + echo "Qt6 not found! Please install Qt6 development packages." + exit 1 +fi + +# Clean previous builds +rm -rf "$BUILD_DIR" "$INSTALL_DIR" "$RELEASE_DIR" + +mkdir -p "$BUILD_DIR" +cd "$BUILD_DIR" + +echo "========================================" +echo "Building Racked.ru PrismLauncher (Linux Portable)" +echo "========================================" + +# Configure with CMake +cmake .. \ + -G "Unix Makefiles" \ + -DCMAKE_BUILD_TYPE="$BUILD_TYPE" \ + -DCMAKE_INSTALL_PREFIX="../$INSTALL_DIR" \ + -DCMAKE_PREFIX_PATH="$QT_PATH" \ + -DLauncher_QT_VERSION_MAJOR=6 \ + -DENABLE_LTO=ON \ + -DUSE_SYSTEM_LIBS=OFF \ + -DENABLE_GLFW=OFF \ + -DENABLE_OPENAL=OFF + +if [ $? -ne 0 ]; then + echo "CMake configuration failed!" + exit 1 +fi + +# Build +make -j$(nproc) + +if [ $? -ne 0 ]; then + echo "Build failed!" + exit 1 +fi + +# Install to staging directory +make install + +if [ $? -ne 0 ]; then + echo "Installation failed!" + exit 1 +fi + +cd .. + +# Create portable package +echo "Creating portable package..." +mkdir -p "$RELEASE_DIR" + +# Copy built files +cp -r "$INSTALL_DIR"/* "$RELEASE_DIR"/ + +# Copy portable.txt to enable portable mode +cp launcher/portable.txt "$RELEASE_DIR"/ + +# Create launcher script +cat > "$RELEASE_DIR/run.sh" <<'EOF' +#!/bin/bash +# Racked.ru PrismLauncher portable launcher +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export LD_LIBRARY_PATH="$SCRIPT_DIR/lib:$LD_LIBRARY_PATH" +export QT_QPA_PLATFORM_PLUGIN_PATH="$SCRIPT_DIR/plugins" +exec "$SCRIPT_DIR/bin/prismlauncher" "$@" +EOF + +chmod +x "$RELEASE_DIR/run.sh" + +echo "========================================" +echo "Build complete!" +echo "Output: $RELEASE_DIR" +echo "========================================" +echo "To create a release archive:" +echo " cd $RELEASE_DIR && tar czf ../racked-prismlauncher-linux-portable.tar.gz ." +echo "========================================" + +exit 0 diff --git a/scripts/build-macos-portable.sh b/scripts/build-macos-portable.sh new file mode 100755 index 0000000..4a56441 --- /dev/null +++ b/scripts/build-macos-portable.sh @@ -0,0 +1,89 @@ +#!/bin/bash +# Build script for macOS portable version of Racked.ru PrismLauncher +# This creates a portable .app bundle that can be run from USB drives + +set -e + +# Configuration +BUILD_TYPE="Release" +BUILD_DIR="build-macos-portable" +INSTALL_DIR="install-macos-portable" +RELEASE_DIR="release/Racked.ru-PrismLauncher-macOS-Portable" + +# Clean previous builds +rm -rf "$BUILD_DIR" "$INSTALL_DIR" "$RELEASE_DIR" + +mkdir -p "$BUILD_DIR" +cd "$BUILD_DIR" + +echo "========================================" +echo "Building Racked.ru PrismLauncher (macOS Portable)" +echo "========================================" + +# Configure with CMake +cmake .. \ + -G "Unix Makefiles" \ + -DCMAKE_BUILD_TYPE="$BUILD_TYPE" \ + -DCMAKE_INSTALL_PREFIX="../$INSTALL_DIR" \ + -DLauncher_QT_VERSION_MAJOR=6 \ + -DENABLE_LTO=ON \ + -DUSE_SYSTEM_LIBS=OFF \ + -DENABLE_GLFW=OFF \ + -DENABLE_OPENAL=OFF + +if [ $? -ne 0 ]; then + echo "CMake configuration failed!" + exit 1 +fi + +# Build +make -j$(sysctl -n hw.ncpu) + +if [ $? -ne 0 ]; then + echo "Build failed!" + exit 1 +fi + +# Install to staging directory +make install + +if [ $? -ne 0 ]; then + echo "Installation failed!" + exit 1 +fi + +cd .. + +# Create portable package +echo "Creating portable package..." +mkdir -p "$RELEASE_DIR" + +# Copy the .app bundle +cp -r "$INSTALL_DIR/PrismLauncher.app" "$RELEASE_DIR/" + +# Copy portable.txt inside the app bundle +cp launcher/portable.txt "$RELEASE_DIR/PrismLauncher.app/Contents/Resources/" + +# Create a simple launcher script +cat > "$RELEASE_DIR/run.sh" <<'EOF' +#!/bin/bash +# Racked.ru PrismLauncher portable launcher +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +open "$SCRIPT_DIR/PrismLauncher.app" +EOF + +chmod +x "$RELEASE_DIR/run.sh" + +echo "========================================" +echo "Build complete!" +echo "Output: $RELEASE_DIR" +echo "========================================" +echo "To create a release archive:" +echo " cd $RELEASE_DIR && tar czf ../racked-prismlauncher-macos-portable.tar.gz PrismLauncher.app run.sh" +echo "========================================" +echo "" +echo "Note: For distribution, you may want to codesign the app:" +echo " codesign --deep --force --sign \"Your Certificate\" $RELEASE_DIR/PrismLauncher.app" +echo "========================================" + +exit 0 diff --git a/scripts/build-windows-portable.bat b/scripts/build-windows-portable.bat new file mode 100644 index 0000000..aff6246 --- /dev/null +++ b/scripts/build-windows-portable.bat @@ -0,0 +1,113 @@ +@echo off +REM Build script for Windows portable version of Racked.ru PrismLauncher +REM This creates a portable build that can be run from USB drives + +setlocal EnableDelayedExpansion + +REM Configuration +set BUILD_TYPE=Release +set BUILD_DIR=build-windows-portable +set INSTALL_DIR=install-windows-portable +set QT_PATH=C:\Qt\6.5.3\msvc2019_64 + +REM Clean previous builds +if exist "%BUILD_DIR%" rmdir /s /q "%BUILD_DIR%" +if exist "%INSTALL_DIR%" rmdir /s /q "%INSTALL_DIR%" + +mkdir "%BUILD_DIR%" +cd "%BUILD_DIR%" + +echo ======================================== +echo Building Racked.ru PrismLauncher (Windows Portable) +echo ======================================== + +REM Configure with CMake +cmake .. ^ + -G "Visual Studio 17 2022" ^ + -A x64 ^ + -DCMAKE_BUILD_TYPE=%BUILD_TYPE% ^ + -DCMAKE_INSTALL_PREFIX="../%INSTALL_DIR%" ^ + -DCMAKE_PREFIX_PATH="%QT_PATH%" ^ + -DLauncher_QT_VERSION_MAJOR=6 ^ + -DENABLE_LTO=ON ^ + -DUSE_SYSTEM_LIBS=OFF + +if errorlevel 1 ( + echo CMake configuration failed! + exit /b 1 +) + +REM Build +cmake --build . --config %BUILD_TYPE% --parallel %NUMBER_OF_PROCESSORS% + +if errorlevel 1 ( + echo Build failed! + exit /b 1 +) + +REM Install to staging directory +cmake --install . --config %BUILD_TYPE% + +if errorlevel 1 ( + echo Installation failed! + exit /b 1 +) + +cd .. + +REM Create portable package +echo Creating portable package... +set RELEASE_DIR=release\Racked.ru-PrismLauncher-Windows-Portable + +if exist "%RELEASE_DIR%" rmdir /s /q "%RELEASE_DIR%" +mkdir "%RELEASE_DIR%" + +REM Copy built files +xcopy /E /I /Y "%INSTALL_DIR%\*" "%RELEASE_DIR%\" + +REM Copy portable.txt to enable portable mode +copy launcher\portable.txt "%RELEASE_DIR%\" + +REM Copy Qt6 DLLs and dependencies +copy "C:\Qt\6.5.3\msvc2019_64\bin\Qt6Core.dll" "%RELEASE_DIR%\" +copy "C:\Qt\6.5.3\msvc2019_64\bin\Qt6Gui.dll" "%RELEASE_DIR%\" +copy "C:\Qt\6.5.3\msvc2019_64\bin\Qt6Widgets.dll" "%RELEASE_DIR%\" +copy "C:\Qt\6.5.3\msvc2019_64\bin\Qt6Network.dll" "%RELEASE_DIR%\" +copy "C:\Qt\6.5.3\msvc2019_64\bin\Qt6Svg.dll" "%RELEASE_DIR%\" +copy "C:\Qt\6.5.3\msvc2019_64\bin\Qt6Xml.dll" "%RELEASE_DIR%\" +copy "C:\Qt\6.5.3\msvc2019_64\bin\Qt6OpenGL.dll" "%RELEASE_DIR%\" +copy "C:\Qt\6.5.3\msvc2019_64\bin\Qt6Core5Compat.dll" "%RELEASE_DIR%\" +copy "C:\Qt\6.5.3\msvc2019_64\bin\Qt6NetworkAuth.dll" "%RELEASE_DIR%\" + +REM Copy Qt platforms +mkdir "%RELEASE_DIR%\platforms" +copy "C:\Qt\6.5.3\msvc2019_64\plugins\platforms\qwindows.dll" "%RELEASE_DIR%\platforms\" + +mkdir "%RELEASE_DIR%\iconengines" +copy "C:\Qt\6.5.3\msvc2019_64\plugins\iconengines\*.*" "%RELEASE_DIR%\iconengines\" + +mkdir "%RELEASE_DIR%\imageformats" +copy "C:\Qt\6.5.3\msvc2019_64\plugins\imageformats\*.*" "%RELEASE_DIR%\imageformats\" + +mkdir "%RELEASE_DIR%\styles" +copy "C:\Qt\6.5.3\msvc2019_64\plugins\styles\*.*" "%RELEASE_DIR%\styles\" + +REM Copy required runtime DLLs +where msvcp140.dll >nul 2>&1 +if not errorlevel 1 ( + for /f "tokens=*" %%i in ('where msvcp140.dll') do copy "%%i" "%RELEASE_DIR%\" +) + +where vcruntime140.dll >nul 2>&1 +if not errorlevel 1 ( + for /f "tokens=*" %%i in ('where vcruntime140.dll') do copy "%%i" "%RELEASE_DIR%\" +) + +echo ======================================== +echo Build complete! +echo Output: %RELEASE_DIR% +echo ======================================== +echo To create a release archive, zip the %RELEASE_DIR% folder +echo ======================================== + +exit /b 0 diff --git a/scripts/compress_images.sh b/scripts/compress_images.sh new file mode 100755 index 0000000..136059f --- /dev/null +++ b/scripts/compress_images.sh @@ -0,0 +1,9 @@ +#!/bin/sh + +## If current working dirctory is ./scripts, ask to invoke from one directory up +if [ ! -d "scripts" ]; then + echo "Please run this script from the root directory of the project" + exit 1 +fi + +find . -type f -name '*.png' -not -path '*/libraries/*' -exec oxipng --opt max --strip all --alpha --interlace 0 {} \; diff --git a/scripts/create-release.sh b/scripts/create-release.sh new file mode 100755 index 0000000..d81f98e --- /dev/null +++ b/scripts/create-release.sh @@ -0,0 +1,120 @@ +#!/bin/bash +# Release creation script for Racked.ru PrismLauncher +# Creates versioned releases for all platforms + +set -e + +# Check if version is provided +if [ -z "$1" ]; then + echo "Usage: $0 " + echo "Example: $0 1.0.0" + echo "" + echo "Version format should be: MAJOR.MINOR.PATCH" + exit 1 +fi + +VERSION="$1" +DATE=$(date +%Y%m%d) +BUILD_DIR="release" + +echo "==============================================" +echo "Creating Racked.ru PrismLauncher Release v$VERSION" +echo "Date: $DATE" +echo "==============================================" +echo "" + +# Verify we're in the right directory +if [ ! -f "launcher/CMakeLists.txt" ]; then + echo "Error: Please run this script from the project root" + exit 1 +fi + +# Check if release directory exists +if [ ! -d "$BUILD_DIR" ]; then + echo "Error: No release directory found. Please build first using:" + echo " bash scripts/build-all-platforms.sh" + exit 1 +fi + +# Create archives for each platform +echo "Creating release archives..." + +# Windows +if [ -d "$BUILD_DIR/Racked.ru-PrismLauncher-Windows-Portable" ]; then + cd "$BUILD_DIR/Racked.ru-PrismLauncher-Windows-Portable" + if command -v zip &> /dev/null; then + zip -r "../racked-prismlauncher-${VERSION}-windows-portable.zip" . + else + # Fallback to tar if zip not available + tar czf "../racked-prismlauncher-${VERSION}-windows-portable.tar.gz" . + fi + cd ../.. + echo " ✓ Windows package created" +fi + +# Linux +if [ -d "$BUILD_DIR/Racked.ru-PrismLauncher-Linux-Portable" ]; then + cd "$BUILD_DIR/Racked.ru-PrismLauncher-Linux-Portable" + tar czf "../racked-prismlauncher-${VERSION}-linux-portable.tar.gz" . + cd ../.. + echo " ✓ Linux package created" +fi + +# macOS +if [ -d "$BUILD_DIR/Racked.ru-PrismLauncher-macOS-Portable" ]; then + cd "$BUILD_DIR/Racked.ru-PrismLauncher-macOS-Portable" + tar czf "../racked-prismlauncher-${VERSION}-macos-portable.tar.gz" PrismLauncher.app run.sh + cd ../.. + echo " ✓ macOS package created" +fi + +echo "" +echo "==============================================" +echo "Release packages created in $BUILD_DIR/:" +echo "==============================================" +ls -lh "$BUILD_DIR"/*.tar.gz "$BUILD_DIR"/*.zip 2>/dev/null || echo "No archives found" +echo "" +echo "Next steps:" +echo "1. Test the packages on each platform" +echo "2. Create a GitHub release: https://github.com/YOUR_USERNAME/racked-prismlauncher/releases/new" +echo "3. Tag: v$VERSION" +echo "4. Upload the archives" +echo "5. Update CHANGELOG.md" +echo "==============================================" + +# Create a simple changelog entry +cat > "release-notes-v${VERSION}.md" < trace.json +""" + +import json +import os +import optparse +import re +import sys + + +class Target: + """Represents a single line read for a .ninja_log file. Start and end times + are milliseconds.""" + def __init__(self, start, end): + self.start = int(start) + self.end = int(end) + self.targets = [] + + +def read_targets(log, show_all): + """Reads all targets from .ninja_log file |log_file|, sorted by start + time""" + header = log.readline() + m = re.search(r'^# ninja log v(\d+)\n$', header) + assert m, "unrecognized ninja log version %r" % header + version = int(m.group(1)) + assert 5 <= version <= 7, "unsupported ninja log version %d" % version + if version >= 6: + # Skip header line + next(log) + + targets = {} + last_end_seen = 0 + for line in log: + if line.startswith('#'): + continue + start, end, _, name, cmdhash = line.strip().split('\t') # Ignore restat. + if not show_all and int(end) < last_end_seen: + # An earlier time stamp means that this step is the first in a new + # build, possibly an incremental build. Throw away the previous data + # so that this new build will be displayed independently. + targets = {} + last_end_seen = int(end) + targets.setdefault(cmdhash, Target(start, end)).targets.append(name) + return sorted(targets.values(), key=lambda job: job.end, reverse=True) + + +class Threads: + """Tries to reconstruct the parallelism from a .ninja_log""" + def __init__(self): + self.workers = [] # Maps thread id to time that thread is occupied for. + + def alloc(self, target): + """Places target in an available thread, or adds a new thread.""" + for worker in range(len(self.workers)): + if self.workers[worker] >= target.end: + self.workers[worker] = target.start + return worker + self.workers.append(target.start) + return len(self.workers) - 1 + + +def read_events(trace, options): + """Reads all events from time-trace json file |trace|.""" + trace_data = json.load(trace) + + def include_event(event, options): + """Only include events if they are complete events, are longer than + granularity, and are not totals.""" + return ((event['ph'] == 'X') and + (event['dur'] >= options['granularity']) and + (not event['name'].startswith('Total'))) + + return [x for x in trace_data['traceEvents'] if include_event(x, options)] + + +def trace_to_dicts(target, trace, options, pid, tid): + """Read a file-like object |trace| containing -ftime-trace data and yields + about:tracing dict per eligible event in that log.""" + ninja_time = (target.end - target.start) * 1000 + for event in read_events(trace, options): + # Check if any event duration is greater than the duration from ninja. + if event['dur'] > ninja_time: + print("Inconsistent timing found (clang time > ninja time). Please" + " ensure that timings are from consistent builds.") + sys.exit(1) + + # Set tid and pid from ninja log. + event['pid'] = pid + event['tid'] = tid + + # Offset trace time stamp by ninja start time. + event['ts'] += (target.start * 1000) + + yield event + + +def embed_time_trace(ninja_log_dir, target, pid, tid, options): + """Produce time trace output for the specified ninja target. Expects + time-trace file to be in .json file named based on .o file.""" + for t in target.targets: + o_path = os.path.join(ninja_log_dir, t) + json_trace_path = os.path.splitext(o_path)[0] + '.json' + try: + with open(json_trace_path, 'r') as trace: + for time_trace_event in trace_to_dicts(target, trace, options, + pid, tid): + yield time_trace_event + except IOError: + pass + + +def log_to_dicts(log, pid, options): + """Reads a file-like object |log| containing a .ninja_log, and yields one + about:tracing dict per command found in the log.""" + threads = Threads() + for target in read_targets(log, options['showall']): + tid = threads.alloc(target) + + yield { + 'name': '%0s' % ', '.join(target.targets), 'cat': 'targets', + 'ph': 'X', 'ts': (target.start * 1000), + 'dur': ((target.end - target.start) * 1000), + 'pid': pid, 'tid': tid, 'args': {}, + } + if options.get('embed_time_trace', False): + # Add time-trace information into the ninja trace. + try: + ninja_log_dir = os.path.dirname(log.name) + except AttributeError: + continue + for time_trace in embed_time_trace(ninja_log_dir, target, pid, + tid, options): + yield time_trace + + +def main(argv): + usage = __doc__ + parser = optparse.OptionParser(usage) + parser.add_option('-a', '--showall', action='store_true', dest='showall', + default=False, + help='report on last build step for all outputs. Default ' + 'is to report just on the last (possibly incremental) ' + 'build') + parser.add_option('-g', '--granularity', type='int', default=50000, + dest='granularity', + help='minimum length time-trace event to embed in ' + 'microseconds. Default: %default') + parser.add_option('-e', '--embed-time-trace', action='store_true', + default=False, dest='embed_time_trace', + help='embed clang -ftime-trace json file found adjacent ' + 'to a target file') + (options, args) = parser.parse_args() + + if len(args) == 0: + print('Must specify at least one .ninja_log file') + parser.print_help() + return 1 + + entries = [] + for pid, log_file in enumerate(args): + with open(log_file, 'r') as log: + entries += list(log_to_dicts(log, pid, vars(options))) + json.dump(entries, sys.stdout) + + +if __name__ == '__main__': + sys.exit(main(sys.argv[1:])) diff --git a/vcpkg-configuration.json b/vcpkg-configuration.json new file mode 100644 index 0000000..2081163 --- /dev/null +++ b/vcpkg-configuration.json @@ -0,0 +1,20 @@ +{ + "default-registry": { + "kind": "git", + "baseline": "2d6a6cf3ac9a7cc93942c3d289a2f9c661a6f4a7", + "repository": "https://github.com/microsoft/vcpkg" + }, + "registries": [ + { + "kind": "artifact", + "location": "https://github.com/microsoft/vcpkg-ce-catalog/archive/refs/heads/main.zip", + "name": "microsoft" + } + ], + "overlay-ports": [ + "./cmake/vcpkg-ports" + ], + "overlay-triplets": [ + "./cmake/vcpkg-triplets" + ] +} diff --git a/vcpkg.json b/vcpkg.json new file mode 100644 index 0000000..5fd336f --- /dev/null +++ b/vcpkg.json @@ -0,0 +1,32 @@ +{ + "dependencies": [ + { + "name": "ecm", + "host": true + }, + { + "name": "libqrencode", + "default-features": false + }, + { + "name": "pkgconf", + "host": true + }, + + "cmark", + { + "name": "libarchive", + "default-features": false, + "features": [ + "bzip2", + "lz4", + "lzma", + "lzo", + "zstd" + ] + }, + "tomlplusplus", + "zlib", + "vulkan-headers" + ] +}