diff --git a/.github/scripts/detect-component-changes.sh b/.github/scripts/detect-component-changes.sh index 845287e9..b1ebca2a 100755 --- a/.github/scripts/detect-component-changes.sh +++ b/.github/scripts/detect-component-changes.sh @@ -80,6 +80,8 @@ changed_files="$(git diff --name-only --no-renames "$base_sha" "$head_sha")" frontend=false backend=false +launcher=false +launcher_build=false while IFS= read -r path; do [[ -z "$path" ]] && continue @@ -91,6 +93,13 @@ while IFS= read -r path; do backend/*) backend=true ;; + launcher/*) + launcher=true + launcher_build=true + ;; + .github/workflows/launcher-release.yml) + launcher=true + ;; esac done <<< "$changed_files" @@ -98,6 +107,8 @@ done <<< "$changed_files" echo "Change detection range: $range_label" echo "Frontend changed: $frontend" echo "Backend changed: $backend" + echo "Launcher changed: $launcher" + echo "Launcher build needed: $launcher_build" echo "Changed files:" if [[ -n "$changed_files" ]]; then printf '%s\n' "$changed_files" @@ -109,4 +120,6 @@ done <<< "$changed_files" { echo "frontend=$frontend" echo "backend=$backend" + echo "launcher=$launcher" + echo "launcher_build=$launcher_build" } >> "${GITHUB_OUTPUT:-/dev/stdout}" diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 481ded71..6c4a88a9 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -25,6 +25,12 @@ jobs: permissions: contents: 'read' id-token: 'write' + outputs: + launcher_build: ${{ steps.filter.outputs.launcher_build }} + launcher_site_base_url: ${{ steps.launcher_metadata.outputs.site_base_url }} + launcher_api_base_url: ${{ steps.launcher_metadata.outputs.api_base_url }} + launcher_version: ${{ steps.launcher_metadata.outputs.version }} + launcher_artifact_prefix: ${{ steps.launcher_metadata.outputs.artifact_prefix }} steps: - uses: actions/checkout@v4 @@ -615,6 +621,21 @@ jobs: fi fi + - name: Export launcher build metadata + id: launcher_metadata + run: | + short_sha="${GITHUB_SHA:0:7}" + suffix="${TAG:-preview}-$short_sha" + suffix="$(printf '%s' "$suffix" | sed 's/[^A-Za-z0-9.-]/-/g' | sed 's/--*/-/g' | cut -c 1-50 | sed 's/^[.-]*//;s/[.-]*$//')" + if [ -z "$suffix" ]; then + suffix="preview-$short_sha" + fi + + echo "site_base_url=$FINAL_FRONTEND_URL" >> "$GITHUB_OUTPUT" + echo "api_base_url=$API_URL" >> "$GITHUB_OUTPUT" + echo "version=0.1.0-$suffix" >> "$GITHUB_OUTPUT" + echo "artifact_prefix=launcher-$suffix" >> "$GITHUB_OUTPUT" + - name: Notify Status Bot of Backend Production Deploy if: env.ENV_TYPE == 'prod' && (steps.update_backend_self_awareness.outputs.revision != '' || steps.deploy_backend.outputs.revision != '') env: @@ -694,6 +715,154 @@ jobs: echo "---" >> $GITHUB_STEP_SUMMARY echo "*View deployment details in [Google Cloud Console](https://console.cloud.google.com/run?project=${{ env.PROJECT_ID }}).* " >> $GITHUB_STEP_SUMMARY + package-launcher: + name: Package Staging Launcher (${{ matrix.name }}) + needs: deploy + if: github.repository == 'Modtale/modtale' && needs.deploy.outputs.launcher_build == 'true' + runs-on: ${{ matrix.os }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - name: Linux Packages + os: ubuntu-latest + artifact: linux + - name: Windows Installer + os: windows-latest + artifact: windows + - name: macOS DMG + os: macos-latest + artifact: macos + defaults: + run: + working-directory: launcher + shell: bash + env: + LAUNCHER_SITE_BASE_URL: ${{ needs.deploy.outputs.launcher_site_base_url }} + LAUNCHER_API_BASE_URL: ${{ needs.deploy.outputs.launcher_api_base_url }} + LAUNCHER_VERSION: ${{ needs.deploy.outputs.launcher_version }} + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Validate launcher target URLs + run: | + : "${LAUNCHER_SITE_BASE_URL:?Launcher site URL was not produced by the deploy job.}" + : "${LAUNCHER_API_BASE_URL:?Launcher API URL was not produced by the deploy job.}" + : "${LAUNCHER_VERSION:?Launcher version was not produced by the deploy job.}" + + echo "Launcher site URL: $LAUNCHER_SITE_BASE_URL" + echo "Launcher API URL: $LAUNCHER_API_BASE_URL" + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Set up Linux packaging tooling + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y \ + binutils \ + flatpak \ + rpm \ + tar \ + xz-utils \ + zstd + sudo apt-get install -y libfuse2 || sudo apt-get install -y libfuse2t64 || true + sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo + sudo flatpak install -y flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08 + curl -L \ + -o "$RUNNER_TEMP/appimagetool" \ + https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage + chmod +x "$RUNNER_TEMP/appimagetool" + echo "APPIMAGETOOL=$RUNNER_TEMP/appimagetool" >> "$GITHUB_ENV" + echo "APPIMAGE_EXTRACT_AND_RUN=1" >> "$GITHUB_ENV" + + - name: Set up Windows installer tooling + if: runner.os == 'Windows' + shell: pwsh + run: | + choco install wixtoolset -y --no-progress + $wix = Get-ChildItem "C:\Program Files (x86)" -Directory -Filter "WiX Toolset*" | + Sort-Object Name -Descending | + Select-Object -First 1 + if ($null -eq $wix) { + throw "WiX Toolset was not installed." + } + "$($wix.FullName)\bin" | Out-File -FilePath $env:GITHUB_PATH -Append + + - name: Ensure Gradle wrapper is executable + run: chmod +x gradlew + + - name: Build staging launcher package + run: | + package_task="packageAll" + if [ "$RUNNER_OS" = "Linux" ]; then + package_task="packageLinuxAll" + fi + + ./gradlew clean "$package_task" \ + -PlauncherVersion="$LAUNCHER_VERSION" \ + -PmodtaleSiteBaseUrl="$LAUNCHER_SITE_BASE_URL" \ + -PmodtaleApiBaseUrl="$LAUNCHER_API_BASE_URL" + + - name: Upload Linux AppImage package + if: runner.os == 'Linux' + uses: actions/upload-artifact@v4 + with: + name: ${{ needs.deploy.outputs.launcher_artifact_prefix }}-linux-appimage + path: launcher/build/distributions/*.AppImage + if-no-files-found: error + + - name: Upload Linux Debian package + if: runner.os == 'Linux' + uses: actions/upload-artifact@v4 + with: + name: ${{ needs.deploy.outputs.launcher_artifact_prefix }}-linux-deb + path: launcher/build/distributions/*.deb + if-no-files-found: error + + - name: Upload Linux RPM package + if: runner.os == 'Linux' + uses: actions/upload-artifact@v4 + with: + name: ${{ needs.deploy.outputs.launcher_artifact_prefix }}-linux-rpm + path: launcher/build/distributions/*.rpm + if-no-files-found: error + + - name: Upload Linux Flatpak package + if: runner.os == 'Linux' + uses: actions/upload-artifact@v4 + with: + name: ${{ needs.deploy.outputs.launcher_artifact_prefix }}-linux-flatpak + path: launcher/build/distributions/*.flatpak + if-no-files-found: error + + - name: Upload Linux pacman package + if: runner.os == 'Linux' + uses: actions/upload-artifact@v4 + with: + name: ${{ needs.deploy.outputs.launcher_artifact_prefix }}-linux-pacman + path: launcher/build/distributions/*.pkg.tar.zst + if-no-files-found: error + + - name: Upload staging launcher package + if: runner.os != 'Linux' + uses: actions/upload-artifact@v4 + with: + name: ${{ needs.deploy.outputs.launcher_artifact_prefix }}-${{ matrix.artifact }} + path: launcher/build/distributions/* + if-no-files-found: error + cleanup-branch-preview: name: Clean Up Deleted Branch Preview if: github.repository == 'Modtale/modtale' && github.event.deleted == true && startsWith(github.ref, 'refs/heads/') && github.ref_name != 'main' && github.ref_name != 'develop' diff --git a/.github/workflows/launcher-release.yml b/.github/workflows/launcher-release.yml new file mode 100644 index 00000000..a00616e5 --- /dev/null +++ b/.github/workflows/launcher-release.yml @@ -0,0 +1,213 @@ +name: Launcher Release + +on: + push: + tags: + - 'v*' + - 'launcher-v*' + workflow_dispatch: + inputs: + version: + description: Launcher version to package, for example 0.2.0 + required: true + type: string + +permissions: + contents: write + +jobs: + metadata: + name: Resolve Release Metadata + if: github.repository == 'Modtale/modtale' + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.version.outputs.tag }} + version: ${{ steps.version.outputs.version }} + steps: + - name: Resolve launcher version + id: version + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + raw_version="${{ inputs.version }}" + version="${raw_version#launcher-v}" + version="${version#v}" + tag="launcher-v$version" + else + tag="${GITHUB_REF_NAME}" + version="${tag#launcher-v}" + version="${version#v}" + fi + + if ! [[ "$version" =~ ^[0-9]+(\.[0-9]+){0,2}([.-][A-Za-z0-9]+([.-][A-Za-z0-9]+)*)?$ ]]; then + echo "::error::Launcher version '$version' is not a valid package version." + exit 1 + fi + + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + + package: + name: Package Launcher (${{ matrix.name }}) + needs: metadata + if: github.repository == 'Modtale/modtale' + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - name: Linux Packages + os: ubuntu-latest + artifact: launcher-linux + - name: Windows Installer + os: windows-latest + artifact: launcher-windows + - name: macOS DMG + os: macos-latest + artifact: launcher-macos + defaults: + run: + working-directory: launcher + shell: bash + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Set up Linux packaging tooling + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y \ + binutils \ + flatpak \ + rpm \ + tar \ + xz-utils \ + zstd + sudo apt-get install -y libfuse2 || sudo apt-get install -y libfuse2t64 || true + sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo + sudo flatpak install -y flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08 + curl -L \ + -o "$RUNNER_TEMP/appimagetool" \ + https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage + chmod +x "$RUNNER_TEMP/appimagetool" + echo "APPIMAGETOOL=$RUNNER_TEMP/appimagetool" >> "$GITHUB_ENV" + echo "APPIMAGE_EXTRACT_AND_RUN=1" >> "$GITHUB_ENV" + + - name: Set up Windows installer tooling + if: runner.os == 'Windows' + shell: pwsh + run: | + choco install wixtoolset -y --no-progress + $wix = Get-ChildItem "C:\Program Files (x86)" -Directory -Filter "WiX Toolset*" | + Sort-Object Name -Descending | + Select-Object -First 1 + if ($null -eq $wix) { + throw "WiX Toolset was not installed." + } + "$($wix.FullName)\bin" | Out-File -FilePath $env:GITHUB_PATH -Append + + - name: Ensure Gradle wrapper is executable + run: chmod +x gradlew + + - name: Build native launcher package + run: | + package_task="packageAll" + if [ "$RUNNER_OS" = "Linux" ]; then + package_task="packageLinuxAll" + fi + + ./gradlew clean "$package_task" -PlauncherVersion="${{ needs.metadata.outputs.version }}" + + - name: Upload Linux AppImage package + if: runner.os == 'Linux' + uses: actions/upload-artifact@v4 + with: + name: launcher-linux-appimage + path: launcher/build/distributions/*.AppImage + if-no-files-found: error + + - name: Upload Linux Debian package + if: runner.os == 'Linux' + uses: actions/upload-artifact@v4 + with: + name: launcher-linux-deb + path: launcher/build/distributions/*.deb + if-no-files-found: error + + - name: Upload Linux RPM package + if: runner.os == 'Linux' + uses: actions/upload-artifact@v4 + with: + name: launcher-linux-rpm + path: launcher/build/distributions/*.rpm + if-no-files-found: error + + - name: Upload Linux Flatpak package + if: runner.os == 'Linux' + uses: actions/upload-artifact@v4 + with: + name: launcher-linux-flatpak + path: launcher/build/distributions/*.flatpak + if-no-files-found: error + + - name: Upload Linux pacman package + if: runner.os == 'Linux' + uses: actions/upload-artifact@v4 + with: + name: launcher-linux-pacman + path: launcher/build/distributions/*.pkg.tar.zst + if-no-files-found: error + + - name: Upload launcher package + if: runner.os != 'Linux' + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: launcher/build/distributions/* + if-no-files-found: error + + publish: + name: Publish Launcher Release + needs: + - metadata + - package + if: github.repository == 'Modtale/modtale' + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.metadata.outputs.tag }} + LAUNCHER_VERSION: ${{ needs.metadata.outputs.version }} + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Download packaged launchers + uses: actions/download-artifact@v4 + with: + path: launcher-dist + + - name: Create checksums + run: | + find launcher-dist -type f -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS + + - name: Publish GitHub release + run: | + if ! gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + gh release create "$RELEASE_TAG" \ + --target "$GITHUB_SHA" \ + --title "Modtale Launcher $LAUNCHER_VERSION" \ + --generate-notes + fi + + mapfile -d '' release_files < <(find launcher-dist -type f -print0) + gh release upload "$RELEASE_TAG" "${release_files[@]}" SHA256SUMS --clobber diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml index 564565a8..6b762220 100644 --- a/.github/workflows/lighthouse.yml +++ b/.github/workflows/lighthouse.yml @@ -37,7 +37,7 @@ jobs: detect-changes: name: Detect Changes needs: dedupe - if: needs.dedupe.outputs.should_run == 'true' + if: needs.dedupe.outputs.should_run == 'true' && github.repository == 'Modtale/modtale' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Modtale/modtale') runs-on: ubuntu-latest outputs: frontend: ${{ steps.filter.outputs.frontend }} @@ -59,7 +59,7 @@ jobs: audit: name: Lighthouse Audit (non-blocking) needs: detect-changes - if: needs.detect-changes.outputs.frontend == 'true' + if: needs.detect-changes.outputs.frontend == 'true' && github.repository == 'Modtale/modtale' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Modtale/modtale') runs-on: ubuntu-latest continue-on-error: true defaults: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 06ea2a9f..f0cd48dd 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -42,6 +42,7 @@ jobs: outputs: frontend: ${{ steps.filter.outputs.frontend }} backend: ${{ steps.filter.outputs.backend }} + launcher: ${{ steps.filter.outputs.launcher }} steps: - name: Check out repository uses: actions/checkout@v4 @@ -60,7 +61,7 @@ jobs: frontend: name: Frontend Tests needs: detect-changes - if: needs.detect-changes.outputs.frontend == 'true' + if: needs.detect-changes.outputs.frontend == 'true' && github.repository == 'Modtale/modtale' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Modtale/modtale') runs-on: ubuntu-latest defaults: run: @@ -122,3 +123,31 @@ jobs: - name: Run backend tests run: ./gradlew test + + launcher: + name: Launcher Tests + needs: detect-changes + if: needs.detect-changes.outputs.launcher == 'true' && github.repository == 'Modtale/modtale' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Modtale/modtale') + runs-on: ubuntu-latest + defaults: + run: + working-directory: launcher + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Ensure Gradle wrapper is executable + run: chmod +x gradlew + + - name: Run launcher tests + run: ./gradlew test diff --git a/README.md b/README.md index 9bb95e1f..d8a6b873 100644 --- a/README.md +++ b/README.md @@ -107,9 +107,11 @@ The Spring Boot backend relies on environment variables. You can set these in yo | Variable | Description | Example | | --- | --- | --- | | `MONGODB_URI` | Connection String | `mongodb://localhost:27017/modtale` | +| `R2_BUCKET_NAME` | Storage Bucket | `modtale-dev` | | `R2_ACCESS_KEY` | Storage Access Key | `your_dev_access_key` | | `R2_SECRET_KEY` | Storage Secret Key | `your_dev_secret_key` | | `R2_ENDPOINT` | Storage Endpoint URL | `https://.r2.cloudflarestorage.com` | +| `R2_PUBLIC_DOMAIN` | Optional public storage URL | `https://cdn.example.test` | | `WARDEN_ENABLED` | **Must be false locally** | `false` | | `STATUS_DISCORD_WEBHOOK_URL` | Optional Discord webhook for status-change alerts | `https://discord.com/api/webhooks/...` | | `STATUS_CHECKER_ENABLED` | Opt into the legacy embedded backend checker | `false` | @@ -174,6 +176,26 @@ npm run dev *The web client is now accessible at `http://localhost:5173`!* +### 5. Native Launcher + +The `launcher/` project is a native Java 21 JavaFX client for installing Modtale projects into a local Hytale mods folder. It does not use Electron. + +```bash +cd launcher +./gradlew run +``` + +The launcher lets users search the Modtale catalog, install the latest compatible version, include required or optional dependencies, check installed projects for updates, apply updates, and point the app at the correct Hytale mods folder. + +Self-contained native packages are built by default: + +```bash +cd launcher +./gradlew build +``` + +Package outputs land in `launcher/build/distributions/`. Windows builds produce an `.exe` installer, macOS builds produce a `.dmg`, and Linux builds produce an `.AppImage`. Each package embeds the required Java runtime, so end users do not need Java installed. Build on each target OS, or use a CI matrix, to produce all three platform artifacts. + --- ## License diff --git a/backend/cloudbuild.yml b/backend/cloudbuild.yml index df8f8b71..814d16b5 100644 --- a/backend/cloudbuild.yml +++ b/backend/cloudbuild.yml @@ -17,4 +17,4 @@ substitutions: _TAG: latest options: - defaultLogsBucketBehavior: REGIONAL_USER_OWNED_BUCKET \ No newline at end of file + defaultLogsBucketBehavior: REGIONAL_USER_OWNED_BUCKET diff --git a/backend/src/main/java/net/modtale/config/auth/HytaleAuthorizationRequestResolver.java b/backend/src/main/java/net/modtale/config/auth/HytaleAuthorizationRequestResolver.java new file mode 100644 index 00000000..640f9bdf --- /dev/null +++ b/backend/src/main/java/net/modtale/config/auth/HytaleAuthorizationRequestResolver.java @@ -0,0 +1,61 @@ +package net.modtale.config.auth; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestCustomizers; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; + +/** + * Applies Hytale's required S256 PKCE to its confidential OAuth client. + */ +public class HytaleAuthorizationRequestResolver implements OAuth2AuthorizationRequestResolver { + + private static final String HYTALE_REGISTRATION_ID = "hytale"; + + private final OAuth2AuthorizationRequestResolver delegate; + + public HytaleAuthorizationRequestResolver(ClientRegistrationRepository registrations) { + this.delegate = new DefaultOAuth2AuthorizationRequestResolver( + registrations, + "/oauth2/authorization" + ); + } + + @Override + public OAuth2AuthorizationRequest resolve(HttpServletRequest request) { + return addHytalePkce(delegate.resolve(request), registrationIdFrom(request)); + } + + @Override + public OAuth2AuthorizationRequest resolve( + HttpServletRequest request, + String clientRegistrationId + ) { + return addHytalePkce( + delegate.resolve(request, clientRegistrationId), + clientRegistrationId + ); + } + + private OAuth2AuthorizationRequest addHytalePkce( + OAuth2AuthorizationRequest authorizationRequest, + String registrationId + ) { + if (authorizationRequest == null || !HYTALE_REGISTRATION_ID.equals(registrationId)) { + return authorizationRequest; + } + + OAuth2AuthorizationRequest.Builder builder = + OAuth2AuthorizationRequest.from(authorizationRequest); + OAuth2AuthorizationRequestCustomizers.withPkce().accept(builder); + return builder.build(); + } + + private String registrationIdFrom(HttpServletRequest request) { + String path = request.getRequestURI(); + int separator = path.lastIndexOf('/'); + return separator >= 0 ? path.substring(separator + 1) : path; + } +} diff --git a/backend/src/main/java/net/modtale/config/core/PublicApiEndpointMatcher.java b/backend/src/main/java/net/modtale/config/core/PublicApiEndpointMatcher.java index 021193f5..c28af029 100644 --- a/backend/src/main/java/net/modtale/config/core/PublicApiEndpointMatcher.java +++ b/backend/src/main/java/net/modtale/config/core/PublicApiEndpointMatcher.java @@ -30,6 +30,7 @@ public final class PublicApiEndpointMatcher { "/api/v1/og/", "/api/v1/download/", "/api/v1/download-bundle/", + "/api/v1/lists/", "/api/v1/meta/", "/api/v1/version/", "/api/v1/wiki/" diff --git a/backend/src/main/java/net/modtale/config/security/SecurityConfig.java b/backend/src/main/java/net/modtale/config/security/SecurityConfig.java index 498743f6..bc137cf0 100644 --- a/backend/src/main/java/net/modtale/config/security/SecurityConfig.java +++ b/backend/src/main/java/net/modtale/config/security/SecurityConfig.java @@ -13,11 +13,13 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Set; +import net.modtale.controller.auth.AuthController; import net.modtale.config.auth.ApiKeyAuthFilter; import net.modtale.config.properties.AppFrontendProperties; import net.modtale.exception.ErrorMessageUtils; import net.modtale.model.user.User; import net.modtale.service.auth.AuthenticationService; +import net.modtale.service.auth.LauncherAuthService; import net.modtale.service.auth.LocalUserDetailsService; import net.modtale.service.auth.OAuth2LoginService; import net.modtale.service.auth.OidcLoginService; @@ -70,6 +72,7 @@ public class SecurityConfig { private final PasswordEncoder passwordEncoder; private final AccountService accountService; private final AuthenticationService authenticationService; + private final LauncherAuthService launcherAuthService; private final AppFrontendProperties frontendProperties; public SecurityConfig( @@ -82,6 +85,7 @@ public SecurityConfig( PasswordEncoder passwordEncoder, AccountService accountService, AuthenticationService authenticationService, + LauncherAuthService launcherAuthService, AppFrontendProperties frontendProperties ) { this.apiKeyAuthFilter = apiKeyAuthFilter; @@ -93,6 +97,7 @@ public SecurityConfig( this.passwordEncoder = passwordEncoder; this.accountService = accountService; this.authenticationService = authenticationService; + this.launcherAuthService = launcherAuthService; this.frontendProperties = frontendProperties; } @@ -289,7 +294,10 @@ public SecurityFilterChain securityFilterChain( "/api/v1/auth/verify", "/api/v1/auth/signin", "/api/v1/auth/logout", + "/api/v1/auth/oauth/**", + "/api/v1/auth/launcher/oauth/**", "/api/v1/auth/mfa/validate-login", + "/api/v1/auth/launcher/exchange", "/api/v1/auth/forgot-password", "/api/v1/auth/reset-password" ).permitAll() @@ -306,13 +314,14 @@ public SecurityFilterChain securityFilterChain( "/api/v1/og/**", "/api/v1/download/**", "/api/v1/download-bundle/**", + "/api/v1/lists/**", "/api/v1/meta/**", "/api/v1/status", "/api/v1/version/**", "/api/v1/analytics/platform/stats", "/api/v1/wiki/**" ).permitAll() - .requestMatchers(HttpMethod.HEAD, "/api/v1/projects/**", "/api/v1/tags", "/api/v1/files/**", "/api/v1/user/profile/**", "/api/v1/og/**").permitAll() + .requestMatchers(HttpMethod.HEAD, "/api/v1/projects/**", "/api/v1/tags", "/api/v1/files/**", "/api/v1/user/profile/**", "/api/v1/og/**", "/api/v1/lists/**").permitAll() .requestMatchers(HttpMethod.POST, "/api/v1/users/batch" ).permitAll() @@ -492,9 +501,44 @@ public AuthenticationSuccessHandler oauthSuccessHandler() { user = accountService.saveUser(user); } boolean isLinking = Boolean.TRUE.equals(oauthUser.getAttribute("is_linking")); + LauncherOAuthRequest launcherOAuthRequest = consumeLauncherOAuthRequest(request); + if (launcherOAuthRequest != null && !isLinking) { + if (user == null) { + response.sendRedirect(launcherCallbackUrl( + launcherOAuthRequest.redirectUri(), + "oauth_user_not_found", + launcherOAuthRequest.state(), + false + )); + return; + } + if (!user.isMfaEnabled()) { + SecurityContextRepository repository = securityContextRepository(); + repository.saveContext(SecurityContextHolder.getContext(), request, response); + try { + LauncherAuthService.LauncherAuthGrant grant = launcherAuthService.issueCode( + user, + launcherOAuthRequest.redirectUri(), + launcherOAuthRequest.state() + ); + response.sendRedirect(launcherCallbackUrl(grant.redirectUri(), grant.code(), grant.state(), true)); + } catch (RuntimeException ex) { + response.sendRedirect(launcherCallbackUrl( + launcherOAuthRequest.redirectUri(), + ex.getMessage(), + launcherOAuthRequest.state(), + false + )); + } + return; + } + } if (user != null && user.isMfaEnabled() && !isLinking) { String preAuthToken = authenticationService.generatePreAuthToken(user.getId()); + String postLoginRedirect = launcherOAuthRequest == null + ? consumePostOAuthRedirect(request, "/dashboard/profile") + : launcherAuthFrontendPath(launcherOAuthRequest); SecurityContextHolder.clearContext(); @@ -506,14 +550,16 @@ public AuthenticationSuccessHandler oauthSuccessHandler() { session.invalidate(); } - String cleanUrl = getCleanFrontendUrl(); - response.sendRedirect((cleanUrl != null ? cleanUrl : "") + "/mfa?token=" + preAuthToken); + String mfaPath = "/mfa?token=" + preAuthToken; + if (!"/dashboard/profile".equals(postLoginRedirect)) { + mfaPath += "&redirect=" + URLEncoder.encode(postLoginRedirect, StandardCharsets.UTF_8); + } + response.sendRedirect(frontendUrl(mfaPath)); } else { SecurityContextRepository repository = securityContextRepository(); repository.saveContext(SecurityContextHolder.getContext(), request, response); - String cleanUrl = getCleanFrontendUrl(); - response.sendRedirect((cleanUrl != null ? cleanUrl : "") + "/dashboard/profile"); + response.sendRedirect(frontendUrl(consumePostOAuthRedirect(request, "/dashboard/profile"))); } }; } @@ -521,12 +567,101 @@ public AuthenticationSuccessHandler oauthSuccessHandler() { @Bean public AuthenticationFailureHandler oauthFailureHandler() { return (request, response, exception) -> { + LauncherOAuthRequest launcherOAuthRequest = consumeLauncherOAuthRequest(request); + if (launcherOAuthRequest != null) { + response.sendRedirect(launcherCallbackUrl( + launcherOAuthRequest.redirectUri(), + exception.getMessage(), + launcherOAuthRequest.state(), + false + )); + return; + } String errorParam = URLEncoder.encode(exception.getMessage(), StandardCharsets.UTF_8); - String cleanUrl = getCleanFrontendUrl(); - response.sendRedirect((cleanUrl != null ? cleanUrl : "") + "/?oauth_error=" + errorParam); + String redirectPath = consumePostOAuthRedirect(request, "/"); + String separator = redirectPath.contains("?") ? "&" : "?"; + response.sendRedirect(frontendUrl(redirectPath + separator + "oauth_error=" + errorParam)); }; } + private String frontendUrl(String path) { + String cleanUrl = getCleanFrontendUrl(); + return (cleanUrl != null ? cleanUrl : "") + safeInternalRedirect(path, "/"); + } + + private LauncherOAuthRequest consumeLauncherOAuthRequest(HttpServletRequest request) { + HttpSession session = request.getSession(false); + if (session == null) { + return null; + } + + Object redirectUri = session.getAttribute(LauncherAuthService.OAUTH_REDIRECT_URI_SESSION_ATTRIBUTE); + Object state = session.getAttribute(LauncherAuthService.OAUTH_STATE_SESSION_ATTRIBUTE); + session.removeAttribute(LauncherAuthService.OAUTH_REDIRECT_URI_SESSION_ATTRIBUTE); + session.removeAttribute(LauncherAuthService.OAUTH_STATE_SESSION_ATTRIBUTE); + + if (redirectUri instanceof String redirect && !redirect.isBlank()) { + return new LauncherOAuthRequest(redirect, state instanceof String value ? value : ""); + } + return null; + } + + private String launcherAuthFrontendPath(LauncherOAuthRequest request) { + return "/launcher/auth?redirect_uri=" + URLEncoder.encode(request.redirectUri(), StandardCharsets.UTF_8) + + (request.state().isBlank() + ? "" + : "&state=" + URLEncoder.encode(request.state(), StandardCharsets.UTF_8)); + } + + private String launcherCallbackUrl(String redirectUri, String value, String state, boolean success) { + String key = success ? "code" : "error"; + int fragmentStart = redirectUri.indexOf('#'); + String base = fragmentStart >= 0 ? redirectUri.substring(0, fragmentStart) : redirectUri; + String fragment = fragmentStart >= 0 ? redirectUri.substring(fragmentStart) : ""; + + StringBuilder target = new StringBuilder(base); + if (base.contains("?")) { + if (!base.endsWith("?") && !base.endsWith("&")) { + target.append('&'); + } + } else { + target.append('?'); + } + + target.append(key).append('=').append(URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8)); + if (state != null && !state.isBlank()) { + target.append("&state=").append(URLEncoder.encode(state, StandardCharsets.UTF_8)); + } + target.append(fragment); + return target.toString(); + } + + private String consumePostOAuthRedirect(HttpServletRequest request, String fallback) { + HttpSession session = request.getSession(false); + if (session == null) { + return fallback; + } + + Object redirect = session.getAttribute(AuthController.POST_OAUTH_REDIRECT_ATTRIBUTE); + session.removeAttribute(AuthController.POST_OAUTH_REDIRECT_ATTRIBUTE); + if (redirect instanceof String redirectPath) { + return safeInternalRedirect(redirectPath, fallback); + } + return fallback; + } + + private String safeInternalRedirect(String redirect, String fallback) { + if (redirect == null || redirect.isBlank()) { + return fallback; + } + + String trimmed = redirect.trim(); + if (!trimmed.startsWith("/") || trimmed.startsWith("//")) { + return fallback; + } + return trimmed; + } + private URI safeUri(String rawUri, String description) { if (rawUri == null || rawUri.isBlank()) { return null; @@ -543,4 +678,7 @@ private String safeHostFromUrl(String rawUri) { URI uri = safeUri(rawUri, "request origin"); return uri != null ? uri.getHost() : null; } + + private record LauncherOAuthRequest(String redirectUri, String state) { + } } diff --git a/backend/src/main/java/net/modtale/controller/auth/AuthController.java b/backend/src/main/java/net/modtale/controller/auth/AuthController.java index 61d74063..98bc574a 100644 --- a/backend/src/main/java/net/modtale/controller/auth/AuthController.java +++ b/backend/src/main/java/net/modtale/controller/auth/AuthController.java @@ -3,19 +3,23 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; +import java.io.IOException; import jakarta.validation.Valid; import java.time.Duration; -import java.util.Map; +import java.util.stream.Collectors; import net.modtale.exception.InvalidAuthenticationRequestException; import net.modtale.exception.UnauthorizedException; import net.modtale.model.dto.request.auth.ChangePasswordRequest; import net.modtale.model.dto.request.auth.ForgotPasswordRequest; +import net.modtale.model.dto.request.auth.LauncherAuthExchangeRequest; +import net.modtale.model.dto.request.auth.LauncherAuthIssueRequest; import net.modtale.model.dto.request.auth.MfaLoginRequest; import net.modtale.model.dto.request.auth.RegisterRequest; import net.modtale.model.dto.request.auth.ResetPasswordRequest; import net.modtale.model.dto.request.auth.SignInRequest; import net.modtale.model.dto.request.auth.UpdateCredentialsRequest; import net.modtale.model.dto.request.auth.VerifyMfaRequest; +import net.modtale.model.dto.response.auth.LauncherAuthIssueResponse; import net.modtale.model.dto.response.auth.MfaChallengeResponse; import net.modtale.model.dto.response.auth.MfaSetupResponse; import net.modtale.model.dto.response.auth.RegistrationResponse; @@ -25,6 +29,7 @@ import net.modtale.model.user.User; import net.modtale.service.auth.AuthenticationMutationService; import net.modtale.service.auth.AuthenticationService; +import net.modtale.service.auth.LauncherAuthService; import net.modtale.service.auth.TwoFactorService; import net.modtale.service.security.access.AdminAuthorityUtils; import net.modtale.service.user.account.AccountService; @@ -43,10 +48,13 @@ @RequestMapping("/api/v1/auth") public class AuthController { + public static final String POST_OAUTH_REDIRECT_ATTRIBUTE = "MODTALE_POST_OAUTH_REDIRECT"; + private final AuthenticationService authenticationService; private final AuthenticationMutationService authenticationMutationService; private final AccountService accountService; private final TwoFactorService twoFactorService; + private final LauncherAuthService launcherAuthService; private final SecurityContextRepository securityContextRepository; public AuthController( @@ -54,12 +62,14 @@ public AuthController( AuthenticationMutationService authenticationMutationService, AccountService accountService, TwoFactorService twoFactorService, + LauncherAuthService launcherAuthService, SecurityContextRepository securityContextRepository ) { this.authenticationService = authenticationService; this.authenticationMutationService = authenticationMutationService; this.accountService = accountService; this.twoFactorService = twoFactorService; + this.launcherAuthService = launcherAuthService; this.securityContextRepository = securityContextRepository; } @@ -198,6 +208,80 @@ public ResponseEntity validateLoginMfa(@Valid @RequestBody MfaLo return ResponseEntity.ok(new StatusResponse("success")); } + @GetMapping("/oauth/{provider}") + public void beginOAuthLogin( + @PathVariable String provider, + @RequestParam(value = "redirect", required = false) String redirect, + HttpServletRequest request, + HttpServletResponse response + ) throws IOException { + if (!provider.matches("[A-Za-z0-9_-]+")) { + throw new InvalidAuthenticationRequestException("That OAuth provider is not valid."); + } + + String safeRedirect = safeInternalRedirect(redirect); + if (safeRedirect != null) { + request.getSession(true).setAttribute(POST_OAUTH_REDIRECT_ATTRIBUTE, safeRedirect); + } + + response.sendRedirect("/oauth2/authorization/" + provider); + } + + @GetMapping("/launcher/oauth/{provider}") + public void beginLauncherOAuthLogin( + @PathVariable String provider, + @RequestParam("redirect_uri") String redirectUri, + @RequestParam(value = "state", required = false) String state, + HttpServletRequest request, + HttpServletResponse response + ) throws IOException { + if (!provider.matches("[A-Za-z0-9_-]+")) { + throw new InvalidAuthenticationRequestException("That OAuth provider is not valid."); + } + + launcherAuthService.validateLoopbackRedirectUri(redirectUri); + HttpSession session = request.getSession(true); + session.setAttribute(LauncherAuthService.OAUTH_REDIRECT_URI_SESSION_ATTRIBUTE, redirectUri.trim()); + session.setAttribute(LauncherAuthService.OAUTH_STATE_SESSION_ATTRIBUTE, state == null ? "" : state.trim()); + + response.sendRedirect("/oauth2/authorization/" + provider); + } + + @PostMapping("/launcher/issue") + @PreAuthorize("@apiSecurity.hasPersonalPerm('PROFILE_READ', authentication)") + public ResponseEntity issueLauncherAuthCode( + @Valid @RequestBody LauncherAuthIssueRequest requestPayload, + Authentication authentication + ) { + User user = accountService.requireCurrentUser(authentication, "authorizing the Modtale Launcher"); + LauncherAuthService.LauncherAuthGrant grant = launcherAuthService.issueCode( + user, + requestPayload.getRedirectUri(), + requestPayload.getState() + ); + return ResponseEntity.ok(new LauncherAuthIssueResponse( + grant.code(), + grant.redirectUri(), + grant.state(), + grant.expiresIn() + )); + } + + @PostMapping("/launcher/exchange") + public ResponseEntity exchangeLauncherAuthCode( + @Valid @RequestBody LauncherAuthExchangeRequest requestPayload, + HttpServletRequest request, + HttpServletResponse response + ) { + User user = launcherAuthService.consumeCode(requestPayload.getCode()); + if (user == null) { + throw new UnauthorizedException("That launcher authorization code is invalid or has expired. Please sign in again."); + } + + createSession(user, request, response); + return ResponseEntity.ok(new StatusResponse("success")); + } + private void createSession(User user, HttpServletRequest request, HttpServletResponse response) { HttpSession session = request.getSession(true); @@ -221,4 +305,16 @@ private void expireCookie(HttpServletResponse response, String name) { response.addHeader(HttpHeaders.SET_COOKIE, expiredCookie.toString()); } + private String safeInternalRedirect(String redirect) { + if (redirect == null || redirect.isBlank()) { + return null; + } + + String trimmed = redirect.trim(); + if (!trimmed.startsWith("/") || trimmed.startsWith("//")) { + return null; + } + return trimmed; + } + } diff --git a/backend/src/main/java/net/modtale/controller/user/UserController.java b/backend/src/main/java/net/modtale/controller/user/UserController.java index 474905de..7829ce8d 100644 --- a/backend/src/main/java/net/modtale/controller/user/UserController.java +++ b/backend/src/main/java/net/modtale/controller/user/UserController.java @@ -16,6 +16,7 @@ import net.modtale.model.dto.user.UserDTO; import net.modtale.model.dto.user.UserSummaryDTO; import net.modtale.model.project.Project; +import net.modtale.model.user.LauncherSettingsSnapshot; import net.modtale.model.user.User; import net.modtale.repository.user.UserRepository; import net.modtale.service.media.MediaUploadService; @@ -185,6 +186,33 @@ public ResponseEntity updateNotificationSettings( return ResponseEntity.ok().build(); } + @GetMapping("/user/launcher-settings") + @PreAuthorize("@apiSecurity.hasPersonalPerm('PROFILE_READ', authentication)") + public ResponseEntity getLauncherSettings(Authentication authentication) { + User user = accountService.requireCurrentUser(authentication, "loading launcher settings"); + return ResponseEntity.ok(accountService.getLauncherSettings(user.getId())); + } + + @PutMapping("/user/launcher-settings") + @PreAuthorize("@apiSecurity.hasPersonalPerm('PROFILE_EDIT_BASIC', authentication)") + public ResponseEntity updateLauncherSettings( + @RequestBody LauncherSettingsSnapshot snapshot, + Authentication authentication + ) { + User user = accountService.requireCurrentUser(authentication, "syncing launcher settings"); + return ResponseEntity.ok(accountService.updateLauncherSettings(user.getId(), snapshot)); + } + + @PutMapping("/user/launcher-settings/preferences") + @PreAuthorize("@apiSecurity.hasPersonalPerm('PROFILE_EDIT_BASIC', authentication)") + public ResponseEntity updateLauncherSettingsPreferences( + @RequestBody LauncherSettingsSnapshot snapshot, + Authentication authentication + ) { + User user = accountService.requireCurrentUser(authentication, "syncing launcher settings"); + return ResponseEntity.ok(accountService.updateLauncherSettingsPreferences(user.getId(), snapshot)); + } + @PostMapping("/user/follow/{targetId}") @PreAuthorize("@apiSecurity.hasPersonalPerm('PROFILE_FOLLOW', authentication)") public ResponseEntity followUser(@PathVariable String targetId, Authentication authentication) { diff --git a/backend/src/main/java/net/modtale/controller/worldlist/WorldModListController.java b/backend/src/main/java/net/modtale/controller/worldlist/WorldModListController.java new file mode 100644 index 00000000..f5a17e07 --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/worldlist/WorldModListController.java @@ -0,0 +1,63 @@ +package net.modtale.controller.worldlist; + +import jakarta.validation.Valid; +import java.io.IOException; +import net.modtale.model.dto.request.worldlist.CreateWorldModListRequest; +import net.modtale.model.dto.worldlist.WorldModListDTO; +import net.modtale.service.user.account.AccountService; +import net.modtale.service.worldlist.WorldModListService; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1") +public class WorldModListController { + + private final WorldModListService service; + private final AccountService accountService; + + public WorldModListController(WorldModListService service, AccountService accountService) { + this.service = service; + this.accountService = accountService; + } + + @PostMapping("/lists") + public ResponseEntity create( + @Valid @RequestBody CreateWorldModListRequest request, + Authentication authentication + ) { + return ResponseEntity.ok(service.create( + request, + accountService.requireCurrentUser(authentication, "sharing a world mod list") + )); + } + + @GetMapping("/lists/{id}") + public ResponseEntity view(@PathVariable String id) { + return ResponseEntity.ok(service.view(id)); + } + + @GetMapping("/lists/{id}/install") + public ResponseEntity installMetadata(@PathVariable String id) { + return ResponseEntity.ok(service.metadataForInstall(id)); + } + + @GetMapping("/lists/{id}/download") + public ResponseEntity download(@PathVariable String id) throws IOException { + WorldModListService.Download download = service.download(id); + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + download.filename() + "\"") + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .body(new ByteArrayResource(download.bytes())); + } +} diff --git a/backend/src/main/java/net/modtale/model/dto/request/auth/LauncherAuthExchangeRequest.java b/backend/src/main/java/net/modtale/model/dto/request/auth/LauncherAuthExchangeRequest.java new file mode 100644 index 00000000..ec1425c8 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/dto/request/auth/LauncherAuthExchangeRequest.java @@ -0,0 +1,17 @@ +package net.modtale.model.dto.request.auth; + +import jakarta.validation.constraints.NotBlank; + +public class LauncherAuthExchangeRequest { + + @NotBlank(message = "A launcher authorization code is required.") + private String code; + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } +} diff --git a/backend/src/main/java/net/modtale/model/dto/request/auth/LauncherAuthIssueRequest.java b/backend/src/main/java/net/modtale/model/dto/request/auth/LauncherAuthIssueRequest.java new file mode 100644 index 00000000..63b4a143 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/dto/request/auth/LauncherAuthIssueRequest.java @@ -0,0 +1,27 @@ +package net.modtale.model.dto.request.auth; + +import jakarta.validation.constraints.NotBlank; + +public class LauncherAuthIssueRequest { + + @NotBlank(message = "A launcher callback URL is required.") + private String redirectUri; + + private String state; + + public String getRedirectUri() { + return redirectUri; + } + + public void setRedirectUri(String redirectUri) { + this.redirectUri = redirectUri; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } +} diff --git a/backend/src/main/java/net/modtale/model/dto/request/worldlist/CreateWorldModListRequest.java b/backend/src/main/java/net/modtale/model/dto/request/worldlist/CreateWorldModListRequest.java new file mode 100644 index 00000000..37611dd2 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/dto/request/worldlist/CreateWorldModListRequest.java @@ -0,0 +1,28 @@ +package net.modtale.model.dto.request.worldlist; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; +import java.util.List; +import net.modtale.model.project.ProjectClassification; +import net.modtale.model.project.ProjectDependency; + +public record CreateWorldModListRequest( + @Size(max = 120) String title, + @Size(max = 120) String worldName, + @Size(max = 60) String gameVersion, + @NotEmpty @Size(max = 200) List<@Valid Item> mods +) { + public record Item( + @Size(max = 160) String modId, + @Size(max = 120) String projectId, + @Size(max = 160) String slug, + @Size(max = 180) String title, + @Size(max = 80) String versionNumber, + ProjectClassification classification, + ProjectDependency.Source source, + @Size(max = 180) String externalId, + @Size(max = 600) String externalUrl, + @Size(max = 600) String icon + ) {} +} diff --git a/backend/src/main/java/net/modtale/model/dto/response/auth/LauncherAuthIssueResponse.java b/backend/src/main/java/net/modtale/model/dto/response/auth/LauncherAuthIssueResponse.java new file mode 100644 index 00000000..56a10a3e --- /dev/null +++ b/backend/src/main/java/net/modtale/model/dto/response/auth/LauncherAuthIssueResponse.java @@ -0,0 +1,4 @@ +package net.modtale.model.dto.response.auth; + +public record LauncherAuthIssueResponse(String code, String redirectUri, String state, int expiresIn) { +} diff --git a/backend/src/main/java/net/modtale/model/dto/worldlist/WorldModListDTO.java b/backend/src/main/java/net/modtale/model/dto/worldlist/WorldModListDTO.java new file mode 100644 index 00000000..e0cd1d14 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/dto/worldlist/WorldModListDTO.java @@ -0,0 +1,48 @@ +package net.modtale.model.dto.worldlist; + +import java.time.Instant; +import java.util.List; +import net.modtale.model.project.ProjectClassification; +import net.modtale.model.project.ProjectDependency; + +public record WorldModListDTO( + String id, + String title, + String worldName, + String gameVersion, + String ownerUsername, + Instant createdAt, + Instant lastViewedAt, + Instant expiresAt, + int viewCount, + int downloadCount, + int modCount, + int downloadableCount, + String shareUrl, + String downloadUrl, + String launcherInstallUrl, + List mods +) { + public record Item( + String id, + String modId, + String projectId, + String slug, + String title, + String authorId, + String author, + String description, + String versionNumber, + ProjectClassification classification, + ProjectDependency.Source source, + String externalId, + String externalUrl, + String icon, + String bannerUrl, + int downloadCount, + int favoriteCount, + String updatedAt, + boolean downloadable, + String unavailableReason + ) {} +} diff --git a/backend/src/main/java/net/modtale/model/user/LauncherSettingsSnapshot.java b/backend/src/main/java/net/modtale/model/user/LauncherSettingsSnapshot.java new file mode 100644 index 00000000..35ea1151 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/user/LauncherSettingsSnapshot.java @@ -0,0 +1,205 @@ +package net.modtale.model.user; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class LauncherSettingsSnapshot implements Serializable { + + private static final long serialVersionUID = 1L; + + private int schemaVersion = 1; + private String settingsHash = ""; + private String updatedAt = ""; + private Preferences preferences = new Preferences(); + private List installedProjects = new ArrayList<>(); + + public int getSchemaVersion() { + return schemaVersion; + } + + public void setSchemaVersion(int schemaVersion) { + this.schemaVersion = Math.max(1, schemaVersion); + } + + public String getSettingsHash() { + return settingsHash; + } + + public void setSettingsHash(String settingsHash) { + this.settingsHash = settingsHash == null ? "" : settingsHash.trim(); + } + + public String getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(String updatedAt) { + this.updatedAt = updatedAt == null ? "" : updatedAt.trim(); + } + + public Preferences getPreferences() { + return preferences; + } + + public void setPreferences(Preferences preferences) { + this.preferences = preferences == null ? new Preferences() : preferences; + } + + public List getInstalledProjects() { + return installedProjects; + } + + public void setInstalledProjects(List installedProjects) { + this.installedProjects = installedProjects == null ? new ArrayList<>() : new ArrayList<>(installedProjects); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Preferences implements Serializable { + private static final long serialVersionUID = 1L; + + private String hytaleModsPath = ""; + private String hytaleGamePath = ""; + private String hytaleUserDataPath = ""; + private String hytaleJavaPath = ""; + private String hytaleBranch = "release"; + private int hytaleBuild; + private String gameVersion = ""; + private boolean includeDependencies = true; + private boolean includeOptionalDependencies; + private boolean autoCheckUpdates = true; + private boolean launcherAutoUpdates; + + public String getHytaleModsPath() { return hytaleModsPath; } + public void setHytaleModsPath(String hytaleModsPath) { this.hytaleModsPath = hytaleModsPath; } + public String getHytaleGamePath() { return hytaleGamePath; } + public void setHytaleGamePath(String hytaleGamePath) { this.hytaleGamePath = hytaleGamePath; } + public String getHytaleUserDataPath() { return hytaleUserDataPath; } + public void setHytaleUserDataPath(String hytaleUserDataPath) { this.hytaleUserDataPath = hytaleUserDataPath; } + public String getHytaleJavaPath() { return hytaleJavaPath; } + public void setHytaleJavaPath(String hytaleJavaPath) { this.hytaleJavaPath = hytaleJavaPath; } + public String getHytaleBranch() { return hytaleBranch; } + public void setHytaleBranch(String hytaleBranch) { this.hytaleBranch = hytaleBranch; } + public int getHytaleBuild() { return hytaleBuild; } + public void setHytaleBuild(int hytaleBuild) { this.hytaleBuild = Math.max(0, hytaleBuild); } + public String getGameVersion() { return gameVersion; } + public void setGameVersion(String gameVersion) { this.gameVersion = gameVersion; } + public boolean isIncludeDependencies() { return includeDependencies; } + public void setIncludeDependencies(boolean includeDependencies) { this.includeDependencies = includeDependencies; } + public boolean isIncludeOptionalDependencies() { return includeOptionalDependencies; } + public void setIncludeOptionalDependencies(boolean includeOptionalDependencies) { this.includeOptionalDependencies = includeOptionalDependencies; } + public boolean isAutoCheckUpdates() { return autoCheckUpdates; } + public void setAutoCheckUpdates(boolean autoCheckUpdates) { this.autoCheckUpdates = autoCheckUpdates; } + public boolean isLauncherAutoUpdates() { return launcherAutoUpdates; } + public void setLauncherAutoUpdates(boolean launcherAutoUpdates) { this.launcherAutoUpdates = launcherAutoUpdates; } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static class InstalledProject implements Serializable { + private static final long serialVersionUID = 1L; + + private String projectId = ""; + private String slug = ""; + private String title = ""; + private String classification = ""; + private String installedVersion = ""; + private String installedVersionId = ""; + private String gameVersion = ""; + private String source = "MODTALE"; + private String installType = "DIRECT"; + private boolean modpackUnlocked; + private List dependencyProjectIds = new ArrayList<>(); + private List externalDependencies = new ArrayList<>(); + private List bundledProjects = new ArrayList<>(); + + public String getProjectId() { return projectId; } + public void setProjectId(String projectId) { this.projectId = projectId; } + public String getSlug() { return slug; } + public void setSlug(String slug) { this.slug = slug; } + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + public String getClassification() { return classification; } + public void setClassification(String classification) { this.classification = classification; } + public String getInstalledVersion() { return installedVersion; } + public void setInstalledVersion(String installedVersion) { this.installedVersion = installedVersion; } + public String getInstalledVersionId() { return installedVersionId; } + public void setInstalledVersionId(String installedVersionId) { this.installedVersionId = installedVersionId; } + public String getGameVersion() { return gameVersion; } + public void setGameVersion(String gameVersion) { this.gameVersion = gameVersion; } + public String getSource() { return source; } + public void setSource(String source) { this.source = source; } + public String getInstallType() { return installType; } + public void setInstallType(String installType) { this.installType = installType; } + public boolean isModpackUnlocked() { return modpackUnlocked; } + public void setModpackUnlocked(boolean modpackUnlocked) { this.modpackUnlocked = modpackUnlocked; } + public List getDependencyProjectIds() { return dependencyProjectIds; } + public void setDependencyProjectIds(List dependencyProjectIds) { + this.dependencyProjectIds = dependencyProjectIds == null ? new ArrayList<>() : new ArrayList<>(dependencyProjectIds); + } + public List getExternalDependencies() { return externalDependencies; } + public void setExternalDependencies(List externalDependencies) { + this.externalDependencies = externalDependencies == null ? new ArrayList<>() : new ArrayList<>(externalDependencies); + } + public List getBundledProjects() { return bundledProjects; } + public void setBundledProjects(List bundledProjects) { + this.bundledProjects = bundledProjects == null ? new ArrayList<>() : new ArrayList<>(bundledProjects); + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static class InstalledProjectReference implements Serializable { + private static final long serialVersionUID = 1L; + + private String id = ""; + private String projectId = ""; + private String slug = ""; + private String title = ""; + private String classification = ""; + private String versionNumber = ""; + private String dependencyType = ""; + private String source = ""; + private String externalId = ""; + private String externalUrl = ""; + private String externalFileUrl = ""; + private String externalFileName = ""; + private String cachedFileUrl = ""; + private String icon = ""; + private Boolean optional; + private Boolean embedded; + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + public String getProjectId() { return projectId; } + public void setProjectId(String projectId) { this.projectId = projectId; } + public String getSlug() { return slug; } + public void setSlug(String slug) { this.slug = slug; } + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + public String getClassification() { return classification; } + public void setClassification(String classification) { this.classification = classification; } + public String getVersionNumber() { return versionNumber; } + public void setVersionNumber(String versionNumber) { this.versionNumber = versionNumber; } + public String getDependencyType() { return dependencyType; } + public void setDependencyType(String dependencyType) { this.dependencyType = dependencyType; } + public String getSource() { return source; } + public void setSource(String source) { this.source = source; } + public String getExternalId() { return externalId; } + public void setExternalId(String externalId) { this.externalId = externalId; } + public String getExternalUrl() { return externalUrl; } + public void setExternalUrl(String externalUrl) { this.externalUrl = externalUrl; } + public String getExternalFileUrl() { return externalFileUrl; } + public void setExternalFileUrl(String externalFileUrl) { this.externalFileUrl = externalFileUrl; } + public String getExternalFileName() { return externalFileName; } + public void setExternalFileName(String externalFileName) { this.externalFileName = externalFileName; } + public String getCachedFileUrl() { return cachedFileUrl; } + public void setCachedFileUrl(String cachedFileUrl) { this.cachedFileUrl = cachedFileUrl; } + public String getIcon() { return icon; } + public void setIcon(String icon) { this.icon = icon; } + public Boolean getOptional() { return optional; } + public void setOptional(Boolean optional) { this.optional = optional; } + public Boolean getEmbedded() { return embedded; } + public void setEmbedded(Boolean embedded) { this.embedded = embedded; } + } +} diff --git a/backend/src/main/java/net/modtale/model/user/User.java b/backend/src/main/java/net/modtale/model/user/User.java index 42bd101b..4609784a 100644 --- a/backend/src/main/java/net/modtale/model/user/User.java +++ b/backend/src/main/java/net/modtale/model/user/User.java @@ -76,6 +76,8 @@ public class User implements Serializable { private NotificationPreferences notificationPreferences = new NotificationPreferences(); + private LauncherSettingsSnapshot launcherSettings; + private String githubAccessToken; private String gitlabAccessToken; @@ -290,6 +292,9 @@ public void setAdminPermissions(Set adminPermissions) { public NotificationPreferences getNotificationPreferences() { return notificationPreferences; } public void setNotificationPreferences(NotificationPreferences notificationPreferences) { this.notificationPreferences = notificationPreferences; } + public LauncherSettingsSnapshot getLauncherSettings() { return launcherSettings; } + public void setLauncherSettings(LauncherSettingsSnapshot launcherSettings) { this.launcherSettings = launcherSettings; } + public String getGithubAccessToken() { return githubAccessToken; } public void setGithubAccessToken(String githubAccessToken) { this.githubAccessToken = githubAccessToken; } diff --git a/backend/src/main/java/net/modtale/model/worldlist/WorldModList.java b/backend/src/main/java/net/modtale/model/worldlist/WorldModList.java new file mode 100644 index 00000000..92c6e703 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/worldlist/WorldModList.java @@ -0,0 +1,158 @@ +package net.modtale.model.worldlist; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import net.modtale.model.project.ProjectClassification; +import net.modtale.model.project.ProjectDependency; +import org.springframework.data.mongodb.core.index.CompoundIndex; +import org.springframework.data.mongodb.core.index.Indexed; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.FieldType; +import org.springframework.data.mongodb.core.mapping.MongoId; + +@Document(collection = "world_mod_lists") +@CompoundIndex(name = "world_mod_lists_expires_idx", def = "{'expiresAt': 1}") +public class WorldModList { + + @MongoId(FieldType.STRING) + private String id = UUID.randomUUID().toString(); + + @Indexed + private String ownerId; + + private String ownerUsername; + private String title; + private String worldName; + private String gameVersion; + private Instant createdAt; + private Instant lastViewedAt; + private Instant expiresAt; + private int viewCount; + private int downloadCount; + private List mods = new ArrayList<>(); + + public String getId() { return id; } + public void setId(String id) { this.id = id == null || id.isBlank() ? UUID.randomUUID().toString() : id; } + + public String getOwnerId() { return ownerId; } + public void setOwnerId(String ownerId) { this.ownerId = ownerId; } + + public String getOwnerUsername() { return ownerUsername; } + public void setOwnerUsername(String ownerUsername) { this.ownerUsername = ownerUsername; } + + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + + public String getWorldName() { return worldName; } + public void setWorldName(String worldName) { this.worldName = worldName; } + + public String getGameVersion() { return gameVersion; } + public void setGameVersion(String gameVersion) { this.gameVersion = gameVersion; } + + public Instant getCreatedAt() { return createdAt; } + public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } + + public Instant getLastViewedAt() { return lastViewedAt; } + public void setLastViewedAt(Instant lastViewedAt) { this.lastViewedAt = lastViewedAt; } + + public Instant getExpiresAt() { return expiresAt; } + public void setExpiresAt(Instant expiresAt) { this.expiresAt = expiresAt; } + + public int getViewCount() { return viewCount; } + public void setViewCount(int viewCount) { this.viewCount = Math.max(0, viewCount); } + + public int getDownloadCount() { return downloadCount; } + public void setDownloadCount(int downloadCount) { this.downloadCount = Math.max(0, downloadCount); } + + public List getMods() { return mods; } + public void setMods(List mods) { this.mods = mods == null ? new ArrayList<>() : new ArrayList<>(mods); } + + public static class Item { + private String id = UUID.randomUUID().toString(); + private String modId; + private String projectId; + private String slug; + private String title; + private String authorId; + private String author; + private String description; + private String versionNumber; + private ProjectClassification classification; + private ProjectDependency.Source source = ProjectDependency.Source.MODTALE; + private String externalId; + private String externalUrl; + private String icon; + private String bannerUrl; + private int downloadCount; + private int favoriteCount; + private String updatedAt; + private String fileUrl; + private boolean downloadable; + private String unavailableReason; + + public String getId() { return id; } + public void setId(String id) { this.id = id == null || id.isBlank() ? UUID.randomUUID().toString() : id; } + + public String getModId() { return modId; } + public void setModId(String modId) { this.modId = modId; } + + public String getProjectId() { return projectId; } + public void setProjectId(String projectId) { this.projectId = projectId; } + + public String getSlug() { return slug; } + public void setSlug(String slug) { this.slug = slug; } + + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + + public String getAuthorId() { return authorId; } + public void setAuthorId(String authorId) { this.authorId = authorId; } + + public String getAuthor() { return author; } + public void setAuthor(String author) { this.author = author; } + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + + public String getVersionNumber() { return versionNumber; } + public void setVersionNumber(String versionNumber) { this.versionNumber = versionNumber; } + + public ProjectClassification getClassification() { return classification; } + public void setClassification(ProjectClassification classification) { this.classification = classification; } + + public ProjectDependency.Source getSource() { return source == null ? ProjectDependency.Source.MODTALE : source; } + public void setSource(ProjectDependency.Source source) { this.source = source == null ? ProjectDependency.Source.MODTALE : source; } + + public String getExternalId() { return externalId; } + public void setExternalId(String externalId) { this.externalId = externalId; } + + public String getExternalUrl() { return externalUrl; } + public void setExternalUrl(String externalUrl) { this.externalUrl = externalUrl; } + + public String getIcon() { return icon; } + public void setIcon(String icon) { this.icon = icon; } + + public String getBannerUrl() { return bannerUrl; } + public void setBannerUrl(String bannerUrl) { this.bannerUrl = bannerUrl; } + + public int getDownloadCount() { return downloadCount; } + public void setDownloadCount(int downloadCount) { this.downloadCount = Math.max(0, downloadCount); } + + public int getFavoriteCount() { return favoriteCount; } + public void setFavoriteCount(int favoriteCount) { this.favoriteCount = Math.max(0, favoriteCount); } + + public String getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(String updatedAt) { this.updatedAt = updatedAt; } + + public String getFileUrl() { return fileUrl; } + public void setFileUrl(String fileUrl) { this.fileUrl = fileUrl; } + + public boolean isDownloadable() { return downloadable; } + public void setDownloadable(boolean downloadable) { this.downloadable = downloadable; } + + public String getUnavailableReason() { return unavailableReason; } + public void setUnavailableReason(String unavailableReason) { this.unavailableReason = unavailableReason; } + } +} diff --git a/backend/src/main/java/net/modtale/repository/worldlist/WorldModListRepository.java b/backend/src/main/java/net/modtale/repository/worldlist/WorldModListRepository.java new file mode 100644 index 00000000..296f083b --- /dev/null +++ b/backend/src/main/java/net/modtale/repository/worldlist/WorldModListRepository.java @@ -0,0 +1,9 @@ +package net.modtale.repository.worldlist; + +import java.time.Instant; +import net.modtale.model.worldlist.WorldModList; +import org.springframework.data.mongodb.repository.MongoRepository; + +public interface WorldModListRepository extends MongoRepository { + void deleteByExpiresAtBefore(Instant cutoff); +} diff --git a/backend/src/main/java/net/modtale/service/auth/LauncherAuthService.java b/backend/src/main/java/net/modtale/service/auth/LauncherAuthService.java new file mode 100644 index 00000000..c7964ca8 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/auth/LauncherAuthService.java @@ -0,0 +1,108 @@ +package net.modtale.service.auth; + +import java.net.URI; +import java.security.SecureRandom; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import net.modtale.exception.InvalidAuthenticationRequestException; +import net.modtale.model.user.User; +import net.modtale.repository.user.UserRepository; +import org.springframework.stereotype.Service; + +@Service +public class LauncherAuthService { + + public static final String OAUTH_REDIRECT_URI_SESSION_ATTRIBUTE = "MODTALE_LAUNCHER_OAUTH_REDIRECT_URI"; + public static final String OAUTH_STATE_SESSION_ATTRIBUTE = "MODTALE_LAUNCHER_OAUTH_STATE"; + private static final Duration CODE_VALIDITY = Duration.ofMinutes(5); + private static final int CODE_LENGTH_BYTES = 32; + + private final Map codes = new ConcurrentHashMap<>(); + private final SecureRandom secureRandom = new SecureRandom(); + private final UserRepository userRepository; + + public LauncherAuthService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + public LauncherAuthGrant issueCode(User user, String redirectUri, String state) { + if (user == null || user.getId() == null || user.getId().isBlank()) { + throw new InvalidAuthenticationRequestException("You need to sign in before authorizing the Modtale Launcher."); + } + validateLoopbackRedirectUri(redirectUri); + cleanExpiredCodes(); + + byte[] randomBytes = new byte[CODE_LENGTH_BYTES]; + secureRandom.nextBytes(randomBytes); + String code = Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes); + Instant expiresAt = Instant.now().plus(CODE_VALIDITY); + codes.put(code, new LauncherAuthCode(user.getId(), expiresAt)); + + return new LauncherAuthGrant(code, redirectUri, normalizeState(state), Math.toIntExact(CODE_VALIDITY.toSeconds())); + } + + public User consumeCode(String code) { + if (code == null || code.isBlank()) { + return null; + } + + LauncherAuthCode authCode = codes.remove(code); + if (authCode == null || authCode.isExpired()) { + return null; + } + + Optional user = userRepository.findById(authCode.userId()); + return user.filter(candidate -> !candidate.isDeleted()).orElse(null); + } + + public int getActiveCodeCount() { + cleanExpiredCodes(); + return codes.size(); + } + + public void validateLoopbackRedirectUri(String redirectUri) { + URI uri; + try { + uri = URI.create(redirectUri == null ? "" : redirectUri.trim()); + } catch (IllegalArgumentException ex) { + throw new InvalidAuthenticationRequestException("The launcher callback URL is invalid."); + } + + String scheme = uri.getScheme(); + String host = uri.getHost(); + if (!"http".equalsIgnoreCase(scheme) || host == null || uri.getPort() < 1) { + throw new InvalidAuthenticationRequestException("The launcher callback URL must use a local HTTP callback."); + } + + String normalizedHost = host.toLowerCase(Locale.ROOT); + boolean loopback = "localhost".equals(normalizedHost) + || "127.0.0.1".equals(normalizedHost) + || "::1".equals(normalizedHost) + || "[::1]".equals(normalizedHost); + if (!loopback) { + throw new InvalidAuthenticationRequestException("The launcher callback URL must point to this device."); + } + } + + private static String normalizeState(String state) { + return state == null ? "" : state.trim(); + } + + private void cleanExpiredCodes() { + codes.entrySet().removeIf(entry -> entry.getValue().isExpired()); + } + + public record LauncherAuthGrant(String code, String redirectUri, String state, int expiresIn) { + } + + private record LauncherAuthCode(String userId, Instant expiresAt) { + boolean isExpired() { + return Instant.now().isAfter(expiresAt); + } + } +} diff --git a/backend/src/main/java/net/modtale/service/auth/OAuth2LoginService.java b/backend/src/main/java/net/modtale/service/auth/OAuth2LoginService.java index 950b5e2a..0e7b3c3f 100644 --- a/backend/src/main/java/net/modtale/service/auth/OAuth2LoginService.java +++ b/backend/src/main/java/net/modtale/service/auth/OAuth2LoginService.java @@ -1,6 +1,7 @@ package net.modtale.service.auth; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; import net.modtale.exception.AuthenticationOperationException; import net.modtale.exception.ForbiddenOperationException; import net.modtale.exception.InvalidAuthenticationRequestException; @@ -43,8 +44,11 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic try { Authentication currentAuth = currentAuthentication(); - if (currentAuth != null && currentAuth.isAuthenticated() && - !currentAuth.getName().equals("anonymousUser")) { + boolean linking = !hasPendingLauncherOAuth() + && currentAuth != null && currentAuth.isAuthenticated() + && !currentAuth.getName().equals("anonymousUser"); + + if (linking) { User currentUser = accountService.getCurrentUser(); @@ -53,10 +57,8 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic } } - if ("gitlab".equals(provider)) { - throw new InvalidAuthenticationRequestException( - "GitLab can only be linked from an existing Modtale account." - ); + if ("gitlab".equalsIgnoreCase(provider)) { + throw new InvalidAuthenticationRequestException("GitLab can be linked from profile settings, but it cannot be used to sign in."); } return authenticationService.processUserLogin(provider, oauthUser, accessToken); @@ -74,6 +76,13 @@ private Authentication currentAuthentication() { return request != null && request.getUserPrincipal() instanceof Authentication auth ? auth : null; } + private boolean hasPendingLauncherOAuth() { + HttpServletRequest request = requestProvider.getIfAvailable(); + HttpSession session = request == null ? null : request.getSession(false); + return session != null + && session.getAttribute(LauncherAuthService.OAUTH_REDIRECT_URI_SESSION_ATTRIBUTE) instanceof String; + } + protected OAuth2User fetchOAuthUser(OAuth2UserRequest userRequest) { return super.loadUser(userRequest); } diff --git a/backend/src/main/java/net/modtale/service/auth/OidcLoginService.java b/backend/src/main/java/net/modtale/service/auth/OidcLoginService.java index 323871a4..d98bfff8 100644 --- a/backend/src/main/java/net/modtale/service/auth/OidcLoginService.java +++ b/backend/src/main/java/net/modtale/service/auth/OidcLoginService.java @@ -1,6 +1,7 @@ package net.modtale.service.auth; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; import java.util.Map; import net.modtale.exception.AuthenticationOperationException; import net.modtale.exception.ForbiddenOperationException; @@ -49,7 +50,8 @@ public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2Authenticatio Authentication currentAuth = currentAuthentication(); HttpServletRequest request = currentRequest(); - if (currentAuth != null && currentAuth.isAuthenticated() && !currentAuth.getName().equals("anonymousUser")) { + if (!hasPendingLauncherOAuth() + && currentAuth != null && currentAuth.isAuthenticated() && !currentAuth.getName().equals("anonymousUser")) { String pendingOrgId = request != null ? (String) request.getSession().getAttribute("pending_org_link_id") : null; @@ -89,6 +91,13 @@ private HttpServletRequest currentRequest() { return requestProvider.getIfAvailable(); } + private boolean hasPendingLauncherOAuth() { + HttpServletRequest request = currentRequest(); + HttpSession session = request == null ? null : request.getSession(false); + return session != null + && session.getAttribute(LauncherAuthService.OAUTH_REDIRECT_URI_SESSION_ATTRIBUTE) instanceof String; + } + protected OidcUser fetchOidcUser(OidcUserRequest userRequest) { return super.loadUser(userRequest); } diff --git a/backend/src/main/java/net/modtale/service/project/version/VersionDownloadOrchestrationService.java b/backend/src/main/java/net/modtale/service/project/version/VersionDownloadOrchestrationService.java index e7681e31..003ab1fa 100644 --- a/backend/src/main/java/net/modtale/service/project/version/VersionDownloadOrchestrationService.java +++ b/backend/src/main/java/net/modtale/service/project/version/VersionDownloadOrchestrationService.java @@ -5,6 +5,7 @@ import net.modtale.config.properties.AppFrontendProperties; import net.modtale.exception.InvalidDownloadTokenException; import net.modtale.exception.ResourceNotFoundException; +import net.modtale.exception.UnauthorizedException; import net.modtale.exception.VersionNotFoundException; import net.modtale.model.dto.response.project.BundleDownloadUrlResponse; import net.modtale.model.dto.response.project.DownloadUrlResponse; @@ -63,7 +64,7 @@ public DownloadUrlResponse createDownloadUrl(String projectId, String versionNum "We couldn't find that project, so no download link could be generated."); getVersionOrThrow(project, versionNumber, gameVersion, "We couldn't find the requested version for that project."); - String token = downloadTokenService.generateToken(projectId, versionNumber, gameVersion); + String token = downloadTokenService.generateToken(projectId, versionNumber, gameVersion, null, currentUserId(currentUser)); return new DownloadUrlResponse("/download/" + token, downloadTokenService.getTokenValiditySeconds()); } @@ -78,7 +79,7 @@ public BundleDownloadUrlResponse createBundleDownloadUrl( "We couldn't find that project, so no bundle download link could be generated."); getVersionOrThrow(project, versionNumber, gameVersion, "We couldn't find the requested version for that bundle download."); - String token = downloadTokenService.generateToken(projectId, versionNumber, gameVersion, dependencies); + String token = downloadTokenService.generateToken(projectId, versionNumber, gameVersion, dependencies, currentUserId(currentUser)); return new BundleDownloadUrlResponse("/download-bundle/" + token, downloadTokenService.getTokenValiditySeconds()); } @@ -90,12 +91,12 @@ public VersionDownloadPayload downloadVersion( String forwardedFor, User currentUser ) throws IOException { - DownloadContext context = resolveDownloadContext(apiRole, referer, remoteAddress, forwardedFor, currentUser); DownloadTokenService.DownloadToken downloadToken = validateToken(token, "This download link is invalid, expired, or has already been used."); + DownloadContext context = resolveDownloadContext(downloadToken, apiRole, referer, remoteAddress, forwardedFor, currentUser); Project project = getRawProjectOrThrow(downloadToken.getProjectId(), "We couldn't find the project for this download link."); - ensureReadable(project, currentUser); + ensureReadable(project, context.currentUser()); ProjectVersion targetVersion = getVersionOrThrow(project, downloadToken.getVersion(), downloadToken.getGameVersion(), "We couldn't find the version requested by this download link."); @@ -121,12 +122,12 @@ public VersionDownloadPayload downloadBundle( String forwardedFor, User currentUser ) throws IOException { - DownloadContext context = resolveDownloadContext(apiRole, referer, remoteAddress, forwardedFor, currentUser); DownloadTokenService.DownloadToken downloadToken = validateToken(token, "This bundle download link is invalid, expired, or has already been used."); + DownloadContext context = resolveDownloadContext(downloadToken, apiRole, referer, remoteAddress, forwardedFor, currentUser); Project project = getRawProjectOrThrow(downloadToken.getProjectId(), "We couldn't find the project for this bundle download link."); - ensureReadable(project, currentUser); + ensureReadable(project, context.currentUser()); ProjectVersion targetVersion = getVersionOrThrow(project, downloadToken.getVersion(), downloadToken.getGameVersion(), "We couldn't find the version requested by this bundle download link."); @@ -152,15 +153,17 @@ public VersionDownloadPayload downloadBundle( } private DownloadContext resolveDownloadContext( + DownloadTokenService.DownloadToken downloadToken, boolean apiRole, String referer, String remoteAddress, String forwardedFor, User currentUser ) { + User effectiveUser = requireTokenUser(downloadToken, currentUser); boolean apiRequest = apiRole || referer == null || !referer.startsWith(frontendUrl); String clientIp = forwardedFor == null ? remoteAddress : forwardedFor.split(",")[0].trim(); - return new DownloadContext(apiRequest, clientIp, currentUser); + return new DownloadContext(apiRequest, clientIp, effectiveUser); } private DownloadTokenService.DownloadToken validateToken(String token, String failureMessage) { @@ -171,6 +174,22 @@ private DownloadTokenService.DownloadToken validateToken(String token, String fa return downloadToken; } + private User requireTokenUser(DownloadTokenService.DownloadToken downloadToken, User currentUser) { + String tokenUserId = downloadToken.getUserId(); + if (tokenUserId == null || tokenUserId.isBlank()) { + return currentUser; + } + + if (currentUser == null || currentUser.getId() == null || !tokenUserId.equals(currentUser.getId())) { + throw new UnauthorizedException("Sign in with the account that created this download link before using it."); + } + return currentUser; + } + + private String currentUserId(User currentUser) { + return currentUser == null ? null : currentUser.getId(); + } + private Project getProjectOrThrow(String projectId, User currentUser, String failureMessage) { Project project = projectService.getProjectById(projectId, currentUser); if (project == null) { diff --git a/backend/src/main/java/net/modtale/service/storage/DownloadTokenService.java b/backend/src/main/java/net/modtale/service/storage/DownloadTokenService.java index 62bbaccd..d94b540b 100644 --- a/backend/src/main/java/net/modtale/service/storage/DownloadTokenService.java +++ b/backend/src/main/java/net/modtale/service/storage/DownloadTokenService.java @@ -21,15 +21,21 @@ public static class DownloadToken { private final String projectId; private final String version; private final String gameVersion; + private final String userId; private final Instant expiresAt; private final List selectedDependencies; private boolean used; public DownloadToken(String projectId, String version, String gameVersion, List selectedDependencies, Instant expiresAt) { + this(projectId, version, gameVersion, selectedDependencies, null, expiresAt); + } + + public DownloadToken(String projectId, String version, String gameVersion, List selectedDependencies, String userId, Instant expiresAt) { this.projectId = projectId; this.version = version; this.gameVersion = gameVersion; this.selectedDependencies = selectedDependencies; + this.userId = userId; this.expiresAt = expiresAt; this.used = false; } @@ -37,6 +43,7 @@ public DownloadToken(String projectId, String version, String gameVersion, List< public String getProjectId() { return projectId; } public String getVersion() { return version; } public String getGameVersion() { return gameVersion; } + public String getUserId() { return userId; } public List getSelectedDependencies() { return selectedDependencies; } public Instant getExpiresAt() { return expiresAt; } public boolean isUsed() { return used; } @@ -48,6 +55,10 @@ public boolean isExpired() { } public String generateToken(String projectId, String version, String gameVersion, List selectedDependencies) { + return generateToken(projectId, version, gameVersion, selectedDependencies, null); + } + + public String generateToken(String projectId, String version, String gameVersion, List selectedDependencies, String userId) { cleanExpiredTokens(); byte[] randomBytes = new byte[TOKEN_LENGTH]; @@ -55,7 +66,7 @@ public String generateToken(String projectId, String version, String gameVersion String token = Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes); Instant expiresAt = Instant.now().plusSeconds(TOKEN_VALIDITY_MINUTES * 60); - tokens.put(token, new DownloadToken(projectId, version, gameVersion, selectedDependencies, expiresAt)); + tokens.put(token, new DownloadToken(projectId, version, gameVersion, selectedDependencies, userId, expiresAt)); return token; } diff --git a/backend/src/main/java/net/modtale/service/user/account/AccountService.java b/backend/src/main/java/net/modtale/service/user/account/AccountService.java index 2fdf6089..3cf3dbb3 100644 --- a/backend/src/main/java/net/modtale/service/user/account/AccountService.java +++ b/backend/src/main/java/net/modtale/service/user/account/AccountService.java @@ -7,6 +7,7 @@ import net.modtale.exception.InvalidAccountRequestException; import net.modtale.exception.ResourceNotFoundException; import net.modtale.model.project.Project; +import net.modtale.model.user.LauncherSettingsSnapshot; import net.modtale.model.user.OAuthProvider; import net.modtale.model.user.User; import net.modtale.repository.user.UserRepository; @@ -26,6 +27,11 @@ @Service public class AccountService { + private static final int MAX_LAUNCHER_SYNC_PROJECTS = 500; + private static final int MAX_LAUNCHER_SYNC_LIST_ITEMS = 64; + private static final int MAX_LAUNCHER_SYNC_STRING = 512; + private static final int MAX_LAUNCHER_SYNC_HASH = 128; + private final UserRepository userRepository; private final MongoTemplate mongoTemplate; private final SanitizationService sanitizer; @@ -161,6 +167,27 @@ public void updateNotificationPreferences(String userId, User.NotificationPrefer userRepository.save(user); } + public LauncherSettingsSnapshot getLauncherSettings(String userId) { + User user = userRepository.findById(userId).orElseThrow(() -> new ResourceNotFoundException("User not found.")); + return user.getLauncherSettings() == null ? new LauncherSettingsSnapshot() : user.getLauncherSettings(); + } + + public LauncherSettingsSnapshot updateLauncherSettings(String userId, LauncherSettingsSnapshot snapshot) { + User user = userRepository.findById(userId).orElseThrow(() -> new ResourceNotFoundException("User not found.")); + LauncherSettingsSnapshot normalized = normalizeLauncherSettings(snapshot); + user.setLauncherSettings(normalized); + userRepository.save(user); + return normalized; + } + + public LauncherSettingsSnapshot updateLauncherSettingsPreferences(String userId, LauncherSettingsSnapshot snapshot) { + User user = userRepository.findById(userId).orElseThrow(() -> new ResourceNotFoundException("User not found.")); + LauncherSettingsSnapshot normalized = normalizeLauncherSettingsPreferences(snapshot, user.getLauncherSettings()); + user.setLauncherSettings(normalized); + userRepository.save(user); + return normalized; + } + public void toggleConnectionVisibility(String userId, String provider) { if ("google".equalsIgnoreCase(provider) || "hytale".equalsIgnoreCase(provider)) { throw new InvalidAccountRequestException(provider + " accounts cannot be made visible on public profiles."); @@ -214,6 +241,145 @@ public void recoverUser(String userId) { accountLifecycleService.recoverUser(userId); } + private LauncherSettingsSnapshot normalizeLauncherSettings(LauncherSettingsSnapshot snapshot) { + LauncherSettingsSnapshot source = snapshot == null ? new LauncherSettingsSnapshot() : snapshot; + LauncherSettingsSnapshot normalized = new LauncherSettingsSnapshot(); + normalized.setSchemaVersion(source.getSchemaVersion()); + normalized.setSettingsHash(limit(source.getSettingsHash(), MAX_LAUNCHER_SYNC_HASH)); + normalized.setUpdatedAt(LocalDateTime.now().toString()); + normalized.setPreferences(normalizeLauncherPreferences(source.getPreferences())); + normalized.setInstalledProjects(normalizeInstalledProjects(source.getInstalledProjects())); + return normalized; + } + + private LauncherSettingsSnapshot normalizeLauncherSettingsPreferences( + LauncherSettingsSnapshot snapshot, + LauncherSettingsSnapshot existing + ) { + LauncherSettingsSnapshot source = snapshot == null ? new LauncherSettingsSnapshot() : snapshot; + LauncherSettingsSnapshot stored = existing == null ? new LauncherSettingsSnapshot() : existing; + LauncherSettingsSnapshot normalized = new LauncherSettingsSnapshot(); + normalized.setSchemaVersion(source.getSchemaVersion()); + normalized.setSettingsHash(limit(source.getSettingsHash(), MAX_LAUNCHER_SYNC_HASH)); + normalized.setUpdatedAt(LocalDateTime.now().toString()); + normalized.setPreferences(normalizeLauncherPreferences(source.getPreferences())); + normalized.setInstalledProjects(normalizeInstalledProjects(stored.getInstalledProjects())); + return normalized; + } + + private LauncherSettingsSnapshot.Preferences normalizeLauncherPreferences(LauncherSettingsSnapshot.Preferences source) { + LauncherSettingsSnapshot.Preferences preferences = new LauncherSettingsSnapshot.Preferences(); + if (source == null) { + return preferences; + } + preferences.setHytaleModsPath(limit(source.getHytaleModsPath(), MAX_LAUNCHER_SYNC_STRING)); + preferences.setHytaleGamePath(limit(source.getHytaleGamePath(), MAX_LAUNCHER_SYNC_STRING)); + preferences.setHytaleUserDataPath(limit(source.getHytaleUserDataPath(), MAX_LAUNCHER_SYNC_STRING)); + preferences.setHytaleJavaPath(limit(source.getHytaleJavaPath(), MAX_LAUNCHER_SYNC_STRING)); + preferences.setHytaleBranch(limit(source.getHytaleBranch(), MAX_LAUNCHER_SYNC_STRING)); + preferences.setHytaleBuild(source.getHytaleBuild()); + preferences.setGameVersion(limit(source.getGameVersion(), MAX_LAUNCHER_SYNC_STRING)); + preferences.setIncludeDependencies(source.isIncludeDependencies()); + preferences.setIncludeOptionalDependencies(source.isIncludeOptionalDependencies()); + preferences.setAutoCheckUpdates(source.isAutoCheckUpdates()); + preferences.setLauncherAutoUpdates(source.isLauncherAutoUpdates()); + return preferences; + } + + private List normalizeInstalledProjects( + List source + ) { + if (source == null || source.isEmpty()) { + return List.of(); + } + List normalized = new ArrayList<>(); + for (LauncherSettingsSnapshot.InstalledProject project : source) { + if (project == null || isBlank(project.getProjectId()) || normalized.size() >= MAX_LAUNCHER_SYNC_PROJECTS) { + continue; + } + LauncherSettingsSnapshot.InstalledProject copy = new LauncherSettingsSnapshot.InstalledProject(); + copy.setProjectId(limit(project.getProjectId(), MAX_LAUNCHER_SYNC_STRING)); + copy.setSlug(limit(project.getSlug(), MAX_LAUNCHER_SYNC_STRING)); + copy.setTitle(limit(project.getTitle(), MAX_LAUNCHER_SYNC_STRING)); + copy.setClassification(limit(project.getClassification(), MAX_LAUNCHER_SYNC_STRING)); + copy.setInstalledVersion(limit(project.getInstalledVersion(), MAX_LAUNCHER_SYNC_STRING)); + copy.setInstalledVersionId(limit(project.getInstalledVersionId(), MAX_LAUNCHER_SYNC_STRING)); + copy.setGameVersion(limit(project.getGameVersion(), MAX_LAUNCHER_SYNC_STRING)); + copy.setSource(defaultValue(limit(project.getSource(), MAX_LAUNCHER_SYNC_STRING), "MODTALE")); + copy.setInstallType(defaultValue(limit(project.getInstallType(), MAX_LAUNCHER_SYNC_STRING), "DIRECT")); + copy.setModpackUnlocked(project.isModpackUnlocked()); + copy.setDependencyProjectIds(normalizeStringList(project.getDependencyProjectIds())); + copy.setExternalDependencies(normalizeStringList(project.getExternalDependencies())); + copy.setBundledProjects(normalizeInstalledProjectReferences(project.getBundledProjects())); + normalized.add(copy); + } + return normalized; + } + + private List normalizeInstalledProjectReferences( + List source + ) { + if (source == null || source.isEmpty()) { + return List.of(); + } + List normalized = new ArrayList<>(); + for (LauncherSettingsSnapshot.InstalledProjectReference reference : source) { + if (reference == null || normalized.size() >= MAX_LAUNCHER_SYNC_LIST_ITEMS) { + continue; + } + LauncherSettingsSnapshot.InstalledProjectReference copy = + new LauncherSettingsSnapshot.InstalledProjectReference(); + copy.setId(limit(reference.getId(), MAX_LAUNCHER_SYNC_STRING)); + copy.setProjectId(limit(reference.getProjectId(), MAX_LAUNCHER_SYNC_STRING)); + copy.setSlug(limit(reference.getSlug(), MAX_LAUNCHER_SYNC_STRING)); + copy.setTitle(limit(reference.getTitle(), MAX_LAUNCHER_SYNC_STRING)); + copy.setClassification(limit(reference.getClassification(), MAX_LAUNCHER_SYNC_STRING)); + copy.setVersionNumber(limit(reference.getVersionNumber(), MAX_LAUNCHER_SYNC_STRING)); + copy.setDependencyType(limit(reference.getDependencyType(), MAX_LAUNCHER_SYNC_STRING)); + copy.setSource(limit(reference.getSource(), MAX_LAUNCHER_SYNC_STRING)); + copy.setExternalId(limit(reference.getExternalId(), MAX_LAUNCHER_SYNC_STRING)); + copy.setExternalUrl(limit(reference.getExternalUrl(), MAX_LAUNCHER_SYNC_STRING)); + copy.setExternalFileUrl(limit(reference.getExternalFileUrl(), MAX_LAUNCHER_SYNC_STRING)); + copy.setExternalFileName(limit(reference.getExternalFileName(), MAX_LAUNCHER_SYNC_STRING)); + copy.setCachedFileUrl(limit(reference.getCachedFileUrl(), MAX_LAUNCHER_SYNC_STRING)); + copy.setIcon(limit(reference.getIcon(), MAX_LAUNCHER_SYNC_STRING)); + copy.setOptional(reference.getOptional()); + copy.setEmbedded(reference.getEmbedded()); + normalized.add(copy); + } + return normalized; + } + + private List normalizeStringList(List source) { + if (source == null || source.isEmpty()) { + return List.of(); + } + List normalized = new ArrayList<>(); + for (String value : source) { + String next = limit(value, MAX_LAUNCHER_SYNC_STRING); + if (!next.isBlank() && !normalized.contains(next)) { + normalized.add(next); + } + if (normalized.size() >= MAX_LAUNCHER_SYNC_LIST_ITEMS) { + break; + } + } + return normalized; + } + + private static String limit(String value, int maxLength) { + String trimmed = value == null ? "" : value.trim(); + return trimmed.length() <= maxLength ? trimmed : trimmed.substring(0, maxLength); + } + + private static String defaultValue(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value; + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } + @Scheduled(cron = "0 0 0 * * ?") public void cleanupDeletedUsers() { accountLifecycleService.cleanupDeletedUsers(); diff --git a/backend/src/main/java/net/modtale/service/worldlist/WorldModListArchiveService.java b/backend/src/main/java/net/modtale/service/worldlist/WorldModListArchiveService.java new file mode 100644 index 00000000..72b9c23d --- /dev/null +++ b/backend/src/main/java/net/modtale/service/worldlist/WorldModListArchiveService.java @@ -0,0 +1,115 @@ +package net.modtale.service.worldlist; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; +import net.modtale.exception.StorageDownloadException; +import net.modtale.model.worldlist.WorldModList; +import net.modtale.service.storage.StorageService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectWriter; + +@Service +public class WorldModListArchiveService { + + private static final Logger logger = LoggerFactory.getLogger(WorldModListArchiveService.class); + private final StorageService storageService; + private final ObjectWriter manifestWriter; + + public WorldModListArchiveService(StorageService storageService, ObjectMapper mapper) { + this.storageService = storageService; + this.manifestWriter = mapper.writerWithDefaultPrettyPrinter(); + } + + public byte[] generateZip(WorldModList list) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(bytes)) { + Set entries = new HashSet<>(); + writeEntry(zip, entries, "modtale-list.json", manifestWriter.writeValueAsBytes(list)); + writeEntry(zip, entries, "README.txt", readme(list).getBytes(StandardCharsets.UTF_8)); + + for (WorldModList.Item item : list.getMods()) { + if (!item.isDownloadable() || item.getFileUrl() == null || item.getFileUrl().isBlank()) { + continue; + } + try { + byte[] file = storageService.download(item.getFileUrl()); + writeEntry(zip, entries, filename(item), file); + } catch (StorageDownloadException ex) { + logger.warn("Skipping unavailable world list file {} for list {}", item.getFileUrl(), list.getId(), ex); + } + } + } + return bytes.toByteArray(); + } + + private void writeEntry(ZipOutputStream zip, Set entries, String rawName, byte[] data) throws IOException { + String entryName = unique(entries, sanitize(rawName)); + zip.putNextEntry(new ZipEntry(entryName)); + zip.write(data == null ? new byte[0] : data); + zip.closeEntry(); + } + + private String filename(WorldModList.Item item) { + String base = firstText(item.getTitle(), item.getSlug(), item.getProjectId(), item.getModId(), "mod"); + String version = firstText(item.getVersionNumber(), "latest"); + return base + "-" + version + ".jar"; + } + + private String readme(WorldModList list) { + return "Modtale world mod list\n" + + "World: " + firstText(list.getWorldName(), "Unknown world") + "\n" + + "List: " + firstText(list.getTitle(), "Shared mod list") + "\n" + + "Game version: " + firstText(list.getGameVersion(), "Not specified") + "\n\n" + + "This ZIP contains the downloadable Modtale projects from the shared list. " + + "Some local or external entries may appear only in modtale-list.json."; + } + + private static String unique(Set entries, String filename) { + String candidate = filename; + int counter = 2; + while (!entries.add(candidate)) { + int dot = filename.lastIndexOf('.'); + candidate = dot > 0 + ? filename.substring(0, dot) + "-" + counter + filename.substring(dot) + : filename + "-" + counter; + counter++; + } + return candidate; + } + + private static String sanitize(String filename) { + String sanitized = firstText(filename, "modtale-list-file") + .replaceAll("[^A-Za-z0-9._-]+", "-") + .replaceAll("-+", "-") + .replaceAll("(^-|-$)", ""); + if (sanitized.isBlank()) { + return "modtale-list-file"; + } + String lower = sanitized.toLowerCase(Locale.ROOT); + if (lower.endsWith(".txt") || lower.endsWith(".json") || lower.endsWith(".jar") || lower.endsWith(".zip")) { + return sanitized; + } + return sanitized + ".jar"; + } + + private static String firstText(String... values) { + if (values == null) { + return ""; + } + for (String value : values) { + if (value != null && !value.isBlank()) { + return value.trim(); + } + } + return ""; + } +} diff --git a/backend/src/main/java/net/modtale/service/worldlist/WorldModListMapper.java b/backend/src/main/java/net/modtale/service/worldlist/WorldModListMapper.java new file mode 100644 index 00000000..89149a6c --- /dev/null +++ b/backend/src/main/java/net/modtale/service/worldlist/WorldModListMapper.java @@ -0,0 +1,86 @@ +package net.modtale.service.worldlist; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.List; +import net.modtale.config.properties.AppFrontendProperties; +import net.modtale.model.dto.worldlist.WorldModListDTO; +import net.modtale.model.worldlist.WorldModList; +import org.springframework.stereotype.Component; + +@Component +final class WorldModListMapper { + + private final String frontendUrl; + + WorldModListMapper(AppFrontendProperties frontendProperties) { + this.frontendUrl = trimTrailingSlash(frontendProperties.url()); + } + + WorldModListDTO toDTO(WorldModList list) { + if (list == null) { + return null; + } + int downloadable = (int) list.getMods().stream() + .filter(WorldModList.Item::isDownloadable) + .count(); + String shareUrl = frontendUrl + "/lists/" + list.getId(); + String downloadUrl = "/lists/" + list.getId() + "/download"; + String launcherInstallUrl = "modtale://install-list?listId=" + encode(list.getId()) + "&url=" + encode(shareUrl); + return new WorldModListDTO( + list.getId(), + list.getTitle(), + list.getWorldName(), + list.getGameVersion(), + list.getOwnerUsername(), + list.getCreatedAt(), + list.getLastViewedAt(), + list.getExpiresAt(), + list.getViewCount(), + list.getDownloadCount(), + list.getMods().size(), + downloadable, + shareUrl, + downloadUrl, + launcherInstallUrl, + list.getMods().stream().map(this::toItemDTO).toList() + ); + } + + private WorldModListDTO.Item toItemDTO(WorldModList.Item item) { + return new WorldModListDTO.Item( + item.getId(), + item.getModId(), + item.getProjectId(), + item.getSlug(), + item.getTitle(), + item.getAuthorId(), + item.getAuthor(), + item.getDescription(), + item.getVersionNumber(), + item.getClassification(), + item.getSource(), + item.getExternalId(), + item.getExternalUrl(), + item.getIcon(), + item.getBannerUrl(), + item.getDownloadCount(), + item.getFavoriteCount(), + item.getUpdatedAt(), + item.isDownloadable(), + item.getUnavailableReason() + ); + } + + private static String trimTrailingSlash(String value) { + String normalized = value == null || value.isBlank() ? "https://modtale.net" : value.trim(); + while (normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return normalized; + } + + private static String encode(String value) { + return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8).replace("+", "%20"); + } +} diff --git a/backend/src/main/java/net/modtale/service/worldlist/WorldModListService.java b/backend/src/main/java/net/modtale/service/worldlist/WorldModListService.java new file mode 100644 index 00000000..f026d2cb --- /dev/null +++ b/backend/src/main/java/net/modtale/service/worldlist/WorldModListService.java @@ -0,0 +1,343 @@ +package net.modtale.service.worldlist; + +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import net.modtale.exception.InvalidProjectRequestException; +import net.modtale.exception.ResourceNotFoundException; +import net.modtale.model.dto.request.worldlist.CreateWorldModListRequest; +import net.modtale.model.dto.worldlist.WorldModListDTO; +import net.modtale.model.project.Project; +import net.modtale.model.project.ProjectDependency; +import net.modtale.model.project.ProjectVersion; +import net.modtale.model.user.User; +import net.modtale.model.worldlist.WorldModList; +import net.modtale.repository.worldlist.WorldModListRepository; +import net.modtale.service.project.access.ProjectVersionAccessService; +import net.modtale.service.project.query.ProjectService; +import net.modtale.service.security.access.AccessControlService; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +@Service +public class WorldModListService { + + private static final Duration EXPIRY_WINDOW = Duration.ofDays(30); + + private final WorldModListRepository repository; + private final ProjectService projectService; + private final ProjectVersionAccessService versionAccessService; + private final AccessControlService accessControlService; + private final WorldModListArchiveService archiveService; + private final WorldModListMapper mapper; + + public WorldModListService( + WorldModListRepository repository, + ProjectService projectService, + ProjectVersionAccessService versionAccessService, + AccessControlService accessControlService, + WorldModListArchiveService archiveService, + WorldModListMapper mapper + ) { + this.repository = repository; + this.projectService = projectService; + this.versionAccessService = versionAccessService; + this.accessControlService = accessControlService; + this.archiveService = archiveService; + this.mapper = mapper; + } + + public WorldModListDTO create(CreateWorldModListRequest request, User owner) { + if (owner == null) { + throw new InvalidProjectRequestException("Sign in before sharing a world mod list."); + } + List requestedMods = request.mods() == null ? List.of() : request.mods(); + Map items = new LinkedHashMap<>(); + for (CreateWorldModListRequest.Item requested : requestedMods) { + WorldModList.Item item = normalizeItem(requested, request.gameVersion(), owner); + String key = itemKey(item); + if (!key.isBlank()) { + items.putIfAbsent(key, item); + } + } + if (items.isEmpty()) { + throw new InvalidProjectRequestException("Pick at least one enabled mod before sharing this list."); + } + + Instant now = Instant.now(); + WorldModList list = new WorldModList(); + list.setOwnerId(owner.getId()); + list.setOwnerUsername(owner.getUsername()); + list.setTitle(firstText(request.title(), request.worldName(), "Shared world mods")); + list.setWorldName(firstText(request.worldName(), "Hytale world")); + list.setGameVersion(value(request.gameVersion())); + list.setCreatedAt(now); + list.setLastViewedAt(now); + list.setExpiresAt(now.plus(EXPIRY_WINDOW)); + list.setMods(items.values().stream().toList()); + return mapper.toDTO(repository.save(list)); + } + + public WorldModListDTO view(String id) { + return mapper.toDTO(touch(findActive(id), true, false)); + } + + public WorldModListDTO metadataForInstall(String id) { + return mapper.toDTO(touch(findActive(id), true, false)); + } + + public Download download(String id) throws IOException { + WorldModList list = touch(findActive(id), true, true); + return new Download(filename(list), archiveService.generateZip(list)); + } + + @Scheduled(cron = "${app.world-lists.cleanup-cron:0 20 3 * * ?}") + public void cleanupExpiredLists() { + repository.deleteByExpiresAtBefore(Instant.now()); + } + + private WorldModList touch(WorldModList list, boolean view, boolean download) { + refreshPublicProjectMetadata(list); + Instant now = Instant.now(); + list.setLastViewedAt(now); + list.setExpiresAt(now.plus(EXPIRY_WINDOW)); + if (view) { + list.setViewCount(list.getViewCount() + 1); + } + if (download) { + list.setDownloadCount(list.getDownloadCount() + 1); + } + return repository.save(list); + } + + private WorldModList findActive(String id) { + WorldModList list = repository.findById(id == null ? "" : id.trim()).orElse(null); + if (list == null || isExpired(list)) { + throw new ResourceNotFoundException("That shared mod list is gone or has expired."); + } + return list; + } + + private boolean isExpired(WorldModList list) { + return list.getExpiresAt() != null && Instant.now().isAfter(list.getExpiresAt()); + } + + private void refreshPublicProjectMetadata(WorldModList list) { + if (list == null || list.getMods() == null || list.getMods().isEmpty()) { + return; + } + for (WorldModList.Item item : list.getMods()) { + Project project = projectFor(item); + if (project == null || !accessControlService.isPubliclyReadable(project)) { + continue; + } + applyProjectMetadata(item, project); + item.setSource(ProjectDependency.Source.MODTALE); + } + } + + private WorldModList.Item normalizeItem(CreateWorldModListRequest.Item requested, String gameVersion, User owner) { + WorldModList.Item item = new WorldModList.Item(); + if (requested == null) { + item.setUnavailableReason("Empty list item."); + return item; + } + + item.setModId(value(requested.modId())); + item.setProjectId(value(requested.projectId())); + item.setSlug(value(requested.slug())); + item.setTitle(firstText(requested.title(), requested.modId(), requested.projectId(), "Unknown mod")); + item.setVersionNumber(value(requested.versionNumber())); + item.setClassification(requested.classification()); + item.setSource(requested.source() == null ? sourceFor(requested) : requested.source()); + item.setExternalId(value(requested.externalId())); + item.setExternalUrl(value(requested.externalUrl())); + item.setIcon(value(requested.icon())); + + if (item.getSource() == ProjectDependency.Source.MODTALE || !item.getProjectId().isBlank()) { + enrichModtaleItem(item, gameVersion, owner); + } else { + item.setDownloadable(false); + item.setUnavailableReason("Listed only; Modtale cannot package this external or local file."); + } + return item; + } + + private void enrichModtaleItem(WorldModList.Item item, String gameVersion, User owner) { + Project project = projectFor(item); + if (project == null || !accessControlService.isPubliclyReadable(project) || !accessControlService.canReadProject(project, owner)) { + item.setDownloadable(false); + item.setUnavailableReason("Project is not public on Modtale."); + return; + } + + ProjectVersion version = versionFor(project, item.getVersionNumber(), gameVersion); + applyProjectMetadata(item, project); + item.setSource(ProjectDependency.Source.MODTALE); + item.setVersionNumber(version == null ? item.getVersionNumber() : version.getVersionNumber()); + item.setFileUrl(version == null ? "" : value(version.getFileUrl())); + item.setDownloadable(version != null && version.getFileUrl() != null && !version.getFileUrl().isBlank()); + if (!item.isDownloadable()) { + item.setUnavailableReason("No downloadable public version could be resolved."); + } + } + + private void applyProjectMetadata(WorldModList.Item item, Project project) { + item.setProjectId(project.getId()); + item.setSlug(firstText(project.getSlug(), project.getId())); + item.setTitle(firstText(project.getTitle(), item.getTitle())); + item.setAuthorId(value(project.getAuthorId())); + item.setAuthor(value(project.getAuthor())); + item.setDescription(value(project.getDescription())); + item.setClassification(project.getClassification()); + item.setIcon(firstText(project.getImageUrl(), item.getIcon())); + item.setBannerUrl(value(project.getBannerUrl())); + item.setDownloadCount(project.getDownloadCount()); + item.setFavoriteCount(project.getFavoriteCount()); + item.setUpdatedAt(value(project.getUpdatedAt())); + } + + private Project projectFor(WorldModList.Item item) { + if (item == null) { + return null; + } + String projectId = value(item.getProjectId()); + if (!projectId.isBlank()) { + Project project = projectService.getRawProjectById(projectId); + if (project != null) { + return project; + } + } + String slug = value(item.getSlug()); + if (!slug.isBlank()) { + Project project = projectService.getRawProjectByRouteKey(slug); + if (project != null) { + return project; + } + } + for (String candidate : routeKeyCandidates(item)) { + Project project = projectService.getRawProjectByRouteKey(candidate); + if (project != null) { + return project; + } + } + return null; + } + + private Set routeKeyCandidates(WorldModList.Item item) { + LinkedHashSet candidates = new LinkedHashSet<>(); + addRouteKeyCandidates(candidates, item.getExternalId()); + addRouteKeyCandidates(candidates, item.getModId()); + addRouteKeyCandidates(candidates, item.getTitle()); + return candidates; + } + + private void addRouteKeyCandidates(Set candidates, String value) { + String normalized = value(value); + if (normalized.isBlank()) { + return; + } + candidates.add(normalized); + addSlugCandidates(candidates, normalized); + + int separator = normalized.lastIndexOf(':'); + if (separator >= 0 && separator < normalized.length() - 1) { + String suffix = normalized.substring(separator + 1).trim(); + if (!suffix.isBlank()) { + candidates.add(suffix); + addSlugCandidates(candidates, suffix); + } + } + } + + private void addSlugCandidates(Set candidates, String value) { + String camelSeparated = value.replaceAll("([a-z0-9])([A-Z])", "$1-$2"); + String slug = camelSeparated.toLowerCase(java.util.Locale.ROOT) + .replaceAll("[^a-z0-9]+", "-") + .replaceAll("(^-|-$)", ""); + if (!slug.isBlank()) { + candidates.add(slug); + } + + String compact = value.toLowerCase(java.util.Locale.ROOT) + .replaceAll("[^a-z0-9]+", ""); + if (!compact.isBlank()) { + candidates.add(compact); + } + } + + private ProjectVersion versionFor(Project project, String versionNumber, String gameVersion) { + ProjectVersion version = null; + if (versionNumber != null && !versionNumber.isBlank()) { + version = versionAccessService.findByVersionNumber(project, versionNumber, gameVersion); + } + if (isApproved(version)) { + return version; + } + return project.getVersions() == null ? null : project.getVersions().stream() + .filter(this::isApproved) + .filter(candidate -> supportsGameVersion(candidate, gameVersion)) + .max(Comparator.comparing(ProjectVersion::getReleaseDate, Comparator.nullsLast(String::compareTo))) + .orElse(null); + } + + private boolean isApproved(ProjectVersion version) { + return version != null && version.getReviewStatus() == ProjectVersion.ReviewStatus.APPROVED; + } + + private boolean supportsGameVersion(ProjectVersion version, String gameVersion) { + return gameVersion == null + || gameVersion.isBlank() + || version.getGameVersions() == null + || version.getGameVersions().stream().anyMatch(gameVersion::equalsIgnoreCase); + } + + private ProjectDependency.Source sourceFor(CreateWorldModListRequest.Item item) { + return item.projectId() == null || item.projectId().isBlank() + ? ProjectDependency.Source.OTHER + : ProjectDependency.Source.MODTALE; + } + + private String itemKey(WorldModList.Item item) { + if (!item.getProjectId().isBlank()) { + return item.getSource() + ":" + item.getProjectId(); + } + if (!item.getExternalId().isBlank()) { + return item.getSource() + ":" + item.getExternalId(); + } + return firstText(item.getModId(), item.getTitle()); + } + + private String filename(WorldModList list) { + String base = firstText(list.getWorldName(), list.getTitle(), "world-mod-list") + .replaceAll("[^A-Za-z0-9._-]+", "-") + .replaceAll("-+", "-") + .replaceAll("(^-|-$)", ""); + return (base.isBlank() ? "world-mod-list" : base) + "-mods.zip"; + } + + private static String value(String value) { + return value == null ? "" : value.trim(); + } + + private static String firstText(String... values) { + if (values == null) { + return ""; + } + for (String value : values) { + if (value != null && !value.isBlank()) { + return value.trim(); + } + } + return ""; + } + + public record Download(String filename, byte[] bytes) { + } +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 5ff2cc37..bff3d6b8 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -137,7 +137,7 @@ app.warden.request-timeout-seconds=${WARDEN_REQUEST_TIMEOUT_SECONDS:75} app.security.pre-auth-secret=${PRE_AUTH_SECRET:c6677126-3ae0-4318-807e-b1af48b9f36c} app.security.pre-auth-expiry-seconds=600 -app.seeding.enabled=${APP_SEEDING_ENABLED:${SEEDING_ENABLED:false}} +app.seeding.enabled=${APP_SEEDING_ENABLED:false} app.seeding.mode=${APP_SEEDING_MODE:${SEEDING_MODE:clone}} app.seeding.reset=${APP_SEEDING_RESET:${SEEDING_RESET:false}} app.seeding.source-db=${APP_SEEDING_SOURCE_DB:${SEEDING_SOURCE_DB:modtale}} diff --git a/backend/src/test/java/net/modtale/config/auth/HytaleAuthorizationRequestResolverTest.java b/backend/src/test/java/net/modtale/config/auth/HytaleAuthorizationRequestResolverTest.java new file mode 100644 index 00000000..67ddf8d6 --- /dev/null +++ b/backend/src/test/java/net/modtale/config/auth/HytaleAuthorizationRequestResolverTest.java @@ -0,0 +1,74 @@ +package net.modtale.config.auth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.security.oauth2.core.endpoint.PkceParameterNames; + +class HytaleAuthorizationRequestResolverTest { + + @Test + void addsS256PkceToConfidentialHytaleAuthorizationRequest() { + HytaleAuthorizationRequestResolver resolver = resolver(); + + OAuth2AuthorizationRequest authorizationRequest = + resolver.resolve(requestFor("hytale")); + + assertNotNull(authorizationRequest); + assertNotNull(authorizationRequest.getAttribute(PkceParameterNames.CODE_VERIFIER)); + assertEquals( + "S256", + authorizationRequest.getAdditionalParameters() + .get(PkceParameterNames.CODE_CHALLENGE_METHOD) + ); + assertNotNull( + authorizationRequest.getAdditionalParameters() + .get(PkceParameterNames.CODE_CHALLENGE) + ); + } + + private static HytaleAuthorizationRequestResolver resolver() { + return new HytaleAuthorizationRequestResolver( + new InMemoryClientRegistrationRepository( + registration("hytale"), + registration("other") + ) + ); + } + + private static ClientRegistration registration(String registrationId) { + return ClientRegistration.withRegistrationId(registrationId) + .clientId(registrationId + "-client") + .clientSecret("secret") + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .scope("openid") + .authorizationUri("https://issuer.example/oauth2/auth") + .tokenUri("https://issuer.example/oauth2/token") + .jwkSetUri("https://issuer.example/jwks") + .userInfoUri("https://issuer.example/userinfo") + .userNameAttributeName("sub") + .clientName(registrationId) + .build(); + } + + private static MockHttpServletRequest requestFor(String registrationId) { + MockHttpServletRequest request = new MockHttpServletRequest( + "GET", + "/oauth2/authorization/" + registrationId + ); + request.setServletPath("/oauth2/authorization/" + registrationId); + request.setScheme("https"); + request.setServerName("api.modtale.net"); + request.setServerPort(443); + return request; + } +} diff --git a/backend/src/test/java/net/modtale/config/security/SecurityConfigLauncherOAuthTest.java b/backend/src/test/java/net/modtale/config/security/SecurityConfigLauncherOAuthTest.java new file mode 100644 index 00000000..ca884636 --- /dev/null +++ b/backend/src/test/java/net/modtale/config/security/SecurityConfigLauncherOAuthTest.java @@ -0,0 +1,124 @@ +package net.modtale.config.security; + +import java.util.Map; +import java.util.Set; +import net.modtale.config.auth.ApiKeyAuthFilter; +import net.modtale.config.properties.AppFrontendProperties; +import net.modtale.model.user.User; +import net.modtale.service.auth.AuthenticationService; +import net.modtale.service.auth.LauncherAuthService; +import net.modtale.service.auth.LocalUserDetailsService; +import net.modtale.service.auth.OAuth2LoginService; +import net.modtale.service.auth.OidcLoginService; +import net.modtale.service.user.account.AccountService; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class SecurityConfigLauncherOAuthTest { + + @Test + void oauthSuccessRedirectsLauncherOAuthToLoopbackCallbackWithCode() throws Exception { + AccountService accountService = mock(AccountService.class); + LauncherAuthService launcherAuthService = mock(LauncherAuthService.class); + SecurityConfig config = config(accountService, launcherAuthService); + + User user = new User(); + user.setId("user-1"); + user.setUsername("ada"); + user.setRoles(java.util.List.of("USER")); + when(accountService.getPublicProfile("ada")).thenReturn(user); + when(launcherAuthService.issueCode(user, "http://127.0.0.1:49152/callback", "state-123")) + .thenReturn(new LauncherAuthService.LauncherAuthGrant( + "launcher-code", + "http://127.0.0.1:49152/callback", + "state-123", + 300 + )); + + MockHttpServletRequest request = launcherOAuthRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + OAuth2AuthenticationToken authentication = authentication(); + SecurityContextHolder.getContext().setAuthentication(authentication); + + config.oauthSuccessHandler().onAuthenticationSuccess(request, response, authentication); + + assertEquals( + "http://127.0.0.1:49152/callback?code=launcher-code&state=state-123", + response.getRedirectedUrl() + ); + SecurityContextHolder.clearContext(); + } + + @Test + void oauthFailureRedirectsLauncherOAuthToLoopbackCallbackWithError() throws Exception { + SecurityConfig config = config(mock(AccountService.class), mock(LauncherAuthService.class)); + MockHttpServletRequest request = launcherOAuthRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + config.oauthFailureHandler().onAuthenticationFailure( + request, + response, + new OAuth2AuthenticationException(new OAuth2Error("provider_error"), "Provider failed") + ); + + assertEquals( + "http://127.0.0.1:49152/callback?error=Provider+failed&state=state-123", + response.getRedirectedUrl() + ); + } + + private static SecurityConfig config(AccountService accountService, LauncherAuthService launcherAuthService) { + return new SecurityConfig( + mock(ApiKeyAuthFilter.class), + mock(RateLimitFilter.class), + mock(OAuth2LoginService.class), + mock(OidcLoginService.class), + mock(OAuth2AuthorizedClientRepository.class), + mock(LocalUserDetailsService.class), + mock(PasswordEncoder.class), + accountService, + mock(AuthenticationService.class), + launcherAuthService, + new AppFrontendProperties("http://localhost:5173") + ); + } + + private static MockHttpServletRequest launcherOAuthRequest() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.getSession().setAttribute( + LauncherAuthService.OAUTH_REDIRECT_URI_SESSION_ATTRIBUTE, + "http://127.0.0.1:49152/callback" + ); + request.getSession().setAttribute( + LauncherAuthService.OAUTH_STATE_SESSION_ATTRIBUTE, + "state-123" + ); + return request; + } + + private static OAuth2AuthenticationToken authentication() { + DefaultOAuth2User principal = new DefaultOAuth2User( + Set.of(new SimpleGrantedAuthority("ROLE_USER")), + Map.of("id", "user-1", "login", "ada"), + "login" + ); + return new OAuth2AuthenticationToken( + principal, + principal.getAuthorities(), + "github" + ); + } +} diff --git a/backend/src/test/java/net/modtale/controller/auth/AuthControllerTest.java b/backend/src/test/java/net/modtale/controller/auth/AuthControllerTest.java index e343f467..2d124959 100644 --- a/backend/src/test/java/net/modtale/controller/auth/AuthControllerTest.java +++ b/backend/src/test/java/net/modtale/controller/auth/AuthControllerTest.java @@ -2,19 +2,26 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import net.modtale.model.dto.request.auth.LauncherAuthExchangeRequest; +import net.modtale.model.dto.request.auth.LauncherAuthIssueRequest; import net.modtale.model.dto.request.auth.SignInRequest; import net.modtale.model.user.User; import net.modtale.service.auth.AuthenticationMutationService; import net.modtale.service.auth.AuthenticationService; +import net.modtale.service.auth.LauncherAuthService; import net.modtale.service.auth.TwoFactorService; import net.modtale.service.user.account.AccountService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.context.SecurityContext; import org.springframework.security.web.context.SecurityContextRepository; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -26,6 +33,7 @@ class AuthControllerTest { private AuthenticationMutationService authenticationMutationService; private AccountService accountService; private TwoFactorService twoFactorService; + private LauncherAuthService launcherAuthService; private SecurityContextRepository securityContextRepository; @BeforeEach @@ -34,12 +42,14 @@ void setUp() { authenticationMutationService = mock(AuthenticationMutationService.class); accountService = mock(AccountService.class); twoFactorService = mock(TwoFactorService.class); + launcherAuthService = mock(LauncherAuthService.class); securityContextRepository = mock(SecurityContextRepository.class); controller = new AuthController( authenticationService, authenticationMutationService, accountService, twoFactorService, + launcherAuthService, securityContextRepository ); } @@ -75,6 +85,79 @@ void logoutClearsTheSecurityContextAndExpiresSessionCookies() { verify(securityContextRepository).saveContext(org.springframework.security.core.context.SecurityContextHolder.createEmptyContext(), request, response); } + @Test + void issueLauncherAuthCodeReturnsGrantForCurrentUser() { + User user = new User(); + user.setId("user-1"); + + LauncherAuthIssueRequest requestPayload = new LauncherAuthIssueRequest(); + requestPayload.setRedirectUri("http://127.0.0.1:49152/callback"); + requestPayload.setState("state-123"); + + when(accountService.requireCurrentUser(null, "authorizing the Modtale Launcher")).thenReturn(user); + when(launcherAuthService.issueCode(user, "http://127.0.0.1:49152/callback", "state-123")) + .thenReturn(new LauncherAuthService.LauncherAuthGrant( + "launcher-code", + "http://127.0.0.1:49152/callback", + "state-123", + 300 + )); + + var response = controller.issueLauncherAuthCode(requestPayload, null); + + assertEquals(200, response.getStatusCode().value()); + assertEquals("launcher-code", response.getBody().code()); + assertEquals("state-123", response.getBody().state()); + assertEquals(300, response.getBody().expiresIn()); + } + + @Test + void beginLauncherOAuthStoresCallbackAndRedirectsToProviderAuthorization() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + controller.beginLauncherOAuthLogin( + "github", + "http://127.0.0.1:49152/callback", + "state-123", + request, + response + ); + + assertEquals("/oauth2/authorization/github", response.getRedirectedUrl()); + assertEquals( + "http://127.0.0.1:49152/callback", + request.getSession().getAttribute(LauncherAuthService.OAUTH_REDIRECT_URI_SESSION_ATTRIBUTE) + ); + assertEquals( + "state-123", + request.getSession().getAttribute(LauncherAuthService.OAUTH_STATE_SESSION_ATTRIBUTE) + ); + verify(launcherAuthService).validateLoopbackRedirectUri("http://127.0.0.1:49152/callback"); + } + + @Test + void exchangeLauncherAuthCodeCreatesSessionForLauncherClient() { + User user = new User(); + user.setId("user-1"); + user.setRoles(java.util.List.of("USER")); + + LauncherAuthExchangeRequest requestPayload = new LauncherAuthExchangeRequest(); + requestPayload.setCode("launcher-code"); + + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + when(launcherAuthService.consumeCode("launcher-code")).thenReturn(user); + + var result = controller.exchangeLauncherAuthCode(requestPayload, request, response); + + assertEquals(200, result.getStatusCode().value()); + assertNotNull(request.getSession(false)); + verify(securityContextRepository).saveContext(any(SecurityContext.class), eq(request), eq(response)); + org.springframework.security.core.context.SecurityContextHolder.clearContext(); + } + @Test void removePasswordDelegatesForTheCurrentUser() { User user = new User(); diff --git a/backend/src/test/java/net/modtale/controller/user/UserControllerTest.java b/backend/src/test/java/net/modtale/controller/user/UserControllerTest.java index 7795c525..bfb0e8a8 100644 --- a/backend/src/test/java/net/modtale/controller/user/UserControllerTest.java +++ b/backend/src/test/java/net/modtale/controller/user/UserControllerTest.java @@ -2,6 +2,7 @@ import net.modtale.model.dto.request.user.UpdateProfileRequest; import net.modtale.model.dto.response.common.ResourceUrlResponse; +import net.modtale.model.user.LauncherSettingsSnapshot; import net.modtale.model.user.User; import net.modtale.repository.user.UserRepository; import net.modtale.service.media.MediaUploadService; @@ -117,6 +118,28 @@ void followUserDelegatesUsingTheAuthenticatedUserId() { verify(socialService).followUser("user-1", "user-2"); } + @Test + void launcherSettingsEndpointsUseCurrentUser() { + User currentUser = user("user-1", "ada"); + LauncherSettingsSnapshot snapshot = new LauncherSettingsSnapshot(); + when(accountService.requireCurrentUser(null, "loading launcher settings")).thenReturn(currentUser); + when(accountService.requireCurrentUser(null, "syncing launcher settings")).thenReturn(currentUser); + when(accountService.getLauncherSettings("user-1")).thenReturn(snapshot); + when(accountService.updateLauncherSettings("user-1", snapshot)).thenReturn(snapshot); + when(accountService.updateLauncherSettingsPreferences("user-1", snapshot)).thenReturn(snapshot); + + var getResponse = controller.getLauncherSettings(null); + var putResponse = controller.updateLauncherSettings(snapshot, null); + var prefsResponse = controller.updateLauncherSettingsPreferences(snapshot, null); + + assertEquals(200, getResponse.getStatusCode().value()); + assertSame(snapshot, getResponse.getBody()); + assertEquals(200, putResponse.getStatusCode().value()); + assertSame(snapshot, putResponse.getBody()); + assertEquals(200, prefsResponse.getStatusCode().value()); + assertSame(snapshot, prefsResponse.getBody()); + } + private static User user(String id, String username) { User user = new User(); user.setId(id); diff --git a/backend/src/test/java/net/modtale/service/auth/LauncherAuthServiceTest.java b/backend/src/test/java/net/modtale/service/auth/LauncherAuthServiceTest.java new file mode 100644 index 00000000..23fd8094 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/auth/LauncherAuthServiceTest.java @@ -0,0 +1,61 @@ +package net.modtale.service.auth; + +import java.util.Optional; +import net.modtale.exception.InvalidAuthenticationRequestException; +import net.modtale.model.user.User; +import net.modtale.repository.user.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class LauncherAuthServiceTest { + + private UserRepository userRepository; + private LauncherAuthService service; + + @BeforeEach + void setUp() { + userRepository = mock(UserRepository.class); + service = new LauncherAuthService(userRepository); + } + + @Test + void issueCodeAllowsOnlyLoopbackRedirectsAndConsumesOnce() { + User user = new User(); + user.setId("user-1"); + + when(userRepository.findById("user-1")).thenReturn(Optional.of(user)); + + LauncherAuthService.LauncherAuthGrant grant = service.issueCode( + user, + "http://127.0.0.1:49152/callback", + "state-123" + ); + + assertNotNull(grant.code()); + assertEquals("http://127.0.0.1:49152/callback", grant.redirectUri()); + assertEquals("state-123", grant.state()); + assertTrue(grant.expiresIn() > 0); + assertEquals(1, service.getActiveCodeCount()); + assertEquals(user, service.consumeCode(grant.code())); + assertNull(service.consumeCode(grant.code())); + } + + @Test + void issueCodeRejectsExternalRedirects() { + User user = new User(); + user.setId("user-1"); + + assertThrows( + InvalidAuthenticationRequestException.class, + () -> service.issueCode(user, "https://evil.example/callback", "state") + ); + } +} diff --git a/backend/src/test/java/net/modtale/service/auth/OAuth2LoginServiceTest.java b/backend/src/test/java/net/modtale/service/auth/OAuth2LoginServiceTest.java index 7ba7e98a..7cdb6bfa 100644 --- a/backend/src/test/java/net/modtale/service/auth/OAuth2LoginServiceTest.java +++ b/backend/src/test/java/net/modtale/service/auth/OAuth2LoginServiceTest.java @@ -9,6 +9,7 @@ import net.modtale.service.user.account.AccountService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.ObjectProvider; +import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.oauth2.client.registration.ClientRegistration; @@ -80,18 +81,13 @@ void loadUserFallsBackToLoginFlowForAnonymousRequests() { } @Test - void loadUserRejectsGitLabAsAnAnonymousSignInMethod() { + void loadUserRejectsGitlabAsSignInProvider() { AccountService accountService = mock(AccountService.class); AuthenticationService authenticationService = mock(AuthenticationService.class); ObjectProvider requestProvider = mock(ObjectProvider.class); - DefaultOAuth2User upstreamUser = oauthUser("oauth-1", "ada-gl"); - TestOAuth2LoginService service = new TestOAuth2LoginService( - accountService, - authenticationService, - requestProvider, - upstreamUser - ); + DefaultOAuth2User upstreamUser = oauthUser("oauth-1", "ada-gitlab"); + TestOAuth2LoginService service = new TestOAuth2LoginService(accountService, authenticationService, requestProvider, upstreamUser); OAuth2AuthenticationException error = assertThrows( OAuth2AuthenticationException.class, @@ -99,11 +95,37 @@ void loadUserRejectsGitLabAsAnAnonymousSignInMethod() { ); assertEquals("login_failure", error.getError().getErrorCode()); - verify(authenticationService, never()).processUserLogin( - org.mockito.ArgumentMatchers.anyString(), - org.mockito.ArgumentMatchers.any(), - org.mockito.ArgumentMatchers.anyString() + verify(authenticationService, never()).processUserLogin(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyString()); + } + + @Test + void loadUserUsesLoginFlowForLauncherOAuthEvenWithExistingBrowserSession() { + AccountService accountService = mock(AccountService.class); + AuthenticationService authenticationService = mock(AuthenticationService.class); + ObjectProvider requestProvider = mock(ObjectProvider.class); + MockHttpServletRequest request = new MockHttpServletRequest(); + Authentication authentication = mock(Authentication.class); + request.setUserPrincipal(authentication); + request.getSession().setAttribute( + LauncherAuthService.OAUTH_REDIRECT_URI_SESSION_ATTRIBUTE, + "http://127.0.0.1:49152/callback" ); + + when(requestProvider.getIfAvailable()).thenReturn(request); + when(authentication.isAuthenticated()).thenReturn(true); + when(authentication.getName()).thenReturn("ada"); + + DefaultOAuth2User upstreamUser = oauthUser("oauth-1", "ada-gh"); + DefaultOAuth2User signedInUser = oauthUser("user-1", "Ada"); + + TestOAuth2LoginService service = new TestOAuth2LoginService(accountService, authenticationService, requestProvider, upstreamUser); + when(authenticationService.processUserLogin("github", upstreamUser, "access-token")).thenReturn(signedInUser); + + OAuth2User result = service.loadUser(oauthRequest("github")); + + assertSame(signedInUser, result); + verify(authenticationService).processUserLogin("github", upstreamUser, "access-token"); + verify(authenticationService, never()).linkAccount(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyString()); } @Test diff --git a/backend/src/test/java/net/modtale/service/auth/OidcLoginServiceTest.java b/backend/src/test/java/net/modtale/service/auth/OidcLoginServiceTest.java index 91e303d5..505fd556 100644 --- a/backend/src/test/java/net/modtale/service/auth/OidcLoginServiceTest.java +++ b/backend/src/test/java/net/modtale/service/auth/OidcLoginServiceTest.java @@ -83,6 +83,37 @@ void loadUserFallsBackToLoginFlowWhenNoAuthenticatedUserExists() { verify(authenticationService, never()).linkAccount(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyString()); } + @Test + void loadUserUsesLoginFlowForLauncherOAuthEvenWithExistingBrowserSession() { + AccountService accountService = mock(AccountService.class); + AuthenticationService authenticationService = mock(AuthenticationService.class); + ObjectProvider requestProvider = mock(ObjectProvider.class); + MockHttpServletRequest request = new MockHttpServletRequest(); + Authentication authentication = mock(Authentication.class); + request.setUserPrincipal(authentication); + request.getSession().setAttribute( + LauncherAuthService.OAUTH_REDIRECT_URI_SESSION_ATTRIBUTE, + "http://127.0.0.1:49152/callback" + ); + + when(requestProvider.getIfAvailable()).thenReturn(request); + when(authentication.isAuthenticated()).thenReturn(true); + when(authentication.getName()).thenReturn("ada"); + + OidcUser upstreamUser = oidcUser(); + DefaultOAuth2User appUser = oauthUser("user-1", "Ada"); + + TestOidcLoginService service = new TestOidcLoginService(accountService, authenticationService, requestProvider, upstreamUser); + when(authenticationService.processUserLogin("google", upstreamUser, "access-token")).thenReturn(appUser); + + OidcUser result = service.loadUser(oidcRequest("google")); + + assertInstanceOf(OidcLoginService.CustomOidcUser.class, result); + assertEquals("Ada", result.getAttribute("login")); + verify(authenticationService).processUserLogin("google", upstreamUser, "access-token"); + verify(authenticationService, never()).linkAccount(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyString()); + } + @Test void loadUserMapsOidcAccountCollisionsToTheExpectedOAuthErrorCode() { AccountService accountService = mock(AccountService.class); diff --git a/backend/src/test/java/net/modtale/service/project/version/VersionDownloadOrchestrationServiceTest.java b/backend/src/test/java/net/modtale/service/project/version/VersionDownloadOrchestrationServiceTest.java index d9407e6e..d380cf0f 100644 --- a/backend/src/test/java/net/modtale/service/project/version/VersionDownloadOrchestrationServiceTest.java +++ b/backend/src/test/java/net/modtale/service/project/version/VersionDownloadOrchestrationServiceTest.java @@ -5,6 +5,7 @@ import net.modtale.config.properties.AppFrontendProperties; import net.modtale.exception.InvalidDownloadTokenException; import net.modtale.exception.ResourceNotFoundException; +import net.modtale.exception.UnauthorizedException; import net.modtale.model.dto.response.project.BundleDownloadUrlResponse; import net.modtale.model.dto.response.project.DownloadUrlResponse; import net.modtale.model.project.Project; @@ -69,14 +70,15 @@ void setUp() { @Test void createDownloadUrlAndBundleUrlGenerateShortLivedTokenRoutes() { User user = new User(); + user.setId("user-1"); Project project = project("project-1", "Sky Tools", ProjectClassification.PLUGIN); ProjectVersion version = version("version-1", "1.0.0", "files/mod.jar"); when(projectService.getProjectById("project-1", user)).thenReturn(project); when(projectVersionAccessService.requireByVersionNumber(org.mockito.Mockito.eq(project), org.mockito.Mockito.eq("1.0.0"), org.mockito.Mockito.eq("1.21.0"), org.mockito.Mockito.any())) .thenReturn(version); - when(downloadTokenService.generateToken("project-1", "1.0.0", "1.21.0")).thenReturn("download-token"); - when(downloadTokenService.generateToken("project-1", "1.0.0", "1.21.0", List.of("dep-1"))).thenReturn("bundle-token"); + when(downloadTokenService.generateToken("project-1", "1.0.0", "1.21.0", null, "user-1")).thenReturn("download-token"); + when(downloadTokenService.generateToken("project-1", "1.0.0", "1.21.0", List.of("dep-1"), "user-1")).thenReturn("bundle-token"); when(downloadTokenService.getTokenValiditySeconds()).thenReturn(300); DownloadUrlResponse download = service.createDownloadUrl("project-1", "1.0.0", "1.21.0", user); @@ -197,6 +199,25 @@ void downloadRejectsInvalidTokensOrUnreadableProjects() { assertThrows(ResourceNotFoundException.class, () -> service.downloadVersion("unreadable", false, null, null, null, user)); } + @Test + void downloadRejectsUserBoundTokenWithoutMatchingSession() { + User user = new User(); + user.setId("other-user"); + + when(downloadTokenService.validateAndConsume("token")).thenReturn( + new DownloadTokenService.DownloadToken( + "project-1", + "1.0.0", + null, + null, + "user-1", + Instant.now().plusSeconds(60) + ) + ); + + assertThrows(UnauthorizedException.class, () -> service.downloadVersion("token", false, null, null, null, user)); + } + private static DownloadTokenService.DownloadToken token( String projectId, String version, diff --git a/backend/src/test/java/net/modtale/service/storage/DownloadTokenServiceTest.java b/backend/src/test/java/net/modtale/service/storage/DownloadTokenServiceTest.java index 3a67a8de..5cdb930a 100644 --- a/backend/src/test/java/net/modtale/service/storage/DownloadTokenServiceTest.java +++ b/backend/src/test/java/net/modtale/service/storage/DownloadTokenServiceTest.java @@ -24,7 +24,7 @@ private Map getTokens() { @Test void generateTokenStoresPayloadAndConsumesItOnce() { - String token = downloadTokenService.generateToken("project-1", "1.2.3", "1.0.0", List.of("dep-a", "dep-b")); + String token = downloadTokenService.generateToken("project-1", "1.2.3", "1.0.0", List.of("dep-a", "dep-b"), "user-1"); assertNotNull(token); assertTrue(downloadTokenService.getActiveTokenCount() >= 1); @@ -35,6 +35,7 @@ void generateTokenStoresPayloadAndConsumesItOnce() { assertEquals("project-1", result.getProjectId()); assertEquals("1.2.3", result.getVersion()); assertEquals("1.0.0", result.getGameVersion()); + assertEquals("user-1", result.getUserId()); assertEquals(List.of("dep-a", "dep-b"), result.getSelectedDependencies()); assertTrue(result.isUsed()); assertNull(downloadTokenService.validateAndConsume(token)); diff --git a/backend/src/test/java/net/modtale/service/user/account/AccountServiceTest.java b/backend/src/test/java/net/modtale/service/user/account/AccountServiceTest.java index af61beab..14cf52f3 100644 --- a/backend/src/test/java/net/modtale/service/user/account/AccountServiceTest.java +++ b/backend/src/test/java/net/modtale/service/user/account/AccountServiceTest.java @@ -1,6 +1,7 @@ package net.modtale.service.user.account; import java.util.Optional; +import net.modtale.model.user.LauncherSettingsSnapshot; import net.modtale.model.user.User; import net.modtale.repository.user.UserRepository; import net.modtale.service.security.validation.SanitizationService; @@ -9,8 +10,12 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.argThat; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -66,6 +71,93 @@ void getPublicProfileReturnsNullForBlankIdentifiers() { assertNull(accountService.getPublicProfile(" ")); } + @Test + void launcherSettingsAreNormalizedBeforeSaving() { + User user = user("user-1", "ada"); + when(userRepository.findById("user-1")).thenReturn(Optional.of(user)); + when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + LauncherSettingsSnapshot snapshot = new LauncherSettingsSnapshot(); + snapshot.setSettingsHash(" hash "); + LauncherSettingsSnapshot.Preferences preferences = new LauncherSettingsSnapshot.Preferences(); + preferences.setGameVersion(" 1.0 "); + snapshot.setPreferences(preferences); + LauncherSettingsSnapshot.InstalledProject installed = new LauncherSettingsSnapshot.InstalledProject(); + installed.setProjectId(" project-1 "); + installed.setSlug(" slug-one "); + installed.setTitle(" Project One "); + installed.setClassification(" MODPACK "); + installed.setInstalledVersion(" 2.0 "); + installed.setSource(""); + installed.setInstallType(""); + installed.setModpackUnlocked(true); + installed.setDependencyProjectIds(java.util.List.of("dep-1", "dep-1", " ")); + LauncherSettingsSnapshot.InstalledProjectReference bundled = + new LauncherSettingsSnapshot.InstalledProjectReference(); + bundled.setProjectId(" bundled-1 "); + bundled.setSlug(" bundled-slug "); + bundled.setVersionNumber(" 1.5 "); + bundled.setSource(" MODTALE "); + bundled.setExternalId(" external-one "); + installed.setBundledProjects(java.util.List.of(bundled)); + snapshot.setInstalledProjects(java.util.List.of(installed)); + + LauncherSettingsSnapshot saved = accountService.updateLauncherSettings("user-1", snapshot); + + assertEquals("hash", saved.getSettingsHash()); + assertEquals("1.0", saved.getPreferences().getGameVersion()); + assertEquals(1, saved.getInstalledProjects().size()); + assertEquals("project-1", saved.getInstalledProjects().getFirst().getProjectId()); + assertEquals("slug-one", saved.getInstalledProjects().getFirst().getSlug()); + assertEquals("Project One", saved.getInstalledProjects().getFirst().getTitle()); + assertEquals("MODPACK", saved.getInstalledProjects().getFirst().getClassification()); + assertEquals("MODTALE", saved.getInstalledProjects().getFirst().getSource()); + assertEquals("DIRECT", saved.getInstalledProjects().getFirst().getInstallType()); + assertTrue(saved.getInstalledProjects().getFirst().isModpackUnlocked()); + assertEquals(java.util.List.of("dep-1"), saved.getInstalledProjects().getFirst().getDependencyProjectIds()); + assertEquals("bundled-1", + saved.getInstalledProjects().getFirst().getBundledProjects().getFirst().getProjectId()); + assertEquals("bundled-slug", + saved.getInstalledProjects().getFirst().getBundledProjects().getFirst().getSlug()); + assertEquals("1.5", + saved.getInstalledProjects().getFirst().getBundledProjects().getFirst().getVersionNumber()); + assertEquals("MODTALE", + saved.getInstalledProjects().getFirst().getBundledProjects().getFirst().getSource()); + assertEquals("external-one", + saved.getInstalledProjects().getFirst().getBundledProjects().getFirst().getExternalId()); + assertNotNull(saved.getUpdatedAt()); + verify(userRepository).save(argThat(savedUser -> savedUser.getLauncherSettings() == saved)); + } + + @Test + void launcherPreferenceUpdatePreservesStoredInstalledProjects() { + User user = user("user-1", "ada"); + LauncherSettingsSnapshot stored = new LauncherSettingsSnapshot(); + LauncherSettingsSnapshot.InstalledProject installed = new LauncherSettingsSnapshot.InstalledProject(); + installed.setProjectId("project-1"); + installed.setInstalledVersion("2.0"); + stored.setInstalledProjects(java.util.List.of(installed)); + user.setLauncherSettings(stored); + when(userRepository.findById("user-1")).thenReturn(Optional.of(user)); + when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + LauncherSettingsSnapshot update = new LauncherSettingsSnapshot(); + update.setSettingsHash(" full-local-hash "); + LauncherSettingsSnapshot.Preferences preferences = new LauncherSettingsSnapshot.Preferences(); + preferences.setGameVersion(" 2.1 "); + update.setPreferences(preferences); + + LauncherSettingsSnapshot saved = accountService.updateLauncherSettingsPreferences("user-1", update); + + assertEquals("full-local-hash", saved.getSettingsHash()); + assertEquals("2.1", saved.getPreferences().getGameVersion()); + assertEquals(1, saved.getInstalledProjects().size()); + assertEquals("project-1", saved.getInstalledProjects().getFirst().getProjectId()); + assertEquals("2.0", saved.getInstalledProjects().getFirst().getInstalledVersion()); + assertNotNull(saved.getUpdatedAt()); + verify(userRepository).save(argThat(savedUser -> savedUser.getLauncherSettings() == saved)); + } + @Test void hytaleConnectionsCannotBeMadePublic() { assertThrows( diff --git a/backend/src/test/java/net/modtale/service/worldlist/WorldModListArchiveServiceTest.java b/backend/src/test/java/net/modtale/service/worldlist/WorldModListArchiveServiceTest.java new file mode 100644 index 00000000..41751248 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/worldlist/WorldModListArchiveServiceTest.java @@ -0,0 +1,77 @@ +package net.modtale.service.worldlist; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import net.modtale.model.project.ProjectClassification; +import net.modtale.model.project.ProjectDependency; +import net.modtale.model.worldlist.WorldModList; +import net.modtale.service.storage.StorageService; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.ObjectMapper; + +class WorldModListArchiveServiceTest { + + @Test + void generateZipIncludesManifestReadmeAndDownloadableFilesOnly() throws IOException { + StorageService storageService = mock(StorageService.class); + when(storageService.download("storage/cool.jar")).thenReturn("cool-bytes".getBytes(StandardCharsets.UTF_8)); + + WorldModList list = new WorldModList(); + list.setId("list-1"); + list.setTitle("Shared list"); + list.setWorldName("Cozy World"); + list.setGameVersion("0.5.0"); + list.setCreatedAt(Instant.parse("2026-06-20T12:00:00Z")); + list.setLastViewedAt(Instant.parse("2026-06-20T12:30:00Z")); + list.setExpiresAt(Instant.parse("2026-07-20T12:00:00Z")); + list.setMods(List.of( + item("Cool Mod", "1.0.0", true, "storage/cool.jar"), + item("External Mod", "0.2.0", false, "") + )); + + byte[] archive = new WorldModListArchiveService(storageService, new ObjectMapper()).generateZip(list); + Map entries = entries(archive); + + assertTrue(entries.containsKey("modtale-list.json")); + assertTrue(entries.get("modtale-list.json").contains("\"createdAt\" : \"2026-06-20T12:00:00Z\"")); + assertTrue(entries.get("README.txt").contains("Cozy World")); + assertEquals("cool-bytes", entries.get("Cool-Mod-1.0.0.jar")); + assertFalse(entries.containsKey("External-Mod-0.2.0.jar")); + } + + private static WorldModList.Item item(String title, String version, boolean downloadable, String fileUrl) { + WorldModList.Item item = new WorldModList.Item(); + item.setId(title); + item.setTitle(title); + item.setVersionNumber(version); + item.setClassification(ProjectClassification.PLUGIN); + item.setSource(ProjectDependency.Source.MODTALE); + item.setDownloadable(downloadable); + item.setFileUrl(fileUrl); + return item; + } + + private static Map entries(byte[] archive) throws IOException { + Map entries = new HashMap<>(); + try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(archive))) { + ZipEntry entry; + while ((entry = zip.getNextEntry()) != null) { + entries.put(entry.getName(), new String(zip.readAllBytes(), StandardCharsets.UTF_8)); + } + } + return entries; + } +} diff --git a/backend/src/test/java/net/modtale/service/worldlist/WorldModListServiceTest.java b/backend/src/test/java/net/modtale/service/worldlist/WorldModListServiceTest.java new file mode 100644 index 00000000..56086cb6 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/worldlist/WorldModListServiceTest.java @@ -0,0 +1,301 @@ +package net.modtale.service.worldlist; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import net.modtale.config.properties.AppFrontendProperties; +import net.modtale.model.dto.request.worldlist.CreateWorldModListRequest; +import net.modtale.model.dto.worldlist.WorldModListDTO; +import net.modtale.model.project.Project; +import net.modtale.model.project.ProjectClassification; +import net.modtale.model.project.ProjectDependency; +import net.modtale.model.project.ProjectVersion; +import net.modtale.model.user.User; +import net.modtale.model.worldlist.WorldModList; +import net.modtale.repository.worldlist.WorldModListRepository; +import net.modtale.service.project.access.ProjectVersionAccessService; +import net.modtale.service.project.query.ProjectService; +import net.modtale.service.security.access.AccessControlService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class WorldModListServiceTest { + + private WorldModListRepository repository; + private ProjectService projectService; + private ProjectVersionAccessService versionAccessService; + private AccessControlService accessControlService; + private WorldModListArchiveService archiveService; + private WorldModListService service; + + @BeforeEach + void setUp() { + repository = mock(WorldModListRepository.class); + projectService = mock(ProjectService.class); + versionAccessService = mock(ProjectVersionAccessService.class); + accessControlService = mock(AccessControlService.class); + archiveService = mock(WorldModListArchiveService.class); + service = new WorldModListService( + repository, + projectService, + versionAccessService, + accessControlService, + archiveService, + new WorldModListMapper(new AppFrontendProperties("https://modtale.test/")) + ); + when(repository.save(any(WorldModList.class))).thenAnswer(invocation -> invocation.getArgument(0)); + } + + @Test + void createEnrichesModtaleItemsDedupesAndLeavesExternalItemsListedOnly() { + User owner = owner(); + Project project = project(); + ProjectVersion version = version("1.2.3", "storage/mod.jar"); + project.setVersions(List.of(version)); + + when(projectService.getRawProjectById("project-1")).thenReturn(project); + when(accessControlService.isPubliclyReadable(project)).thenReturn(true); + when(accessControlService.canReadProject(project, owner)).thenReturn(true); + when(versionAccessService.findByVersionNumber(project, "1.2.3", "0.5.0")).thenReturn(version); + + CreateWorldModListRequest request = new CreateWorldModListRequest( + "My world share", + "Cozy World", + "0.5.0", + List.of( + new CreateWorldModListRequest.Item( + "group:mod", + "project-1", + "", + "Local name", + "1.2.3", + ProjectClassification.PLUGIN, + ProjectDependency.Source.MODTALE, + "", + "", + "" + ), + new CreateWorldModListRequest.Item( + "group:mod", + "project-1", + "", + "Duplicate", + "1.2.3", + ProjectClassification.PLUGIN, + ProjectDependency.Source.MODTALE, + "", + "", + "" + ), + new CreateWorldModListRequest.Item( + "local:only", + "", + "", + "Local Only", + "0.1.0", + ProjectClassification.PLUGIN, + ProjectDependency.Source.OTHER, + "local:only", + "", + "" + ) + ) + ); + + WorldModListDTO dto = service.create(request, owner); + + UUID.fromString(dto.id()); + assertEquals("My world share", dto.title()); + assertEquals("https://modtale.test/lists/" + dto.id(), dto.shareUrl()); + assertEquals("/lists/" + dto.id() + "/download", dto.downloadUrl()); + assertTrue(dto.launcherInstallUrl().startsWith("modtale://install-list?listId=" + dto.id())); + assertEquals(2, dto.modCount()); + assertEquals(1, dto.downloadableCount()); + assertEquals("Catalog Mod", dto.mods().getFirst().title()); + assertEquals("catalog-mod", dto.mods().getFirst().slug()); + assertEquals("mayuna", dto.mods().getFirst().author()); + assertEquals("A tiny catalog mod.", dto.mods().getFirst().description()); + assertEquals(42, dto.mods().getFirst().downloadCount()); + assertEquals(ProjectDependency.Source.MODTALE, dto.mods().getFirst().source()); + assertEquals("0.1.0", dto.mods().get(1).versionNumber()); + assertEquals("Listed only; Modtale cannot package this external or local file.", dto.mods().get(1).unavailableReason()); + + ArgumentCaptor saved = ArgumentCaptor.forClass(WorldModList.class); + verify(repository).save(saved.capture()); + assertEquals(dto.id(), saved.getValue().getId()); + assertTrue(saved.getValue().getExpiresAt().isAfter(Instant.now().plusSeconds(29L * 24L * 60L * 60L))); + } + + @Test + void viewTouchesListAndExtendsExpiry() { + Instant oldExpiry = Instant.now().plusSeconds(3600); + WorldModList list = new WorldModList(); + list.setId("list-1"); + list.setTitle("Shared list"); + list.setWorldName("World"); + list.setCreatedAt(Instant.now().minusSeconds(3600)); + list.setLastViewedAt(Instant.now().minusSeconds(1800)); + list.setExpiresAt(oldExpiry); + list.setViewCount(2); + list.setMods(List.of(externalItem())); + + when(repository.findById("list-1")).thenReturn(Optional.of(list)); + + WorldModListDTO dto = service.view("list-1"); + + assertEquals(3, dto.viewCount()); + assertEquals(0, dto.downloadCount()); + assertTrue(dto.expiresAt().isAfter(oldExpiry)); + assertTrue(dto.lastViewedAt().isAfter(list.getCreatedAt())); + verify(repository).save(list); + } + + @Test + void viewHydratesStoredModtaleItemsWithCurrentProjectMetadata() { + Instant oldExpiry = Instant.now().plusSeconds(3600); + WorldModList.Item staleItem = new WorldModList.Item(); + staleItem.setId("item-1"); + staleItem.setProjectId("project-1"); + staleItem.setTitle("Old local title"); + staleItem.setSource(ProjectDependency.Source.OTHER); + staleItem.setDownloadable(true); + + WorldModList list = new WorldModList(); + list.setId("list-1"); + list.setTitle("Shared list"); + list.setWorldName("World"); + list.setCreatedAt(Instant.now().minusSeconds(3600)); + list.setExpiresAt(oldExpiry); + list.setMods(List.of(staleItem)); + + Project project = project(); + when(repository.findById("list-1")).thenReturn(Optional.of(list)); + when(projectService.getRawProjectById("project-1")).thenReturn(project); + when(accessControlService.isPubliclyReadable(project)).thenReturn(true); + + WorldModListDTO dto = service.view("list-1"); + + WorldModListDTO.Item item = dto.mods().getFirst(); + assertEquals("Catalog Mod", item.title()); + assertEquals("catalog-mod", item.slug()); + assertEquals("author-1", item.authorId()); + assertEquals("mayuna", item.author()); + assertEquals("A tiny catalog mod.", item.description()); + assertEquals("/banners/mod.png", item.bannerUrl()); + assertEquals(42, item.downloadCount()); + assertEquals(7, item.favoriteCount()); + assertEquals(ProjectDependency.Source.MODTALE, item.source()); + verify(repository).save(list); + } + + @Test + void viewHydratesStoredItemsBySlugLikeModIdWhenProjectIdIsMissing() { + WorldModList.Item staleItem = new WorldModList.Item(); + staleItem.setId("item-1"); + staleItem.setModId("AzureDoom:LevelingCore"); + staleItem.setTitle("LevelingCore"); + staleItem.setSource(ProjectDependency.Source.OTHER); + staleItem.setDownloadable(false); + + WorldModList list = new WorldModList(); + list.setId("list-1"); + list.setTitle("Shared list"); + list.setWorldName("World"); + list.setCreatedAt(Instant.now().minusSeconds(3600)); + list.setExpiresAt(Instant.now().plusSeconds(3600)); + list.setMods(List.of(staleItem)); + + Project project = project(); + project.setSlug("leveling-core"); + project.setTitle("LevelingCore"); + when(repository.findById("list-1")).thenReturn(Optional.of(list)); + when(projectService.getRawProjectByRouteKey("leveling-core")).thenReturn(project); + when(accessControlService.isPubliclyReadable(project)).thenReturn(true); + + WorldModListDTO dto = service.view("list-1"); + + WorldModListDTO.Item item = dto.mods().getFirst(); + assertEquals("project-1", item.projectId()); + assertEquals("leveling-core", item.slug()); + assertEquals("LevelingCore", item.title()); + assertEquals("mayuna", item.author()); + assertEquals(42, item.downloadCount()); + assertEquals(7, item.favoriteCount()); + assertEquals(ProjectDependency.Source.MODTALE, item.source()); + verify(repository).save(list); + } + + @Test + void downloadTouchesListAndBuildsArchive() throws IOException { + WorldModList list = new WorldModList(); + list.setId("list-1"); + list.setTitle("Shared list"); + list.setWorldName("A World"); + list.setExpiresAt(Instant.now().plusSeconds(3600)); + list.setMods(List.of(externalItem())); + + when(repository.findById("list-1")).thenReturn(Optional.of(list)); + when(archiveService.generateZip(list)).thenReturn(new byte[]{1, 2, 3}); + + WorldModListService.Download download = service.download("list-1"); + + assertEquals("A-World-mods.zip", download.filename()); + assertEquals(1, list.getViewCount()); + assertEquals(1, list.getDownloadCount()); + assertEquals(3, download.bytes().length); + } + + private static User owner() { + User user = new User(); + user.setId("user-1"); + user.setUsername("willow"); + return user; + } + + private static Project project() { + Project project = new Project(); + project.setId("project-1"); + project.setSlug("catalog-mod"); + project.setTitle("Catalog Mod"); + project.setAuthorId("author-1"); + project.setAuthor("mayuna"); + project.setDescription("A tiny catalog mod."); + project.setImageUrl("/icons/mod.png"); + project.setBannerUrl("/banners/mod.png"); + project.setClassification(ProjectClassification.PLUGIN); + project.setDownloadCount(42); + project.setFavoriteCount(7); + project.setUpdatedAt("2026-06-02T00:00:00Z"); + return project; + } + + private static ProjectVersion version(String versionNumber, String fileUrl) { + ProjectVersion version = new ProjectVersion(); + version.setId("version-1"); + version.setVersionNumber(versionNumber); + version.setGameVersions(List.of("0.5.0")); + version.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED); + version.setReleaseDate("2026-06-01"); + version.setFileUrl(fileUrl); + return version; + } + + private static WorldModList.Item externalItem() { + WorldModList.Item item = new WorldModList.Item(); + item.setId("item-1"); + item.setTitle("Local Only"); + item.setSource(ProjectDependency.Source.OTHER); + item.setDownloadable(false); + return item; + } +} diff --git a/frontend/public/assets/launcher/patchly.png b/frontend/public/assets/launcher/patchly.png new file mode 100644 index 00000000..86e262bc Binary files /dev/null and b/frontend/public/assets/launcher/patchly.png differ diff --git a/frontend/public/assets/launcher/project.png b/frontend/public/assets/launcher/project.png new file mode 100644 index 00000000..21754683 Binary files /dev/null and b/frontend/public/assets/launcher/project.png differ diff --git a/frontend/public/assets/launcher/voile-mid.png b/frontend/public/assets/launcher/voile-mid.png new file mode 100644 index 00000000..ad1ab0f8 Binary files /dev/null and b/frontend/public/assets/launcher/voile-mid.png differ diff --git a/frontend/public/assets/launcher/voile.png b/frontend/public/assets/launcher/voile.png new file mode 100644 index 00000000..d08744ab Binary files /dev/null and b/frontend/public/assets/launcher/voile.png differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5213e38e..5a2bc8fb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, Suspense, lazy, useRef } from 'react'; +import React, { useState, useEffect, Suspense, lazy, useCallback, useRef } from 'react'; import { Route, Routes, useNavigate, useLocation, Navigate, BrowserRouter } from 'react-router-dom'; import { StaticRouter } from 'react-router'; import { HelmetProvider } from 'react-helmet-async'; @@ -36,6 +36,9 @@ const Dashboard = lazy(() => import('@/modules/user/views/Dashboard').then((modu const VerifyEmail = lazy(() => import('@/modules/auth/views/VerifyEmail').then((module) => ({ default: module.VerifyEmail }))); const ResetPassword = lazy(() => import('@/modules/auth/views/ResetPassword').then((module) => ({ default: module.ResetPassword }))); const MfaVerify = lazy(() => import('@/modules/auth/views/MfaVerify').then((module) => ({ default: module.MfaVerify }))); +const LauncherAuth = lazy(() => import('@/modules/auth/views/LauncherAuth').then((module) => ({ default: module.LauncherAuth }))); +const LauncherPage = lazy(() => import('@/modules/launcher/views/LauncherPage').then((module) => ({ default: module.LauncherPage }))); +const WorldModListView = lazy(() => import('@/modules/worldlist/views/WorldModListView').then((module) => ({ default: module.WorldModListView }))); const CreateProject = lazy(() => import('@/modules/project/views/CreateProject').then((module) => ({ default: module.CreateProject }))); const ProjectEditorView = lazy(() => import('@/modules/project/views/ProjectEditor').then((module) => ({ default: module.ProjectEditorView }))); const AdminPanel = lazy(() => import('@/modules/admin/views/AdminPanel').then((module) => ({ default: module.AdminPanel }))); @@ -44,6 +47,10 @@ const SwaggerDocs = lazy(() => import('@/modules/core/views/SwaggerDocs').then(( const RouteLoading = () =>
; +type FavoriteToggleOptions = { + onError?: () => void; +}; + const StatusRedirect = () => { useEffect(() => { if (typeof window !== 'undefined') { @@ -75,33 +82,29 @@ const hasLikelyAuthCookie = () => { return /(?:^|;\s*)(SESSION|JSESSIONID|XSRF-TOKEN)=/.test(cookies); }; -const projectRouteBase = (pathname: string) => { - const match = pathname.match(/^\/(project|mod|modpack|world)\/[^/]+/i); - return match ? match[0].toLowerCase() : ''; -}; +const setProjectLikedState = (user: User, projectId: string, liked: boolean): User => { + const likedProjectIds = user.likedProjectIds || []; + const alreadyLiked = likedProjectIds.includes(projectId); + + if (alreadyLiked === liked) return user; -const isProjectModalSubroute = (pathname: string) => ( - /^\/(project|mod|modpack|world)\/[^/]+\/(download|changelog|gallery)\/?$/i.test(pathname) -); + return { + ...user, + likedProjectIds: liked + ? [...likedProjectIds, projectId] + : likedProjectIds.filter(likedProjectId => likedProjectId !== projectId) + }; +}; const ScrollToTop = () => { const { pathname } = useLocation(); - const previousPathRef = useRef(null); + const previousPathnameRef = useRef(undefined); useEffect(() => { - const previousPath = previousPathRef.current; - const previousProjectBase = previousPath ? projectRouteBase(previousPath) : ''; - const nextProjectBase = projectRouteBase(pathname); - const isSameProjectModalTransition = Boolean( - previousPath - && previousProjectBase - && previousProjectBase === nextProjectBase - && (isProjectModalSubroute(previousPath) || isProjectModalSubroute(pathname)) - ); - - previousPathRef.current = pathname; - - if (isSameProjectModalTransition) { + const previousPathname = previousPathnameRef.current; + previousPathnameRef.current = pathname; + + if (previousPathname && SiteRoutes.isSameProjectModalContext(previousPathname, pathname)) { return; } @@ -119,6 +122,8 @@ const AppContent: React.FC = () => { const [showOnboarding, setShowOnboarding] = useState(false); const [isDarkMode, setIsDarkMode] = useState(true); const [statusModal, setStatusModal] = useState<{ type: 'success' | 'error' | 'warning' | 'info'; title: string; msg: string } | null>(null); + const userRef = useRef(null); + const pendingFavoriteIdsRef = useRef>(new Set()); const navigate = useNavigate(); const location = useLocation(); @@ -131,7 +136,9 @@ const AppContent: React.FC = () => { const decodedError = decodeURIComponent(oauthError).replace(/\+/g, ' '); setGlobalError(decodedError); clearPendingSignInMethod(); - navigate(location.pathname, { replace: true }); + params.delete('oauth_error'); + const remainingSearch = params.toString(); + navigate(`${location.pathname}${remainingSearch ? `?${remainingSearch}` : ''}`, { replace: true }); } }, [location, navigate]); @@ -157,7 +164,11 @@ const AppContent: React.FC = () => { }); }; - const fetchUser = async () => { + useEffect(() => { + userRef.current = user; + }, [user]); + + const fetchUser = useCallback(async () => { if (!hasLikelyAuthCookie()) { setLoadingAuth(false); return; @@ -166,26 +177,30 @@ const AppContent: React.FC = () => { try { const res = await api.get(`/user/me?t=${Date.now()}`); if (res.data) { - setUser(normalizeUser(res.data)); + const normalizedUser = normalizeUser(res.data); + userRef.current = normalizedUser; + setUser(normalizedUser); completeSignInMethod(); if ((res.data as any).is_new_account) { setShowOnboarding(true); } } } catch (e: any) { + userRef.current = null; setUser(null); } finally { setLoadingAuth(false); } - }; + }, []); useEffect(() => { fetchUser(); - }, []); + }, [fetchUser]); const handleLogout = async () => { try { await api.post('/auth/logout'); + userRef.current = null; setUser(null); setShowOnboarding(false); navigate(SiteRoutes.home()); @@ -197,20 +212,37 @@ const AppContent: React.FC = () => { const handleNavigate = (page: string) => { navigate(page === 'home' ? SiteRoutes.home() : `/${page}`); }; const handleUserClick = (userId: string, username?: string) => { navigate(SiteRoutes.creator(userId, username)); }; - const handleToggleFavorite = async (id: string) => { - if (!user) return; - const previousUser = user; - const likedProjectIds = user.likedProjectIds || []; - const isLiked = likedProjectIds.includes(id); - const newProjectLikes = isLiked ? likedProjectIds.filter(lid => lid !== id) : [...likedProjectIds, id]; - setUser({ ...user, likedProjectIds: newProjectLikes }); - try { - await api.post(`/projects/${id}/favorite`); - } catch (e) { - setUser(previousUser); - fetchUser(); - } - }; + const handleToggleFavorite = useCallback((id: string, options?: FavoriteToggleOptions) => { + if (!id || pendingFavoriteIdsRef.current.has(id)) return undefined; + + const currentUser = userRef.current; + if (!currentUser) return undefined; + + const wasLiked = (currentUser.likedProjectIds || []).includes(id); + const nextLiked = !wasLiked; + const nextUser = setProjectLikedState(currentUser, id, nextLiked); + + userRef.current = nextUser; + pendingFavoriteIdsRef.current.add(id); + setUser(nextUser); + + api.post(`/projects/${id}/favorite`) + .catch(() => { + setUser(latestUser => { + if (!latestUser || latestUser.id !== currentUser.id) return latestUser; + const revertedUser = setProjectLikedState(latestUser, id, wasLiked); + userRef.current = revertedUser; + return revertedUser; + }); + options?.onError?.(); + fetchUser(); + }) + .finally(() => { + pendingFavoriteIdsRef.current.delete(id); + }); + + return nextLiked; + }, [fetchUser]); const handleDownload = (id: string) => { if (!downloadedSessionIds.has(id)) setDownloadedSessionIds(prev => new Set(prev).add(id)); }; const onShowStatus = (type: 'success' | 'error' | 'warning' | 'info', title: string, msg: string) => setStatusModal({ type, title, msg }); @@ -340,6 +372,9 @@ const AppContent: React.FC = () => { } /> } /> } /> + } /> + } /> + } /> } /> } /> diff --git a/frontend/src/components/ui/MarkdownRichRenderer.tsx b/frontend/src/components/ui/MarkdownRichRenderer.tsx index 8bd203c5..298f7f04 100644 --- a/frontend/src/components/ui/MarkdownRichRenderer.tsx +++ b/frontend/src/components/ui/MarkdownRichRenderer.tsx @@ -1,4 +1,4 @@ -import React, { Suspense, lazy, useEffect, useState } from 'react'; +import React, { Suspense, lazy, useEffect, useRef, useState } from 'react'; import ReactMarkdown from 'react-markdown'; import rehypeRaw from 'rehype-raw'; import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'; @@ -99,6 +99,44 @@ const CodeFallback = ({ content }: { content: string }) => ( ); +const fallbackCopyText = (content: string) => { + if (typeof document === 'undefined') { + return false; + } + + const textarea = document.createElement('textarea'); + textarea.value = content; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.top = '-9999px'; + textarea.style.left = '-9999px'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + textarea.setSelectionRange(0, textarea.value.length); + + const legacyDocument = document as unknown as { execCommand?: (command: string) => boolean }; + + try { + return legacyDocument.execCommand?.('copy') ?? false; + } finally { + document.body.removeChild(textarea); + } +}; + +const copyText = async (content: string) => { + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(content); + return true; + } + } catch { + // Fall through to the textarea fallback for non-secure or embedded browser contexts. + } + + return fallbackCopyText(content); +}; + const MermaidFallback = ({ content }: { content: string }) => (
@@ -147,9 +185,16 @@ const DeferredMermaidChart = ({ content }: { content: string }) => { const CodeBlock = ({ node: _node, inline, className, children, ...props }: any) => { const [copied, setCopied] = useState(false); + const resetTimerRef = useRef(null); const match = /language-(\w+)/.exec(className || ''); const isBlock = !inline && (match || String(children).includes('\n')); + useEffect(() => () => { + if (resetTimerRef.current !== null) { + window.clearTimeout(resetTimerRef.current); + } + }, []); + if (isBlock) { const lang = match ? match[1] : 'text'; const content = String(children).replace(/\n$/, ''); @@ -158,10 +203,20 @@ const CodeBlock = ({ node: _node, inline, className, children, ...props }: any) return ; } - const handleCopy = () => { - navigator.clipboard.writeText(content); + const handleCopy = async () => { + const copiedToClipboard = await copyText(content); + if (!copiedToClipboard) { + return; + } + setCopied(true); - setTimeout(() => setCopied(false), 2000); + if (resetTimerRef.current !== null) { + window.clearTimeout(resetTimerRef.current); + } + resetTimerRef.current = window.setTimeout(() => { + setCopied(false); + resetTimerRef.current = null; + }, 2000); }; return ( @@ -169,7 +224,8 @@ const CodeBlock = ({ node: _node, inline, className, children, ...props }: any)
{lang} diff --git a/frontend/src/data/seo-constants.ts b/frontend/src/data/seo-constants.ts index 12cc186a..b08c4473 100644 --- a/frontend/src/data/seo-constants.ts +++ b/frontend/src/data/seo-constants.ts @@ -111,6 +111,58 @@ export const ROUTE_SEO: Record = { }, ], }, + '/launcher': { + title: 'Modtale Launcher | Native Hytale Mod Manager', + h1: 'Modtale Launcher', + description: 'Download the Modtale Launcher for Windows, macOS, and Linux. Install, update, and manage Hytale projects with a native launcher built for Modtale releases.', + keywords: 'modtale launcher, hytale launcher, hytale mod manager, hytale mods launcher, download hytale mods, modtale download', + intro: 'The Modtale Launcher is a native desktop app for browsing Modtale projects, installing compatible Hytale releases, resolving dependencies, and keeping your local library ready to play.', + contentBlocks: [ + { + title: 'Desktop Launcher Packages', + body: 'Download a self-contained Modtale Launcher package for your desktop platform and manage Hytale mods, plugins, worlds, assets, and modpacks from one app.', + }, + { + title: 'Install Compatible Project Releases', + body: 'The launcher works with Modtale project metadata to help players choose compatible builds, review dependencies, and install projects into the right local Hytale folder.', + }, + { + title: 'Built Alongside the Modtale Platform', + body: 'Launcher releases are published from the same open-source Modtale project, with package formats for Windows, macOS, and Linux.', + }, + ], + relatedLinks: [ + { + href: '/mods', + label: 'Browse Hytale Projects', + description: 'Explore projects before opening them in the launcher.', + }, + { + href: '/modpacks', + label: 'Hytale Modpacks', + description: 'Find curated collections that benefit from dependency-aware installs.', + }, + { + href: '/upload', + label: 'Publish a Project', + description: 'Share your Hytale work with players on Modtale.', + }, + ], + faq: [ + { + question: 'Does the Modtale Launcher need Java installed?', + answer: 'No. The native launcher packages embed their own runtime, so players do not need to install a separate JDK or JRE.', + }, + { + question: 'Which desktop platforms does the Modtale Launcher support?', + answer: 'Modtale publishes launcher packages for Windows, macOS, and Linux. The launcher page detects your platform and links to the best available release asset when GitHub release metadata is available.', + }, + { + question: 'What does the launcher manage?', + answer: 'The launcher can browse Modtale projects, install compatible project releases, help with dependencies, check installed projects for updates, and connect to Hytale launch flows.', + }, + ], + }, '/status': { title: 'System Status | Modtale', h1: 'Modtale System Status', diff --git a/frontend/src/index.css b/frontend/src/index.css index 550af8db..03914796 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -21,6 +21,7 @@ margin: 0; padding: 0; min-height: 100vh; + letter-spacing: 0; } #root { diff --git a/frontend/src/modules/admin/components/PlatformAnalytics.tsx b/frontend/src/modules/admin/components/PlatformAnalytics.tsx index aaaa2872..33f74f7f 100644 --- a/frontend/src/modules/admin/components/PlatformAnalytics.tsx +++ b/frontend/src/modules/admin/components/PlatformAnalytics.tsx @@ -24,7 +24,7 @@ const SummaryCard = ({ title, value, subValue, trend, icon: Icon, color, isPerce

{title}

-
+
{value}{isPercent && %}
{subValue &&
{subValue}
} @@ -145,7 +145,7 @@ export function PlatformAnalytics() { )}
-

Platform Analytics

+

Platform Analytics

Monitor platform-wide statistics and growth.

diff --git a/frontend/src/modules/admin/components/UserManagement.tsx b/frontend/src/modules/admin/components/UserManagement.tsx index 0ff9a1af..fb09edd5 100644 --- a/frontend/src/modules/admin/components/UserManagement.tsx +++ b/frontend/src/modules/admin/components/UserManagement.tsx @@ -434,7 +434,7 @@ export function UserManagement({ setStatus, currentAdmin: initialAdmin }: { setS {foundUser.username}
-

{foundUser.username}

+

{foundUser.username}

Active Roles
diff --git a/frontend/src/modules/admin/components/VerificationQueue.tsx b/frontend/src/modules/admin/components/VerificationQueue.tsx index 5ad38e77..b4ab6064 100644 --- a/frontend/src/modules/admin/components/VerificationQueue.tsx +++ b/frontend/src/modules/admin/components/VerificationQueue.tsx @@ -83,7 +83,7 @@ export const VerificationQueue: React.FC = ({
-

+

{mod.title} {mod.classification}

diff --git a/frontend/src/modules/admin/views/AdminPanel.tsx b/frontend/src/modules/admin/views/AdminPanel.tsx index 5d3873f5..cd666b37 100644 --- a/frontend/src/modules/admin/views/AdminPanel.tsx +++ b/frontend/src/modules/admin/views/AdminPanel.tsx @@ -283,7 +283,7 @@ export function AdminPanel({ currentUser }: AdminPanelProps) { {activeTab === 'verification' && canReadReviewQueue && (
-

Verification Queue

+

Verification Queue

Review pending projects and updates.

{queueError && ( @@ -304,7 +304,7 @@ export function AdminPanel({ currentUser }: AdminPanelProps) { {activeTab === 'reports' && canReadReports && (
-

Report Queue

+

Report Queue

Handle content violations and user reports.

{reportsError && ( @@ -331,7 +331,7 @@ export function AdminPanel({ currentUser }: AdminPanelProps) { {activeTab === 'projects' && canUseProjectManagement && (
-

Project Management

+

Project Management

Manage, unlist, or delete any project.

@@ -341,7 +341,7 @@ export function AdminPanel({ currentUser }: AdminPanelProps) { {activeTab === 'users' && canUseUserManagement && (
-

User Management

+

User Management

Manage roles, tiers, and user statuses.

@@ -351,7 +351,7 @@ export function AdminPanel({ currentUser }: AdminPanelProps) { {activeTab === 'logs' && canReadLogs && (
-

Audit Logs

+

Audit Logs

Review all administrative actions.

diff --git a/frontend/src/modules/admin/views/Review.tsx b/frontend/src/modules/admin/views/Review.tsx index c2cc7388..6312c7cd 100644 --- a/frontend/src/modules/admin/views/Review.tsx +++ b/frontend/src/modules/admin/views/Review.tsx @@ -328,7 +328,7 @@ export const Review: React.FC = ({ reviewingProject, onClose, onApp
-

{mod.title}

+

{mod.title}

{mod.id} @@ -875,7 +875,7 @@ export const Review: React.FC = ({ reviewingProject, onClose, onApp
-

+

{isNewProject ? "Approve Project?" : "Approve Update?"}

diff --git a/frontend/src/modules/auth/api/authClient.ts b/frontend/src/modules/auth/api/authClient.ts index 2e5615d9..ef5af365 100644 --- a/frontend/src/modules/auth/api/authClient.ts +++ b/frontend/src/modules/auth/api/authClient.ts @@ -103,5 +103,8 @@ export const authClient = { }, validateMfaLogin: async (data: { pre_auth_token: string | null; code: string }) => { return await api.post('/auth/mfa/validate-login', data); + }, + issueLauncherAuthCode: async (data: { redirectUri: string; state?: string | null }) => { + return await api.post('/auth/launcher/issue', data); } }; diff --git a/frontend/src/modules/auth/components/SignInModal.tsx b/frontend/src/modules/auth/components/SignInModal.tsx index 53da588c..f2ad953d 100644 --- a/frontend/src/modules/auth/components/SignInModal.tsx +++ b/frontend/src/modules/auth/components/SignInModal.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'; import { X, ArrowRight, Loader2, ArrowLeft, CheckCircle2 } from 'lucide-react'; import { DiscordBrandIcon, GitHubBrandIcon, GoogleBrandIcon, HytaleBrandIcon } from '@/components/ui/icons/BrandIcons'; import { useLocation, useNavigate } from 'react-router-dom'; -import { BACKEND_URL, extractApiErrorMessage } from '@/utils/api'; +import { API_BASE_URL, extractApiErrorMessage } from '@/utils/api'; import { StatusModal } from '@/components/ui/StatusModal'; import { ModalPortal } from '@/components/ui/ModalPortal'; import { useToast } from '@/components/ui/Toast'; @@ -34,7 +34,7 @@ export function SignInModal({ isOpen, onClose }: SignInModalProps) { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [loading, setLoading] = useState(false); - const [statusModal, setStatusModal] = useState<{ title: string; msg: string } | null>(null); + const [statusModal, setStatusModal] = useState<{ type?: 'error' | 'info'; title: string; msg: string } | null>(null); const [lastSignInMethod, setLastSignInMethod] = useState(null); useEffect(() => { @@ -79,7 +79,10 @@ export function SignInModal({ isOpen, onClose }: SignInModalProps) { const handleOAuthLogin = (provider: OAuthSignInMethod) => { stageSignInMethod(provider); - window.location.href = `${BACKEND_URL}/oauth2/authorization/${provider}`; + const params = new URLSearchParams(); + if (redirectTo) params.set('redirect', redirectTo); + const query = params.toString(); + window.location.href = `${API_BASE_URL}/auth/oauth/${provider}${query ? `?${query}` : ''}`; }; const handleSubmit = async (e: React.FormEvent) => { @@ -151,20 +154,20 @@ export function SignInModal({ isOpen, onClose }: SignInModalProps) {

{statusModal && ( setStatusModal(null)} /> )} -
e.stopPropagation()}> +
e.stopPropagation()}>
-

+

{mode === 'signin' ? 'Welcome Back' : (mode === 'register' ? 'Create Account' : 'Reset Password')}

@@ -184,7 +187,8 @@ export function SignInModal({ isOpen, onClose }: SignInModalProps) { + +

+
+
+ ); +} diff --git a/frontend/src/modules/core/components/Navbar.tsx b/frontend/src/modules/core/components/Navbar.tsx index 9635fc3b..155879a9 100644 --- a/frontend/src/modules/core/components/Navbar.tsx +++ b/frontend/src/modules/core/components/Navbar.tsx @@ -1,5 +1,5 @@ import React, { lazy, Suspense, useState, useRef, useEffect } from 'react'; -import { Menu, X, Upload, LayoutDashboard, User as UserIcon, LogOut, Shield, Users, LogIn, Code2, ChevronDown, LayoutGrid } from 'lucide-react'; +import { Menu, X, Upload, LayoutDashboard, User as UserIcon, LogOut, Shield, Users, LogIn, ChevronDown, LayoutGrid, MonitorDown } from 'lucide-react'; import { Link, useLocation, useNavigate } from 'react-router-dom'; import { AnimatedThemeToggler } from '@/components/ui/AnimatedThemeToggler'; import { useMobile } from '@/context/MobileContext'; @@ -164,15 +164,15 @@ export const Navbar: React.FC = ({
- - API + + Launcher {user && ( <> @@ -317,7 +317,7 @@ export const Navbar: React.FC = ({
- setIsMobileMenuOpen(false)} className="flex items-center p-3 rounded-lg hover:bg-slate-50 dark:hover:bg-white/5 font-bold text-slate-700 dark:text-slate-200 text-left"> API + setIsMobileMenuOpen(false)} className="flex items-center p-3 rounded-lg hover:bg-slate-50 dark:hover:bg-white/5 font-bold text-slate-700 dark:text-slate-200 text-left"> Launcher {user && ( <> setIsMobileMenuOpen(false)} className="flex items-center p-3 rounded-lg hover:bg-slate-50 dark:hover:bg-white/5 font-bold text-slate-700 dark:text-slate-200 text-left"> Dashboard diff --git a/frontend/src/modules/core/views/ApiDocs.tsx b/frontend/src/modules/core/views/ApiDocs.tsx index 1a9a0ba3..7d816f74 100644 --- a/frontend/src/modules/core/views/ApiDocs.tsx +++ b/frontend/src/modules/core/views/ApiDocs.tsx @@ -1387,7 +1387,7 @@ export const ApiDocs: React.FC = () => {
-

+

Modtale API v1

diff --git a/frontend/src/modules/discovery/components/BrowseFilters.tsx b/frontend/src/modules/discovery/components/BrowseFilters.tsx index 1995f68a..202f8a81 100644 --- a/frontend/src/modules/discovery/components/BrowseFilters.tsx +++ b/frontend/src/modules/discovery/components/BrowseFilters.tsx @@ -443,25 +443,27 @@ export const BrowseFilters: React.FC = React.memo(({ ))}

- onItemsPerPageChange(Number(value))} - onOpen={() => setIsTagsOpen(false)} - options={BROWSE_ITEMS_PER_PAGE_OPTIONS.map(size => ({ - value: String(size), - label: String(size) - }))} - placeholder="12" - containerClassName="relative flex-none h-10 w-16" - buttonLabel={itemsPerPage} - buttonAriaLabel="Results per page" - buttonTitle="Results per page" - showSelectedCheck={false} - buttonClassName="w-full h-full flex items-center justify-center gap-1 border rounded-xl px-2 text-xs font-black transition-all whitespace-nowrap bg-white dark:bg-slate-900 border-slate-200 dark:border-white/10 text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-white/[0.02] shadow-sm" - menuAlign="right" - menuClassName="w-16 max-w-[calc(100vw-2rem)] bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10 rounded-xl shadow-xl py-1 z-[70] animate-in fade-in zoom-in-95 duration-200 overflow-hidden" - optionClassName="w-full px-2 py-2 text-sm font-bold flex justify-center items-center transition-colors text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-white/5" - /> + {!isMobile && ( + onItemsPerPageChange(Number(value))} + onOpen={() => setIsTagsOpen(false)} + options={BROWSE_ITEMS_PER_PAGE_OPTIONS.map(size => ({ + value: String(size), + label: String(size) + }))} + placeholder="12" + containerClassName="relative flex-none h-10 w-16" + buttonLabel={itemsPerPage} + buttonAriaLabel="Results per page" + buttonTitle="Results per page" + showSelectedCheck={false} + buttonClassName="w-full h-full flex items-center justify-center gap-1 border rounded-xl px-2 text-xs font-black transition-all whitespace-nowrap bg-white dark:bg-slate-900 border-slate-200 dark:border-white/10 text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-white/[0.02] shadow-sm" + menuAlign="right" + menuClassName="w-16 max-w-[calc(100vw-2rem)] bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10 rounded-xl shadow-xl py-1 z-[70] animate-in fade-in zoom-in-95 duration-200 overflow-hidden" + optionClassName="w-full px-2 py-2 text-sm font-bold flex justify-center items-center transition-colors text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-white/5" + /> + )}

{title}

-
+
{value}{isPercent && %}
{subValue &&
{subValue}
} @@ -715,7 +715,7 @@ const CompactFeaturedModCard = ({ project }: { project: Project }) => {
-

+

{project.title}

@@ -771,7 +771,7 @@ export const TrendingProjectsSection = ({
-

+

Trending

@@ -823,7 +823,7 @@ export const NewReleasesSection = ({
-

+

New Releases

@@ -861,7 +861,7 @@ export const ModpackPreviewSection = ({ randomProject }: { randomProject?: Proje return (
-

+

Modpacks, Upgraded

@@ -883,7 +883,7 @@ export const DirectDownloadsSection = () => { return (

-

+

Direct Downloads

@@ -901,11 +901,66 @@ export const DirectDownloadsSection = () => { ); }; +export const LauncherPreviewSection = () => { + return ( +

+
+

+ Modtale Launcher +

+

+ Native installs, updates, and Hytale launch flows. +

+

+ Download a desktop launcher that can browse Modtale, install compatible project releases, prompt for dependencies, and keep your local Hytale library organized. +

+
+ +
+
+ +
+
+
+
+ + + + Launcher preview +
+ Modtale Launcher browsing a project page +
+
+ {['Browse projects', 'Resolve dependencies', 'Check updates'].map((item) => ( +
+
+ ))} +
+
+
+ ); +}; + export const SmartDependenciesSection = ({ randomProject, previewProjects }: { randomProject?: Project; previewProjects?: Project[] }) => { return (
-

+

Smart Dependencies

@@ -927,7 +982,7 @@ export const ProjectAnalyticsSection = ({ showConversionRate = true }: { showCon return (

-

+

Project Analytics

@@ -949,7 +1004,7 @@ export const CommunityThreadsSection = ({ project, currentUser }: { project?: Pr return (

-

+

Comment Threads

@@ -971,7 +1026,7 @@ export const RealTimeAlertsSection = () => { return (

-

+

Push Notifications

@@ -993,7 +1048,7 @@ export const AccountPreferencesSection = () => { return (

-

+

Notification Control

diff --git a/frontend/src/modules/home/components/HeroMarquee.tsx b/frontend/src/modules/home/components/HeroMarquee.tsx index 48cc5b12..5eac3c84 100644 --- a/frontend/src/modules/home/components/HeroMarquee.tsx +++ b/frontend/src/modules/home/components/HeroMarquee.tsx @@ -57,7 +57,7 @@ export const FeaturedModCard = memo(({ project, priority = false }: { project: P

-

+

{project.title}

diff --git a/frontend/src/modules/home/views/Home.tsx b/frontend/src/modules/home/views/Home.tsx index 823bfabb..8f1b5b9d 100644 --- a/frontend/src/modules/home/views/Home.tsx +++ b/frontend/src/modules/home/views/Home.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useMemo, useRef } from 'react'; import { Link } from 'react-router-dom'; import { Helmet } from 'react-helmet-async'; -import { Search, Upload, Code } from 'lucide-react'; +import { Search, Upload, Code, MonitorDown } from 'lucide-react'; import { GitHubBrandIcon } from '@/components/ui/icons/BrandIcons'; import { api } from '@/utils/api'; import { ROUTE_SEO } from '@/data/seo-constants'; @@ -16,6 +16,7 @@ import { NewReleasesSection, ModpackPreviewSection, DirectDownloadsSection, + LauncherPreviewSection, SmartDependenciesSection, ProjectAnalyticsSection, CommunityThreadsSection, @@ -593,7 +594,6 @@ export const Home: React.FC<{ return (
{homeSeo.title} @@ -1037,7 +1037,7 @@ export const Home: React.FC<{ />
-

+

The Hytale
Community
Repository @@ -1063,26 +1063,33 @@ export const Home: React.FC<{

- + {formatMetric(stats.totalProjects)} Projects
+ +
+ {list.mods.map((item, index) => ( + + ))} +
+ + ); +}; + +export default WorldModListView; diff --git a/frontend/src/pages/[...all].astro b/frontend/src/pages/[...all].astro index cfa99e6a..68443422 100644 --- a/frontend/src/pages/[...all].astro +++ b/frontend/src/pages/[...all].astro @@ -23,7 +23,6 @@ import { } from '../utils/schema'; import { BACKEND_URL } from '@/utils/api.ts'; -const { all } = Astro.params; const url = Astro.url; const path = url.pathname; const cleanPath = normalizeSeoPath(path); @@ -95,13 +94,15 @@ const isBot = isBotUserAgent(userAgent); const pathSegments = cleanPath.split('/').filter(Boolean); const isProjectRoute = pathSegments.length >= 2 && ['project', 'mod', 'modpack', 'world'].includes(pathSegments[0]); -const identifier = isProjectRoute ? pathSegments[1] : (all ? all.split('/').pop() : ''); -const projectId = identifier ? SiteRoutes.extractId(identifier) : ''; const wikiSegmentIndex = isProjectRoute ? pathSegments.indexOf('wiki', 2) : -1; const wikiPagePath = wikiSegmentIndex >= 0 ? pathSegments.slice(wikiSegmentIndex + 1).join('/') : ''; wikiBootstrapPageSlug = wikiPagePath || 'home-1'; -let image = (isProjectRoute && identifier) +const projectRouteKey = isProjectRoute ? SiteRoutes.projectRouteKeyFromPath(cleanPath) : ''; +const projectLookupKey = projectRouteKey ? SiteRoutes.extractId(projectRouteKey) : ''; +const projectId = projectLookupKey; + +let image = (isProjectRoute && projectLookupKey) ? getProjectOgImageUrl(projectId) : 'https://modtale.net/assets/logo.svg'; @@ -237,14 +238,19 @@ if (classification) { } else { jsonLd = baseSchemas; } -} else if (identifier && isProjectRoute && (isBot || (pathSegments[0] === 'project' && pathSegments.length === 2))) { +} else if (projectLookupKey && isProjectRoute + && (isBot || (pathSegments[0] === 'project' && pathSegments.length === 2))) { try { - const modData = await fetchJsonWithTimeout(`${BACKEND_URL}/api/v1/projects/${projectId}`, 2500); + const modData = await fetchJsonWithTimeout(`${BACKEND_URL}/api/v1/projects/${projectLookupKey}`, 2500); if (modData) { const projectUrl = SiteRoutes.project(modData); - if (cleanPath !== normalizeSeoPath(projectUrl)) { - return Astro.redirect(projectUrl, 301); + const canonicalBasePath = normalizeSeoPath(projectUrl); + const currentBasePath = normalizeSeoPath(SiteRoutes.projectBasePathFromPath(cleanPath)); + + if (currentBasePath && currentBasePath !== canonicalBasePath) { + const canonicalPath = cleanPath.replace(currentBasePath, canonicalBasePath); + return Astro.redirect(canonicalPath, 301); } initialData = modData; @@ -325,7 +331,7 @@ if (ssrSuccessfullyCompleted && (isHome || isStaticBrowseRequest)) { Astro.response.headers.set('Cache-Control', 'public, max-age=0, s-maxage=0, must-revalidate'); } -const projectBootstrapUrl = !initialData && isProjectRoute && projectId ? `${BACKEND_URL}/api/v1/projects/${projectId}` : undefined; +const projectBootstrapUrl = !initialData && isProjectRoute && projectLookupKey ? `${BACKEND_URL}/api/v1/projects/${projectLookupKey}` : undefined; const wikiBootstrap = wikiSegmentIndex >= 0 && projectId ? { projectId, diff --git a/frontend/src/pages/sitemap-static.xml.ts b/frontend/src/pages/sitemap-static.xml.ts index af31f06d..f3a71515 100644 --- a/frontend/src/pages/sitemap-static.xml.ts +++ b/frontend/src/pages/sitemap-static.xml.ts @@ -5,6 +5,7 @@ const STATIC_ROUTES = [ { path: '/mods', changefreq: 'daily', priority: '0.95' }, { path: '/plugins', changefreq: 'daily', priority: '0.9' }, { path: '/modpacks', changefreq: 'daily', priority: '0.8' }, + { path: '/launcher', changefreq: 'weekly', priority: '0.85' }, { path: '/art', changefreq: 'weekly', priority: '0.75' }, { path: '/data', changefreq: 'weekly', priority: '0.75' }, { path: '/worlds', changefreq: 'daily', priority: '0.75' }, diff --git a/frontend/src/styles/theme.ts b/frontend/src/styles/theme.ts index 27e4f305..240c3410 100644 --- a/frontend/src/styles/theme.ts +++ b/frontend/src/styles/theme.ts @@ -29,7 +29,7 @@ export const theme = { buttonDanger: 'bg-red-500 hover:bg-red-600 text-white px-5 py-2.5 rounded-xl font-bold transition-colors shadow-sm disabled:opacity-50', buttonGhost: 'px-5 py-2.5 rounded-xl font-bold text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-white/10 transition-colors disabled:opacity-50', iconButton: 'p-2 rounded-full text-slate-400 hover:text-slate-700 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/10 transition-colors', - modalOverlay: 'fixed inset-0 z-[200] flex items-center justify-center bg-slate-900/45 dark:bg-slate-900/70 backdrop-blur-sm p-4 animate-in fade-in duration-200', + modalOverlay: 'fixed inset-0 z-[200] flex items-center justify-center bg-slate-950/55 dark:bg-slate-950/75 p-4 animate-in fade-in duration-200', modalContent: 'bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10 rounded-2xl shadow-2xl overflow-hidden relative flex flex-col', modalHeader: 'p-6 border-b border-slate-200 dark:border-white/10 flex justify-between items-center bg-slate-50/70 dark:bg-slate-800/55 shrink-0', modalBody: 'p-6 space-y-4 overflow-y-auto bg-transparent flex-1', diff --git a/frontend/src/utils/images.ts b/frontend/src/utils/images.ts index 4a2fb7e7..4b91d688 100644 --- a/frontend/src/utils/images.ts +++ b/frontend/src/utils/images.ts @@ -13,14 +13,19 @@ const isLocalEnvironment = () => { return hostname === 'localhost' || hostname === '127.0.0.1' || hostname.startsWith('192.168.'); }; +const isCloudflareImageHost = (hostname: string) => + hostname === 'modtale.net' || hostname.endsWith('.modtale.net'); + export const getCloudflareUrl = (url: string, width: number, quality: number) => { if (!url || url.includes('.svg') || url.startsWith('blob:')) { return url; } const isLocal = isLocalEnvironment(); + const appHost = typeof window !== 'undefined' ? window.location.hostname : 'modtale.net'; + const canUseImageProxyHost = !isLocal && isCloudflareImageHost(appHost); const cloudflareOrigin = 'https://modtale.net'; - let canUseCloudflareProxy = !isLocal; + let canUseCloudflareProxy = canUseImageProxyHost; if (url.startsWith('http')) { try { @@ -28,19 +33,18 @@ export const getCloudflareUrl = (url: string, width: number, quality: number) => const isFirstPartyCdn = srcHost === 'cdn.modtale.net'; if (typeof window !== 'undefined') { - const appHost = window.location.hostname; const isSameHost = srcHost === appHost; const isSubdomainOfAppHost = srcHost.endsWith(`.${appHost}`); if (!isSameHost && !isSubdomainOfAppHost && !isFirstPartyCdn) return url; - canUseCloudflareProxy = !isLocal || isFirstPartyCdn; + canUseCloudflareProxy = canUseImageProxyHost; } else { canUseCloudflareProxy = isFirstPartyCdn; } } catch { return url; } - } else if (isLocal) { + } else if (isLocal || !canUseImageProxyHost) { return url; } diff --git a/frontend/src/utils/routes.ts b/frontend/src/utils/routes.ts index c690c3ff..3f24e5c0 100644 --- a/frontend/src/utils/routes.ts +++ b/frontend/src/utils/routes.ts @@ -1,9 +1,25 @@ +type ProjectRouteParts = { + prefix: string; + routeKey: string; + subroute: string; + segmentCount: number; +}; + export class SiteRoutes { + private static readonly PROJECT_ROUTE_PREFIXES = new Set(['project', 'mod', 'modpack', 'world']); + private static readonly PROJECT_MODAL_SUBROUTES = new Set(['download', 'changelog', 'gallery']); + static home() { return '/'; } static upload() { return '/upload'; } static admin() { return '/admin'; } + static launcher() { return '/launcher'; } static apiDocs() { return '/api-docs'; } static swaggerDocs() { return '/api-docs/swagger'; } + static list(id: string) { return `/lists/${id}`; } + static createModpackFromList(id: string) { + const params = new URLSearchParams({ type: 'MODPACK', fromList: id }); + return `/upload?${params.toString()}`; + } static login(redirectTo?: string) { const params = new URLSearchParams(); if (redirectTo) params.set('redirect', redirectTo); @@ -131,4 +147,47 @@ export class SiteRoutes { const match = param.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i); return match ? match[0] : param; } + + private static projectRouteParts(pathname: string | undefined): ProjectRouteParts | null { + if (!pathname) return null; + + const pathOnly = pathname.split(/[?#]/)[0]; + const normalizedPath = (pathOnly || '/').replace(/\/+$/, '') || '/'; + const segments = normalizedPath.split('/').filter(Boolean); + if (segments.length < 2) return null; + + const prefix = segments[0].toLowerCase(); + if (!this.PROJECT_ROUTE_PREFIXES.has(prefix)) return null; + + return { + prefix, + routeKey: segments[1], + subroute: segments[2]?.toLowerCase() || '', + segmentCount: segments.length + }; + } + + static projectRouteKeyFromPath(pathname: string | undefined) { + return this.projectRouteParts(pathname)?.routeKey || ''; + } + + static projectBasePathFromPath(pathname: string | undefined) { + const parts = this.projectRouteParts(pathname); + if (!parts) return ''; + return `/${parts.prefix}/${parts.routeKey}`; + } + + static isProjectModalRoute(pathname: string | undefined) { + const parts = this.projectRouteParts(pathname); + if (!parts) return false; + return parts.segmentCount === 3 && this.PROJECT_MODAL_SUBROUTES.has(parts.subroute); + } + + static isSameProjectModalContext(previousPathname: string | undefined, nextPathname: string | undefined) { + const previousBasePath = this.projectBasePathFromPath(previousPathname); + const nextBasePath = this.projectBasePathFromPath(nextPathname); + + if (!previousBasePath || previousBasePath !== nextBasePath) return false; + return this.isProjectModalRoute(previousPathname) || this.isProjectModalRoute(nextPathname); + } } diff --git a/frontend/tailwind.config.mjs b/frontend/tailwind.config.mjs index b9f3b93a..559bfead 100644 --- a/frontend/tailwind.config.mjs +++ b/frontend/tailwind.config.mjs @@ -21,7 +21,7 @@ export default { } }, fontFamily: { - sans: ['system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial', 'sans-serif'], + sans: ['Inter Variable', 'Inter', 'SF Pro Text', 'Segoe UI', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Roboto', 'Helvetica Neue', 'Arial', 'sans-serif'], } }, }, diff --git a/frontend/tests/components/MarkdownRichRenderer.test.tsx b/frontend/tests/components/MarkdownRichRenderer.test.tsx index 467c4b3c..25f571f7 100644 --- a/frontend/tests/components/MarkdownRichRenderer.test.tsx +++ b/frontend/tests/components/MarkdownRichRenderer.test.tsx @@ -1,8 +1,12 @@ import { act } from 'react'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createRoot, type Root } from 'react-dom/client'; import { MarkdownRichRenderer } from '@/components/ui/MarkdownRichRenderer'; +vi.mock('@/components/ui/MarkdownSyntaxHighlighter', () => ({ + HighlightedCode: ({ content }: { content: string }) =>
{content}
+})); + describe('MarkdownRichRenderer', () => { let container: HTMLDivElement; let root: Root; @@ -71,4 +75,34 @@ describe('MarkdownRichRenderer', () => { expect(image).not.toBeNull(); expect(image?.getAttribute('src')).toBe('https://cdn.modtale.net/gallery/build.png'); }); + + it('copies markdown code block contents', async () => { + const originalClipboard = navigator.clipboard; + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText } + }); + + try { + await act(async () => { + root.render(); + }); + + const copyButton = container.querySelector('button[title="Copy code"]') as HTMLButtonElement | null; + expect(copyButton).not.toBeNull(); + + await act(async () => { + copyButton?.click(); + }); + + expect(writeText).toHaveBeenCalledWith('{"ok": true}'); + expect(copyButton?.textContent).toContain('Copied'); + } finally { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: originalClipboard + }); + } + }); }); diff --git a/frontend/tests/modules/auth/api/authClient.test.ts b/frontend/tests/modules/auth/api/authClient.test.ts index 36aaa4df..012c5e63 100644 --- a/frontend/tests/modules/auth/api/authClient.test.ts +++ b/frontend/tests/modules/auth/api/authClient.test.ts @@ -55,6 +55,14 @@ describe('authClient', () => { expect(mockedApi.post).toHaveBeenCalledWith('/auth/mfa/validate-login', payload); }); + it('issues launcher auth codes through the auth API', async () => { + const payload = { redirectUri: 'http://127.0.0.1:49152/callback', state: 'state-123' }; + + await authClient.issueLauncherAuthCode(payload); + + expect(mockedApi.post).toHaveBeenCalledWith('/auth/launcher/issue', payload); + }); + it('normalizes signin payloads from both camelCase and snake_case responses', () => { expect(normalizeSignInResponse({ status: 'mfa_required', diff --git a/frontend/tests/modules/auth/views/LauncherAuth.test.tsx b/frontend/tests/modules/auth/views/LauncherAuth.test.tsx new file mode 100644 index 00000000..65b4d5b4 --- /dev/null +++ b/frontend/tests/modules/auth/views/LauncherAuth.test.tsx @@ -0,0 +1,86 @@ +import React, { act } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRoot, type Root } from 'react-dom/client'; +import { MemoryRouter } from 'react-router-dom'; +import { LauncherAuth } from '@/modules/auth/views/LauncherAuth'; +import { authClient } from '@/modules/auth/api/authClient'; +import type { User } from '@/types'; + +vi.mock('@/modules/auth/api/authClient', () => ({ + authClient: { + issueLauncherAuthCode: vi.fn() + } +})); + +const mockedAuthClient = vi.mocked(authClient); + +const signedInUser: User = { + id: 'user-1', + username: 'ada', + displayName: 'Ada Lovelace', + avatarUrl: '/avatar.png', + likedProjectIds: [] +}; + +describe('LauncherAuth', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + vi.clearAllMocks(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + mockedAuthClient.issueLauncherAuthCode.mockReturnValue( + new Promise(() => {}) as ReturnType + ); + }); + + afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); + }); + + it('asks for launcher consent before issuing an auth code', async () => { + await act(async () => { + root.render( + + + + ); + }); + + expect(container.textContent).toContain('Do you want to authenticate with Modtale Launcher?'); + expect(container.textContent).toContain('Ada Lovelace'); + expect(mockedAuthClient.issueLauncherAuthCode).not.toHaveBeenCalled(); + + const authenticateButton = Array.from(container.querySelectorAll('button')) + .find(button => button.textContent?.includes('Authenticate')); + expect(authenticateButton).toBeTruthy(); + + await act(async () => { + authenticateButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(mockedAuthClient.issueLauncherAuthCode).toHaveBeenCalledWith({ + redirectUri: 'http://127.0.0.1:49152/callback', + state: 'state-123' + }); + }); + + it('rejects non-local launcher callback URLs before consent', async () => { + await act(async () => { + root.render( + + + + ); + }); + + expect(container.textContent).toContain('Launcher Sign-In Failed'); + expect(container.textContent).toContain('The launcher callback URL must point to this device.'); + expect(mockedAuthClient.issueLauncherAuthCode).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/tests/modules/discovery/components/BrowseFilters.test.tsx b/frontend/tests/modules/discovery/components/BrowseFilters.test.tsx index affadac8..b9abfe61 100644 --- a/frontend/tests/modules/discovery/components/BrowseFilters.test.tsx +++ b/frontend/tests/modules/discovery/components/BrowseFilters.test.tsx @@ -30,7 +30,8 @@ const renderFilters = ( itemsPerPage = 12, onItemsPerPageChange = vi.fn(), openSourceOnly = false, - setOpenSourceOnly = vi.fn() + setOpenSourceOnly = vi.fn(), + isMobile = false ) => ( { expect(onItemsPerPageChange).toHaveBeenCalledWith(96); }); + + it('hides the results-per-page control on mobile', async () => { + await act(async () => { + root.render(renderFilters(false, 'Any', vi.fn(), vi.fn(), 12, 12, vi.fn(), false, vi.fn(), true)); + }); + + const pageSizeButton = Array.from(container.querySelectorAll('button')).find( + (button) => button.getAttribute('aria-label') === 'Results per page' + ); + + expect(pageSizeButton).toBeUndefined(); + }); }); diff --git a/frontend/tests/modules/home/Home.test.tsx b/frontend/tests/modules/home/Home.test.tsx index 1a21b247..c5fc01c9 100644 --- a/frontend/tests/modules/home/Home.test.tsx +++ b/frontend/tests/modules/home/Home.test.tsx @@ -27,6 +27,7 @@ vi.mock('@/modules/home/components/FeaturePreviews', () => ({ NewReleasesSection: () =>
, ModpackPreviewSection: () =>
, DirectDownloadsSection: () =>
, + LauncherPreviewSection: () =>
, SmartDependenciesSection: () =>
, ProjectAnalyticsSection: () =>
, CommunityThreadsSection: () =>
, diff --git a/frontend/tests/modules/launcher/utils/launcherProtocol.test.ts b/frontend/tests/modules/launcher/utils/launcherProtocol.test.ts new file mode 100644 index 00000000..fc39ee21 --- /dev/null +++ b/frontend/tests/modules/launcher/utils/launcherProtocol.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + buildLauncherInstallUrl, + buildLauncherListInstallUrl, + openLauncherInstallOrFallback, + openLauncherListInstallOrFallback +} from '@/modules/launcher/utils/launcherProtocol'; + +describe('launcherProtocol', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('builds launcher install URLs with project and version context', () => { + expect(buildLauncherInstallUrl({ + projectId: 'project-123', + projectHandle: 'cool-mod', + versionNumber: '1.2.3', + gameVersion: '0.5.4' + })).toBe('modtale://install?projectId=project-123&project=cool-mod&version=1.2.3&gameVersion=0.5.4'); + }); + + it('falls back to the project id when no project handle is available', () => { + expect(buildLauncherInstallUrl({ projectId: 'project-123' })) + .toBe('modtale://install?projectId=project-123&project=project-123'); + }); + + it('builds launcher list install URLs with share context', () => { + expect(buildLauncherListInstallUrl({ + listId: 'list-123', + shareUrl: 'https://modtale.net/lists/list-123' + })).toBe('modtale://install-list?listId=list-123&url=https%3A%2F%2Fmodtale.net%2Flists%2Flist-123'); + }); + + it('runs the fallback if the protocol handoff does not leave the page', () => { + vi.useFakeTimers(); + const openUrl = vi.fn(); + const fallback = vi.fn(); + + openLauncherInstallOrFallback( + { projectId: 'project-123', projectHandle: 'cool-mod' }, + fallback, + { openUrl, timeoutMs: 25 } + ); + + expect(openUrl).toHaveBeenCalledWith('modtale://install?projectId=project-123&project=cool-mod'); + + vi.advanceTimersByTime(25); + + expect(fallback).toHaveBeenCalledOnce(); + }); + + it('does not run fallback after a browser handoff signal', () => { + vi.useFakeTimers(); + const openUrl = vi.fn(); + const fallback = vi.fn(); + + openLauncherInstallOrFallback( + { projectId: 'project-123' }, + fallback, + { openUrl, timeoutMs: 25 } + ); + + window.dispatchEvent(new Event('blur')); + vi.advanceTimersByTime(25); + + expect(fallback).not.toHaveBeenCalled(); + }); + + it('opens list install URLs through the same handoff path', () => { + vi.useFakeTimers(); + const openUrl = vi.fn(); + const fallback = vi.fn(); + + openLauncherListInstallOrFallback( + { listId: 'list-123' }, + fallback, + { openUrl, timeoutMs: 25 } + ); + + expect(openUrl).toHaveBeenCalledWith('modtale://install-list?listId=list-123'); + + vi.advanceTimersByTime(25); + + expect(fallback).toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend/tests/modules/project/components/DownloadModal.test.tsx b/frontend/tests/modules/project/components/DownloadModal.test.tsx index 120f1f5a..4cf025e8 100644 --- a/frontend/tests/modules/project/components/DownloadModal.test.tsx +++ b/frontend/tests/modules/project/components/DownloadModal.test.tsx @@ -12,6 +12,12 @@ const settle = async () => { }); }; +const launcherProtocolMock = vi.hoisted(() => ({ + openLauncherInstallOrFallback: vi.fn() +})); + +vi.mock('@/modules/launcher/utils/launcherProtocol', () => launcherProtocolMock); + describe('DownloadModal Toggle Visibility', () => { let container: HTMLDivElement; let root: Root; @@ -28,6 +34,7 @@ describe('DownloadModal Toggle Visibility', () => { root.unmount(); }); container.remove(); + launcherProtocolMock.openLauncherInstallOrFallback.mockClear(); }); it('hides Alpha/Beta toggle if the project has no alpha/beta versions at all', async () => { @@ -492,4 +499,81 @@ describe('DownloadModal Toggle Visibility', () => { expect(pageText()).not.toContain('This modpack uses external mods'); expect(pageText()).not.toContain('External Library'); }); + + it('keeps the launcher install action hidden without project context', async () => { + const versionsByGame = { + '0.5.4': [ + { id: 'v1', versionNumber: '1.0.0', channel: 'RELEASE', gameVersion: '0.5.4', releaseDate: new Date().toISOString(), fileUrl: '/download.zip' } + ] + }; + + await act(async () => { + root.render( + + + + ); + }); + + expect(pageText()).not.toContain('Install with launcher'); + }); + + it('hands the selected version to the launcher install flow', async () => { + const fallback = vi.fn(); + const versionsByGame = { + '0.5.4': [ + { id: 'v1', versionNumber: '1.0.0', channel: 'RELEASE', gameVersion: '0.5.4', releaseDate: new Date().toISOString(), fileUrl: '/download.zip' } + ] + }; + + await act(async () => { + root.render( + + + + ); + }); + + const launcherButton = Array.from(document.body.querySelectorAll('button')) + .find((button) => button.textContent?.includes('Install with launcher')) as HTMLButtonElement | undefined; + + expect(launcherButton).toBeTruthy(); + + await act(async () => { + launcherButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(launcherProtocolMock.openLauncherInstallOrFallback).toHaveBeenCalledWith( + { + projectId: 'project-123', + projectHandle: 'cool-mod', + versionNumber: '1.0.0', + gameVersion: '0.5.4' + }, + fallback + ); + }); }); diff --git a/frontend/tests/modules/project/components/PostDownloadModal.test.tsx b/frontend/tests/modules/project/components/PostDownloadModal.test.tsx new file mode 100644 index 00000000..80d588f4 --- /dev/null +++ b/frontend/tests/modules/project/components/PostDownloadModal.test.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { act } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRoot, type Root } from 'react-dom/client'; +import { MemoryRouter } from 'react-router-dom'; + +import { PostDownloadModal } from '@/modules/project/components/dialogs/PostDownloadModal'; + +describe('PostDownloadModal', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); + }); + + it('links users to the launcher download page', async () => { + await act(async () => { + root.render( + + + + ); + }); + + expect(document.body.textContent).toContain('You can install mods automatically using the Modtale Launcher.'); + + const launcherLink = document.body.querySelector('a[aria-label="Download Modtale Launcher"]'); + + expect(launcherLink?.getAttribute('href')).toBe('/launcher'); + }); +}); diff --git a/frontend/tests/modules/project/components/ProjectCard.test.tsx b/frontend/tests/modules/project/components/ProjectCard.test.tsx index c3fff583..ba1a096c 100644 --- a/frontend/tests/modules/project/components/ProjectCard.test.tsx +++ b/frontend/tests/modules/project/components/ProjectCard.test.tsx @@ -129,4 +129,45 @@ describe('ProjectCard banner fade', () => { expect(container.textContent).toContain('8'); }); + + it('uses the latest favorite handler after rerendering with unchanged project data', async () => { + const oldToggle = vi.fn(); + const newToggle = vi.fn(); + + await act(async () => { + root.render( + + + + ); + }); + + await act(async () => { + root.render( + + + + ); + }); + + const favoriteButton = container.querySelector('button[aria-label="7 favorites"]') as HTMLButtonElement | null; + expect(favoriteButton).not.toBeNull(); + + await act(async () => { + favoriteButton?.click(); + }); + + expect(oldToggle).not.toHaveBeenCalled(); + expect(newToggle).toHaveBeenCalledWith('project-1'); + }); }); diff --git a/frontend/tests/modules/project/components/ProjectLayout.test.tsx b/frontend/tests/modules/project/components/ProjectLayout.test.tsx new file mode 100644 index 00000000..3643ed0d --- /dev/null +++ b/frontend/tests/modules/project/components/ProjectLayout.test.tsx @@ -0,0 +1,66 @@ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ProjectLayout } from '@/modules/project/components/ProjectLayout'; + +const layoutProps = { + iconUrl: '/icon.png', + headerContent:

Project Title

, + mainContent:
Main content
, + sidebarContent:
Sidebar content
+}; + +describe('ProjectLayout banner rendering', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); + }); + + it('does not render a public banner area when the project has no banner', async () => { + await act(async () => { + root.render(); + }); + + expect(container.querySelector('.modtale-project-banner-parallax')).toBeNull(); + expect(container.textContent).not.toContain('Upload Banner'); + }); + + it('renders the banner area when the project has a banner', async () => { + await act(async () => { + root.render(); + }); + + expect(container.querySelector('.modtale-project-banner-parallax')).not.toBeNull(); + expect(container.querySelector('img[alt="Project Banner"]')).not.toBeNull(); + }); + + it('keeps the banner upload target visible while editing without a banner', async () => { + const onBannerUpload = vi.fn(); + + await act(async () => { + root.render( + + ); + }); + + expect(container.querySelector('.modtale-project-banner-parallax')).not.toBeNull(); + expect(container.textContent).toContain('Upload Banner'); + }); +}); diff --git a/frontend/tests/performance/renderSpeed.test.tsx b/frontend/tests/performance/renderSpeed.test.tsx index 075439fd..38a3660b 100644 --- a/frontend/tests/performance/renderSpeed.test.tsx +++ b/frontend/tests/performance/renderSpeed.test.tsx @@ -11,6 +11,7 @@ import { Browse } from '@/modules/discovery/views/Browse'; import { ProjectDetails } from '@/modules/project/views/ProjectDetails'; import { discoveryClient } from '@/modules/discovery/api/discoveryClient'; import { projectClient } from '@/modules/project/api/projectClient'; +import { theme } from '@/styles/theme'; import type { Project } from '@/types'; vi.mock('@/modules/discovery/api/discoveryClient', () => ({ @@ -256,4 +257,9 @@ describe('critical page render budgets', () => { expect(navbarSource).toContain("import('@/modules/auth/components/SignInModal.tsx')"); expect(navbarSource).toContain("import('@/modules/user/components/NotificationMenu')"); }); + + it('keeps shared modal overlays off backdrop-filter paint paths', () => { + expect(theme.components.modalOverlay).not.toContain('backdrop-blur'); + expect(theme.components.modalOverlay).not.toContain('backdrop-filter'); + }); }); diff --git a/frontend/tests/utils/images.test.ts b/frontend/tests/utils/images.test.ts index 8ad3ae62..5875ee0e 100644 --- a/frontend/tests/utils/images.test.ts +++ b/frontend/tests/utils/images.test.ts @@ -53,6 +53,15 @@ describe('image utils', () => { .toBe('https://modtale.net/cdn-cgi/image/width=128,quality=90,format=auto,onerror=redirect/https://cdn.modtale.net/images/hero.png'); }); + it('returns raw urls on Cloud Run preview hosts that cannot serve Cloudflare image resizing', () => { + setWindowLocation('modtale-frontend-launcher-ptpi2wdeva-uc.a.run.app', 'https://modtale-frontend-launcher-ptpi2wdeva-uc.a.run.app'); + + expect(getCloudflareUrl('https://cdn.modtale.net/images/hero.png', 80, 90)) + .toBe('https://cdn.modtale.net/images/hero.png'); + expect(getCloudflareUrl('/images/hero.png', 500, 75)) + .toBe('/images/hero.png'); + }); + it('builds a fallback absolute url in SSR mode for relative paths', () => { vi.stubGlobal('window', undefined); expect(getCloudflareUrl('images/hero.png', 70, 80)) diff --git a/frontend/tests/utils/routes.test.ts b/frontend/tests/utils/routes.test.ts index 7bc1924d..bf8e797a 100644 --- a/frontend/tests/utils/routes.test.ts +++ b/frontend/tests/utils/routes.test.ts @@ -2,6 +2,10 @@ import { describe, expect, it } from 'vitest'; import { SiteRoutes } from '@/utils/routes'; describe('SiteRoutes', () => { + it('returns the launcher route', () => { + expect(SiteRoutes.launcher()).toBe('/launcher'); + }); + it('maps browse routes by classification', () => { expect(SiteRoutes.browse('PLUGIN')).toBe('/plugins'); expect(SiteRoutes.browse('MODPACK')).toBe('/modpacks'); diff --git a/launcher/README.md b/launcher/README.md new file mode 100644 index 00000000..d1137435 --- /dev/null +++ b/launcher/README.md @@ -0,0 +1,81 @@ +# Modtale Launcher + +Native Java 21 JavaFX launcher for installing and updating Modtale projects in a local Hytale mods folder. A linked Hytale account is required before the launcher UI is available. Modtale sign-in is only required for Modtale API-backed features such as browsing, installs, updates, notifications, following, and favorites; local world mod management and game launch can work without a Modtale session. + +The Play tab can launch a locally installed Hytale client with official Hytale authentication. Hytale launch first tries to refresh the Hytale OAuth session and create fresh game-session tokens. If Hytale authentication is unavailable or rejects the active session, Modtale may reuse the last Hytale-issued launch tokens stored for the linked account and still starts the client in authenticated mode so Hytale itself applies its first-party offline restrictions. The launcher does not create offline identities, fabricate tokens, or provide a piracy launch mode. + +## Run From Source + +```bash +./gradlew run +``` + +Launcher sign-in talks to `https://api.modtale.net/api/v1` by default. OAuth provider flows return to a temporary local launcher callback. Browser fallback sign-in opens `https://modtale.net`. For local auth testing, override the site and API URLs with environment variables: + +```bash +MODTALE_SITE_BASE_URL=http://localhost:5173 MODTALE_API_BASE_URL=http://localhost:8080/api/v1 ./gradlew run +``` + +Discord Rich Presence is enabled when a Discord application ID is configured: + +```bash +MODTALE_DISCORD_CLIENT_ID=123456789012345678 ./gradlew run +``` + +The equivalent JVM property is `-Dmodtale.discordClientId=123456789012345678`. `DISCORD_CLIENT_ID` is also accepted as a fallback for environments that already provide the OAuth application ID. +Packaged builds can embed the ID with `./gradlew packageAll -PdiscordClientId=123456789012345678`. + +## Build Packages + +```bash +./gradlew build +``` + +The launcher build produces a self-contained native package for the host OS: + +- Windows hosts: `build/distributions/Modtale Launcher-.exe` +- macOS hosts: `build/distributions/Modtale Launcher-.dmg` +- Linux hosts: `build/distributions/modtale-launcher--.AppImage` + +Each package embeds its own Java runtime and launcher dependencies; users do not need a system JDK or JRE to run it. Windows and macOS builds produce OS installers. Linux `packageLinux` produces a self-contained AppImage; desktop integration is handled by the user's AppImage integration tool when present. `packageAll` is matrix-friendly: run it on Windows, macOS, and Linux hosts to produce the default release artifact for each platform. + +Additional Linux package formats are available: + +```bash +./gradlew packageLinuxDeb +./gradlew packageLinuxRpm +./gradlew packageLinuxFlatpak +./gradlew packageLinuxPacman +./gradlew packageLinuxAll +``` + +These produce: + +- `build/distributions/modtale-launcher_-_.deb` +- `build/distributions/modtale-launcher--..rpm` +- `build/distributions/net.modtale.launcher--.flatpak` +- `build/distributions/modtale-launcher---.pkg.tar.zst` + +Build hosts still need a full JDK 21 because `jpackage` is a JDK tool. Linux AppImage builds also need `appimagetool` on `PATH`; set `APPIMAGETOOL` or `-PappImageTool=/path/to/appimagetool` if it is installed elsewhere. RPM builds need `rpmbuild`, Flatpak builds need `flatpak` plus the configured Freedesktop runtime/SDK, and pacman builds need `zstd`. Windows installer builds require the native packaging tools expected by `jpackage` for `.exe` or `.msi` output. + +Alternative native installer types can be selected per host: + +```bash +./gradlew packageWindows -PwindowsPackageType=msi +./gradlew packageMac -PmacPackageType=pkg +``` + +To smoke-test a self-contained unpacked app image without creating an installer, run: + +```bash +./gradlew jpackageAppImage +build/jpackage/app-image/Modtale\ Launcher/bin/Modtale\ Launcher +``` + +The jar remains available in `build/libs/` as a build artifact for development and diagnostics, but it is not the end-user launcher distribution. + +## GitHub Releases and Updates + +The launcher release workflow builds Windows, macOS, and Linux packages in GitHub Actions and attaches them to a GitHub release. Push a tag like `launcher-v0.2.0`, or run the `Launcher Release` workflow manually with a version, to publish native launcher assets. + +Packaged launchers check `Modtale/modtale` releases for newer launcher builds. The updater matches release assets by platform (`.exe`/`.msi`, `.dmg`/`.pkg`, or `.AppImage`) and opens the matching installer when the user chooses to update. For development or forks, override the release source with `MODTALE_LAUNCHER_UPDATES_REPOSITORY=owner/repo`. diff --git a/launcher/build.gradle b/launcher/build.gradle new file mode 100644 index 00000000..ddced4ed --- /dev/null +++ b/launcher/build.gradle @@ -0,0 +1,942 @@ +plugins { + id 'application' +} + +def configuredLauncherVersion = providers.gradleProperty('launcherVersion') + .orElse(providers.environmentVariable('LAUNCHER_VERSION')) + .orElse('0.1.0-SNAPSHOT') + .get() + +group = 'net.modtale' +version = configuredLauncherVersion + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'com.fasterxml.jackson.core:jackson-databind:2.22.0' + implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.22.0' + implementation 'com.vladsch.flexmark:flexmark:0.64.8' + implementation 'com.vladsch.flexmark:flexmark-ext-autolink:0.64.8' + implementation 'com.vladsch.flexmark:flexmark-ext-gfm-strikethrough:0.64.8' + implementation 'com.vladsch.flexmark:flexmark-ext-tables:0.64.8' + implementation 'com.vladsch.flexmark:flexmark-ext-gfm-tasklist:0.64.8' + implementation 'net.java.dev.jna:jna:5.19.1' + implementation 'org.apache.logging.log4j:log4j-api:2.26.0' + implementation 'org.apache.logging.log4j:log4j-core:2.26.0' + implementation "org.openjfx:javafx-base:21.0.11:${javafxPlatform()}" + implementation "org.openjfx:javafx-graphics:21.0.11:${javafxPlatform()}" + implementation "org.openjfx:javafx-controls:21.0.11:${javafxPlatform()}" + + testImplementation 'org.junit.jupiter:junit-jupiter:6.1.0' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:6.1.0' +} + +application { + mainClass = 'net.modtale.launcher.LauncherMain' +} + +def appId = 'net.modtale.launcher' +def appDisplayName = 'Modtale Launcher' +def appSlug = 'modtale-launcher' +def packageVersion = project.version.toString() +def discordClientId = providers.gradleProperty('discordClientId') + .orElse(providers.environmentVariable('MODTALE_DISCORD_CLIENT_ID')) + .orElse(providers.environmentVariable('DISCORD_CLIENT_ID')) + .orElse('') + .get() +def modtaleSiteBaseUrl = providers.gradleProperty('modtaleSiteBaseUrl') + .orElse(providers.gradleProperty('siteBaseUrl')) + .orElse('') + .get() +def modtaleApiBaseUrl = providers.gradleProperty('modtaleApiBaseUrl') + .orElse(providers.gradleProperty('apiBaseUrl')) + .orElse('') + .get() +def launcherJvmArgs = ["-Dmodtale.launcherVersion=${packageVersion}"] +if (!discordClientId.isBlank()) { + launcherJvmArgs.add("-Dmodtale.discordClientId=${discordClientId.trim()}") +} +if (!modtaleSiteBaseUrl.isBlank()) { + launcherJvmArgs.add("-Dmodtale.siteBaseUrl=${modtaleSiteBaseUrl.trim()}") +} +if (!modtaleApiBaseUrl.isBlank()) { + launcherJvmArgs.add("-Dmodtale.apiBaseUrl=${modtaleApiBaseUrl.trim()}") +} +def installerVersionMatcher = packageVersion =~ /\d+(?:\.\d+){0,2}/ +def installerVersion = installerVersionMatcher.find() ? installerVersionMatcher.group() : '0.1.0' +def jpackageAppVersion = jpackageCompatibleVersion(installerVersion) +def appDescription = 'Native Modtale launcher for Hytale mods.' +def appVendor = 'Modtale' +def appHomepage = 'https://modtale.net' +def appCopyright = 'Copyright (C) Modtale contributors' +def appLicenseId = 'AGPL-3.0-or-later' +def appLicense = layout.projectDirectory.file('../LICENSE') +def appIconPng = layout.projectDirectory.file('src/main/resources/net/modtale/launcher/ui/nativefx/assets/favicon.png') +def distributionDirectory = layout.buildDirectory.dir('distributions') +def linuxAppImageDirectory = layout.buildDirectory.dir('jpackage/linux-app-image') +def linuxAppDir = layout.buildDirectory.dir("appimage/${appSlug}.AppDir") +def linuxPackageRootDirectory = layout.buildDirectory.dir('linux-package/root') +def linuxPackageWorkDirectory = layout.buildDirectory.dir('linux-package/work') +def linuxInstallDirectory = "/opt/${appSlug}" +def linuxInstallPath = linuxInstallDirectory.replaceFirst('^/', '') +def linuxPackageRelease = providers.gradleProperty('linuxPackageRelease') + .orElse(providers.environmentVariable('LINUX_PACKAGE_RELEASE')) + .orElse('1') + .get() +def linuxDebMaintainer = providers.gradleProperty('linuxDebMaintainer') + .orElse('Modtale contributors ') + .get() +def flatpakRuntimeVersion = providers.gradleProperty('flatpakRuntimeVersion').orElse('25.08').get() +def flatpakRuntime = providers.gradleProperty('flatpakRuntime').orElse('org.freedesktop.Platform').get() +def flatpakSdk = providers.gradleProperty('flatpakSdk').orElse('org.freedesktop.Sdk').get() +def flatpakBranch = providers.gradleProperty('flatpakBranch').orElse('stable').get() +def flatpakBuildDirectory = layout.buildDirectory.dir('flatpak/build') +def flatpakRepoDirectory = layout.buildDirectory.dir('flatpak/repo') +def currentOs = org.gradle.internal.os.OperatingSystem.current() +def javaRuntimeModules = [ + 'java.base', + 'java.datatransfer', + 'java.desktop', + 'java.logging', + 'java.net.http', + 'java.prefs', + 'java.sql', + 'java.xml', + 'jdk.crypto.ec', + 'jdk.httpserver', + 'jdk.unsupported' +] +def jpackageJlinkOptions = [ + '--strip-native-commands', + '--strip-debug', + '--no-man-pages', + '--no-header-files', + '--compress=zip-9' +] + +application { + applicationDefaultJvmArgs = launcherJvmArgs +} + +tasks.named('jar') { + manifest { + attributes( + 'Implementation-Title': appDisplayName, + 'Implementation-Version': packageVersion, + 'Implementation-Vendor': appVendor + ) + } +} + +def appJar = tasks.named('jar').flatMap { it.archiveFile } +def runtimeClasspathFiles = configurations.runtimeClasspath +def windowsPackageType = providers.gradleProperty('windowsPackageType') + .orElse('exe') + .map { it.toLowerCase(Locale.ROOT) } + .get() +def macPackageType = providers.gradleProperty('macPackageType') + .orElse('dmg') + .map { it.toLowerCase(Locale.ROOT) } + .get() +def appImageTool = providers.gradleProperty('appImageTool') + .orElse(providers.environmentVariable('APPIMAGETOOL')) + .orElse('appimagetool') +def linuxAppImageFile = distributionDirectory.map { + it.file("${appSlug}-${installerVersion}-${appImageArch()}.AppImage") +} +def linuxDebFile = distributionDirectory.map { + it.file("${appSlug}_${installerVersion}-${linuxPackageRelease}_${debArchitecture()}.deb") +} +def linuxRpmFile = distributionDirectory.map { + it.file("${appSlug}-${installerVersion}-${linuxPackageRelease}.${rpmArchitecture()}.rpm") +} +def linuxPacmanFile = distributionDirectory.map { + it.file("${appSlug}-${installerVersion}-${linuxPackageRelease}-${pacmanArchitecture()}.pkg.tar.zst") +} +def linuxFlatpakFile = distributionDirectory.map { + it.file("${appId}-${installerVersion}-${flatpakArchitecture()}.flatpak") +} + +tasks.register('prepareJpackageInput', Sync) { + group = 'distribution' + description = 'Collects launcher jars for self-contained jpackage installers.' + dependsOn tasks.named('jar') + into layout.buildDirectory.dir('jpackage/input') + from(appJar) + from(runtimeClasspathFiles) +} + +def java21Launcher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(21) +} + +def jpackageExecutable = java21Launcher.map { + def extension = currentOs.isWindows() ? '.exe' : '' + file("${it.executablePath.asFile.parentFile}/jpackage${extension}") +} + +def commonJpackageArgs = { + [ + '--input', layout.buildDirectory.dir('jpackage/input').get().asFile.absolutePath, + '--name', appDisplayName, + '--main-jar', appJar.get().asFile.name, + '--main-class', application.mainClass.get(), + '--app-version', jpackageAppVersion, + '--vendor', appVendor, + '--description', appDescription, + '--add-modules', javaRuntimeModules.join(','), + '--jlink-options', jpackageJlinkOptions.join(' ') + ] + (['-Dfile.encoding=UTF-8'] + launcherJvmArgs).collectMany { ['--java-options', it] } +} + +def installerJpackageArgs = { + [ + '--about-url', appHomepage, + '--copyright', appCopyright, + '--license-file', appLicense.asFile.absolutePath + ] +} + +def desktopFileContent = { String execName, String iconName -> + """[Desktop Entry] +Type=Application +Name=${appDisplayName} +Comment=Install and update Hytale mods from Modtale +Exec=${execName} +Icon=${iconName} +Terminal=false +Categories=Game; +StartupNotify=true +StartupWMClass=net.modtale.launcher.ModtaleLauncher +""" +} + +def metainfoContent = { + """ + + ${appId} + ${appDisplayName} + Install and update Hytale mods from Modtale + CC0-1.0 + ${appLicenseId} + + Modtale contributors + + +

Install, update, and manage Hytale mods from Modtale with a self-contained native launcher.

+
+ ${appId}.desktop + ${appHomepage} + +
+""" +} + +def configureJpackageTask = { TaskProvider packageTask, String packageType, Closure hostPredicate, Closure> platformArgs -> + packageTask.configure { + group = 'distribution' + dependsOn tasks.named('prepareJpackageInput') + onlyIf { + hostPredicate.call() + } + doFirst { + def executable = jpackageExecutable.get() + if (!executable.exists()) { + throw new GradleException("jpackage was not found at ${executable}. Install a full JDK 21 to build native launcher installers.") + } + + delete layout.buildDirectory.dir("jpackage/tmp/${name}") + mkdir distributionDirectory.get().asFile + + commandLine([ + executable.absolutePath, + '--type', packageType, + '--dest', distributionDirectory.get().asFile.absolutePath, + '--temp', layout.buildDirectory.dir("jpackage/tmp/${name}").get().asFile.absolutePath + ] + commonJpackageArgs.call() + installerJpackageArgs.call() + platformArgs.call()) + } + } +} + +configureJpackageTask( + tasks.register('packageWindows', Exec) { + description = 'Builds a self-contained Windows .exe installer with an embedded Java runtime.' + }, + windowsPackageType, + { currentOs.isWindows() }, + { + def args = [ + '--win-menu', + '--win-menu-group', appDisplayName, + '--win-shortcut', + '--win-upgrade-uuid', '3d6c6852-fc6f-4a7e-a617-988263243f90' + ] + if (windowsPackageType == 'exe') { + args += [ + '--win-dir-chooser', + '--win-per-user-install' + ] + } + args + } +) + +configureJpackageTask( + tasks.register('packageMac', Exec) { + description = 'Builds a self-contained macOS .dmg containing a native .app bundle and embedded Java runtime.' + }, + macPackageType, + { currentOs.isMacOsX() }, + { + [ + '--mac-package-identifier', appId, + '--mac-package-name', appDisplayName + ] + } +) + +tasks.register('prepareLinuxAppImageRuntime', Exec) { + group = 'distribution' + description = 'Builds the self-contained Linux runtime image used by the AppImage package.' + dependsOn tasks.named('prepareJpackageInput') + onlyIf { + currentOs.isLinux() + } + doFirst { + def executable = jpackageExecutable.get() + if (!executable.exists()) { + throw new GradleException("jpackage was not found at ${executable}. Install a full JDK 21 to build the Linux AppImage.") + } + + delete linuxAppImageDirectory + commandLine([ + executable.absolutePath, + '--type', 'app-image', + '--dest', linuxAppImageDirectory.get().asFile.absolutePath, + '--icon', appIconPng.asFile.absolutePath + ] + commonJpackageArgs.call()) + } +} + +tasks.register('prepareLinuxAppDir') { + group = 'distribution' + description = 'Assembles the AppDir consumed by appimagetool.' + dependsOn tasks.named('prepareLinuxAppImageRuntime') + onlyIf { + currentOs.isLinux() + } + inputs.dir linuxAppImageDirectory + outputs.dir linuxAppDir + doLast { + def appDir = linuxAppDir.get().asFile + def packagedApp = linuxAppImageDirectory.get().dir(appDisplayName).asFile + if (!packagedApp.exists()) { + throw new GradleException("Expected jpackage app image at ${packagedApp}, but it was not created.") + } + + delete appDir + copy { + from(packagedApp) + into(appDir) + } + + copy { + from(appIconPng) + into(appDir) + rename { "${appSlug}.png" } + } + copy { + from(appIconPng) + into(appDir) + rename { '.DirIcon' } + } + copy { + from(appIconPng) + into(new File(appDir, 'usr/share/icons/hicolor/256x256/apps')) + rename { "${appSlug}.png" } + } + + def appRun = new File(appDir, 'AppRun') + appRun.text = '''#!/usr/bin/env sh +set -eu +APPDIR="${APPDIR:-$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)}" +if [ -n "${WAYLAND_DISPLAY:-}" ]; then + export GDK_BACKEND=x11 +fi +exec "$APPDIR/bin/Modtale Launcher" "$@" +''' + appRun.setExecutable(true, false) + + def appImageDesktopFileContent = desktopFileContent(appSlug, appSlug) + def desktopFile = new File(appDir, "${appId}.desktop") + desktopFile.text = appImageDesktopFileContent + + def desktopInstallFile = new File(appDir, "usr/share/applications/${appId}.desktop") + desktopInstallFile.parentFile.mkdirs() + desktopInstallFile.text = appImageDesktopFileContent + + def metainfo = new File(appDir, "usr/share/metainfo/${appId}.appdata.xml") + metainfo.parentFile.mkdirs() + metainfo.text = metainfoContent() + } +} + +tasks.register('packageLinux', Exec) { + group = 'distribution' + description = 'Builds a self-contained Linux AppImage with an embedded Java runtime.' + dependsOn tasks.named('prepareLinuxAppDir') + onlyIf { + currentOs.isLinux() + } + inputs.dir linuxAppDir + outputs.file linuxAppImageFile + doFirst { + def tool = resolveExecutable(appImageTool.get(), 'appimagetool', 'Install appimagetool, or set -PappImageTool=/path/to/appimagetool or APPIMAGETOOL.') + mkdir distributionDirectory.get().asFile + delete linuxAppImageFile.get().asFile + commandLine tool, linuxAppDir.get().asFile.absolutePath, linuxAppImageFile.get().asFile.absolutePath + } +} + +tasks.register('prepareLinuxPackageRoot') { + group = 'distribution' + description = 'Stages the self-contained Linux launcher tree used by DEB, RPM, and Flatpak packages.' + dependsOn tasks.named('prepareLinuxAppImageRuntime') + onlyIf { + currentOs.isLinux() + } + inputs.dir linuxAppImageDirectory + outputs.dir linuxPackageRootDirectory + doLast { + def packagedApp = linuxAppImageDirectory.get().dir(appDisplayName).asFile + if (!packagedApp.exists()) { + throw new GradleException("Expected jpackage app image at ${packagedApp}, but it was not created.") + } + + def rootDir = linuxPackageRootDirectory.get().asFile + delete rootDir + copy { + from(packagedApp) + into(new File(rootDir, linuxInstallPath)) + } + + def wrapper = new File(rootDir, "usr/bin/${appSlug}") + wrapper.parentFile.mkdirs() +wrapper.text = """#!/usr/bin/env sh +set -eu +APPDIR="${linuxInstallDirectory}" +exec "\$APPDIR/bin/${appDisplayName}" "\$@" +""" + wrapper.setExecutable(true, false) + + copy { + from(appIconPng) + into(new File(rootDir, "usr/share/icons/hicolor/256x256/apps")) + rename { "${appSlug}.png" } + } + + def desktopFile = new File(rootDir, "usr/share/applications/${appId}.desktop") + desktopFile.parentFile.mkdirs() + desktopFile.text = desktopFileContent(appSlug, appSlug) + + def metainfo = new File(rootDir, "usr/share/metainfo/${appId}.appdata.xml") + metainfo.parentFile.mkdirs() + metainfo.text = metainfoContent() + + copy { + from(appLicense) + into(new File(rootDir, "usr/share/licenses/${appSlug}")) + rename { 'LICENSE' } + } + } +} + +tasks.register('packageLinuxDeb') { + group = 'distribution' + description = 'Builds a self-contained Linux .deb package for the launcher.' + dependsOn tasks.named('prepareLinuxPackageRoot') + onlyIf { + currentOs.isLinux() + } + inputs.dir linuxPackageRootDirectory + outputs.file linuxDebFile + doLast { + def tar = resolveExecutable('tar', 'tar', 'Install tar to create Debian packages.') + def ar = resolveExecutable('ar', 'ar', 'Install binutils ar to create Debian packages.') + def rootDir = linuxPackageRootDirectory.get().asFile + def workDir = new File(linuxPackageWorkDirectory.get().asFile, 'deb') + def controlDir = new File(workDir, 'control') + def debianBinary = new File(workDir, 'debian-binary') + def controlTar = new File(workDir, 'control.tar.xz') + def dataTar = new File(workDir, 'data.tar.xz') + def output = linuxDebFile.get().asFile + + delete workDir + controlDir.mkdirs() + mkdir distributionDirectory.get().asFile + delete output + + long installedBytes = 0 + rootDir.eachFileRecurse(groovy.io.FileType.FILES) { file -> + installedBytes += file.length() + } + long installedSizeKb = Math.max(1L, Math.ceil(installedBytes / 1024.0D) as long) + + new File(controlDir, 'control').text = """Package: ${appSlug} +Version: ${installerVersion}-${linuxPackageRelease} +Section: games +Priority: optional +Architecture: ${debArchitecture()} +Maintainer: ${linuxDebMaintainer} +Installed-Size: ${installedSizeKb} +Depends: libc6, libx11-6, libxext6, libxrender1, libxtst6, libxi6, libfreetype6, fontconfig, libgtk-3-0, zlib1g +Homepage: ${appHomepage} +Description: ${appDescription} + Install, update, and manage Hytale mods from Modtale with a self-contained native launcher. +""" + debianBinary.text = '2.0\n' + + runExternalCommand([ + tar, '--sort=name', '--mtime=@0', '--owner=0', '--group=0', '--numeric-owner', + '-C', controlDir.absolutePath, '-cJf', controlTar.absolutePath, 'control' + ], null, ['XZ_OPT': '-9e']) + runExternalCommand([ + tar, '--sort=name', '--mtime=@0', '--owner=0', '--group=0', '--numeric-owner', + '-C', rootDir.absolutePath, '-cJf', dataTar.absolutePath, '.' + ], null, ['XZ_OPT': '-9e']) + runExternalCommand([ar, 'rcs', output.absolutePath, debianBinary.name, controlTar.name, dataTar.name], workDir) + } +} + +tasks.register('packageLinuxRpm') { + group = 'distribution' + description = 'Builds a self-contained Linux .rpm package for the launcher.' + dependsOn tasks.named('prepareLinuxPackageRoot') + onlyIf { + currentOs.isLinux() + } + inputs.dir linuxPackageRootDirectory + outputs.file linuxRpmFile + doLast { + def rpmbuild = resolveExecutable('rpmbuild', 'rpmbuild', 'Install rpm-build to create RPM packages.') + def tar = resolveExecutable('tar', 'tar', 'Install tar to create RPM packages.') + def rootDir = linuxPackageRootDirectory.get().asFile + def workDir = new File(linuxPackageWorkDirectory.get().asFile, 'rpm') + def sourceParent = new File(workDir, 'source') + def sourceRoot = new File(sourceParent, "${appSlug}-${installerVersion}-root") + def sourcesDir = new File(workDir, 'SOURCES') + def specsDir = new File(workDir, 'SPECS') + def specFile = new File(specsDir, "${appSlug}.spec") + def sourceTar = new File(sourcesDir, "${appSlug}-${installerVersion}-root.tar.gz") + def output = linuxRpmFile.get().asFile + + delete workDir + sourceRoot.mkdirs() + sourcesDir.mkdirs() + specsDir.mkdirs() + mkdir new File(workDir, 'BUILD') + mkdir new File(workDir, 'BUILDROOT') + mkdir new File(workDir, 'RPMS') + mkdir new File(workDir, 'SRPMS') + mkdir distributionDirectory.get().asFile + delete output + + copy { + from(rootDir) + into(sourceRoot) + } + runExternalCommand([tar, '-C', sourceParent.absolutePath, '-czf', sourceTar.absolutePath, sourceRoot.name]) + + specFile.text = """Name: ${appSlug} +Version: ${installerVersion} +Release: ${linuxPackageRelease} +Summary: ${appDescription} +License: ${appLicenseId} +URL: ${appHomepage} +BuildArch: ${rpmArchitecture()} +Source0: %{name}-%{version}-root.tar.gz +%global debug_package %{nil} +%global __brp_check_rpaths %{nil} + +%description +Install, update, and manage Hytale mods from Modtale with a self-contained native launcher. + +%prep +%setup -q -n %{name}-%{version}-root + +%build + +%install +mkdir -p %{buildroot} +cp -a . %{buildroot}/ + +%files +%license /usr/share/licenses/${appSlug}/LICENSE +${linuxInstallDirectory} +/usr/bin/${appSlug} +/usr/share/applications/${appId}.desktop +/usr/share/icons/hicolor/256x256/apps/${appSlug}.png +/usr/share/metainfo/${appId}.appdata.xml +""" + + runExternalCommand([ + rpmbuild, '-bb', + '--define', "_topdir ${workDir.absolutePath}", + '--define', '_binary_payload w19.zstdio', + specFile.absolutePath + ], null, ['QA_RPATHS': '0x0002']) + + def builtRpms = fileTree(new File(workDir, "RPMS/${rpmArchitecture()}")).matching { + include '*.rpm' + }.files + if (builtRpms.isEmpty()) { + throw new GradleException("rpmbuild completed but no RPM was produced in ${new File(workDir, "RPMS/${rpmArchitecture()}")}.") + } + copy { + from(builtRpms.first()) + into(distributionDirectory) + rename { output.name } + } + } +} + +tasks.register('packageLinuxPacman') { + group = 'distribution' + description = 'Builds a self-contained Arch Linux pacman package for the launcher.' + dependsOn tasks.named('prepareLinuxPackageRoot') + onlyIf { + currentOs.isLinux() + } + inputs.dir linuxPackageRootDirectory + outputs.file linuxPacmanFile + doLast { + def tar = resolveExecutable('tar', 'tar', 'Install tar to create pacman packages.') + resolveExecutable('zstd', 'zstd', 'Install zstd to create pacman packages.') + def rootDir = linuxPackageRootDirectory.get().asFile + def workDir = new File(linuxPackageWorkDirectory.get().asFile, 'pacman') + def packageRoot = new File(workDir, 'pkg') + def output = linuxPacmanFile.get().asFile + + delete workDir + packageRoot.mkdirs() + mkdir distributionDirectory.get().asFile + delete output + + copy { + from(rootDir) + into(packageRoot) + } + + long installedBytes = 0 + rootDir.eachFileRecurse(groovy.io.FileType.FILES) { file -> + installedBytes += file.length() + } + + new File(packageRoot, '.PKGINFO').text = """pkgname = ${appSlug} +pkgbase = ${appSlug} +pkgver = ${installerVersion}-${linuxPackageRelease} +pkgdesc = ${appDescription} +url = ${appHomepage} +builddate = 0 +packager = Modtale contributors +size = ${installedBytes} +arch = ${pacmanArchitecture()} +license = ${appLicenseId} +depend = glibc +depend = libx11 +depend = libxext +depend = libxrender +depend = libxtst +depend = libxi +depend = freetype2 +depend = fontconfig +depend = gtk3 +""" + + runExternalCommand([ + tar, '--sort=name', '--mtime=@0', '--owner=0', '--group=0', '--numeric-owner', + '--zstd', '-C', packageRoot.absolutePath, '-cf', output.absolutePath, '.' + ], null, ['ZSTD_CLEVEL': '19']) + } +} + +tasks.register('packageLinuxFlatpak') { + group = 'distribution' + description = 'Builds a self-contained Linux Flatpak bundle for the launcher.' + dependsOn tasks.named('prepareLinuxAppImageRuntime') + onlyIf { + currentOs.isLinux() + } + inputs.dir linuxAppImageDirectory + outputs.file linuxFlatpakFile + doLast { + def flatpak = resolveExecutable('flatpak', 'flatpak', 'Install flatpak to create Flatpak bundles.') + def packagedApp = linuxAppImageDirectory.get().dir(appDisplayName).asFile + if (!packagedApp.exists()) { + throw new GradleException("Expected jpackage app image at ${packagedApp}, but it was not created.") + } + + def buildDir = flatpakBuildDirectory.get().asFile + def repoDir = flatpakRepoDirectory.get().asFile + def filesDir = new File(buildDir, 'files') + def output = linuxFlatpakFile.get().asFile + + delete buildDir + delete repoDir + mkdir distributionDirectory.get().asFile + delete output + + runExternalCommand([ + flatpak, 'build-init', "--arch=${flatpakArchitecture()}", + buildDir.absolutePath, appId, flatpakSdk, flatpakRuntime, flatpakRuntimeVersion + ]) + + copy { + from(packagedApp) + into(new File(filesDir, appSlug)) + } + + def wrapper = new File(filesDir, "bin/${appSlug}") + wrapper.parentFile.mkdirs() +wrapper.text = """#!/usr/bin/env sh +set -eu +APPDIR="/app/${appSlug}" +exec "\$APPDIR/bin/${appDisplayName}" "\$@" +""" + wrapper.setExecutable(true, false) + + copy { + from(appIconPng) + into(new File(filesDir, 'share/icons/hicolor/256x256/apps')) + rename { "${appId}.png" } + } + + def desktopFile = new File(filesDir, "share/applications/${appId}.desktop") + desktopFile.parentFile.mkdirs() + desktopFile.text = desktopFileContent(appSlug, appId) + + def metainfo = new File(filesDir, "share/metainfo/${appId}.appdata.xml") + metainfo.parentFile.mkdirs() + metainfo.text = metainfoContent() + + copy { + from(appLicense) + into(new File(filesDir, "share/licenses/${appSlug}")) + rename { 'LICENSE' } + } + + runExternalCommand([ + flatpak, 'build-finish', + "--command=${appSlug}", + '--share=network', + '--share=ipc', + '--socket=x11', + '--socket=wayland', + '--device=dri', + '--filesystem=home', + '--talk-name=org.freedesktop.portal.Desktop', + buildDir.absolutePath + ]) + runExternalCommand([ + flatpak, 'build-export', "--arch=${flatpakArchitecture()}", + repoDir.absolutePath, buildDir.absolutePath, flatpakBranch + ]) + runExternalCommand([ + flatpak, 'build-bundle', "--arch=${flatpakArchitecture()}", + repoDir.absolutePath, output.absolutePath, appId, flatpakBranch + ]) + } +} + +tasks.register('packageLinuxInstallers') { + group = 'distribution' + description = 'Builds Linux Flatpak, RPM, DEB, and pacman launcher packages.' + dependsOn tasks.named('packageLinuxFlatpak'), tasks.named('packageLinuxRpm'), tasks.named('packageLinuxDeb'), tasks.named('packageLinuxPacman') +} + +tasks.register('packageLinuxAll') { + group = 'distribution' + description = 'Builds all Linux launcher package formats, including AppImage, Flatpak, RPM, DEB, and pacman.' + dependsOn tasks.named('packageLinux'), tasks.named('packageLinuxInstallers') +} + +tasks.register('packageAll') { + group = 'distribution' + description = 'Builds the self-contained native installer for the current OS. Run on Windows, macOS, and Linux hosts to produce all release artifacts.' + dependsOn tasks.named('packageMac'), tasks.named('packageWindows'), tasks.named('packageLinux') +} + +tasks.register('jpackageAppImage', Exec) { + group = 'distribution' + description = 'Builds a self-contained unpacked app image for the current host OS.' + dependsOn tasks.named('prepareJpackageInput') + doFirst { + def executable = jpackageExecutable.get() + if (!executable.exists()) { + throw new GradleException("jpackage was not found at ${executable}. Install a full JDK 21 to build native launcher app images.") + } + + delete layout.buildDirectory.dir('jpackage/app-image') + commandLine([ + executable.absolutePath, + '--type', 'app-image', + '--dest', layout.buildDirectory.dir('jpackage/app-image').get().asFile.absolutePath + ] + commonJpackageArgs.call()) + } +} + +tasks.named('assemble') { + dependsOn tasks.named('packageAll') +} + +tasks.named('distZip') { + enabled = false +} + +tasks.named('distTar') { + enabled = false +} + +tasks.named('assembleDist') { + enabled = false +} + +tasks.named('installDist') { + enabled = false +} + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' + options.compilerArgs += ['-parameters'] +} + +tasks.named('test') { + useJUnitPlatform() +} + +def javafxPlatform() { + def os = org.gradle.internal.os.OperatingSystem.current() + def arch = System.getProperty('os.arch', '').toLowerCase(Locale.ROOT) + if (os.isWindows()) { + return 'win' + } + if (os.isMacOsX()) { + return arch.contains('aarch64') || arch.contains('arm64') ? 'mac-aarch64' : 'mac' + } + return arch.contains('aarch64') || arch.contains('arm64') ? 'linux-aarch64' : 'linux' +} + +def appImageArch() { + def arch = System.getProperty('os.arch', '').toLowerCase(Locale.ROOT) + if (arch == 'amd64' || arch == 'x86_64') { + return 'x86_64' + } + if (arch.contains('aarch64') || arch.contains('arm64')) { + return 'aarch64' + } + return arch.replaceAll(/[^a-z0-9_+-]/, '-') +} + +def jpackageCompatibleVersion(String version) { + def parts = version.tokenize('.').collect { Integer.parseInt(it) } + if (parts.isEmpty()) { + return '1' + } + if (parts[0] < 1) { + parts[0] = 1 + } + return parts.take(3).join('.') +} + +def debArchitecture() { + def arch = System.getProperty('os.arch', '').toLowerCase(Locale.ROOT) + if (arch == 'amd64' || arch == 'x86_64') { + return 'amd64' + } + if (arch.contains('aarch64') || arch.contains('arm64')) { + return 'arm64' + } + return arch.replaceAll(/[^a-z0-9_+-]/, '-') +} + +def rpmArchitecture() { + def arch = System.getProperty('os.arch', '').toLowerCase(Locale.ROOT) + if (arch == 'amd64') { + return 'x86_64' + } + if (arch.contains('aarch64') || arch.contains('arm64')) { + return 'aarch64' + } + return arch.replaceAll(/[^a-z0-9_+-]/, '-') +} + +def flatpakArchitecture() { + def arch = System.getProperty('os.arch', '').toLowerCase(Locale.ROOT) + if (arch == 'amd64' || arch == 'x86_64') { + return 'x86_64' + } + if (arch.contains('aarch64') || arch.contains('arm64')) { + return 'aarch64' + } + return arch.replaceAll(/[^a-z0-9_+-]/, '-') +} + +def pacmanArchitecture() { + def arch = System.getProperty('os.arch', '').toLowerCase(Locale.ROOT) + if (arch == 'amd64' || arch == 'x86_64') { + return 'x86_64' + } + if (arch.contains('aarch64') || arch.contains('arm64')) { + return 'aarch64' + } + return arch.replaceAll(/[^a-z0-9_+-]/, '-') +} + +def runExternalCommand(List command, File workingDirectory = null, Map environmentOverrides = [:]) { + def normalizedCommand = command.collect { it.toString() } + def processBuilder = new ProcessBuilder(normalizedCommand) + if (workingDirectory != null) { + processBuilder.directory(workingDirectory) + } + processBuilder.environment().putAll(environmentOverrides) + processBuilder.inheritIO() + def process = processBuilder.start() + def exitCode = process.waitFor() + if (exitCode != 0) { + throw new GradleException("Command failed with exit code ${exitCode}: ${normalizedCommand.join(' ')}") + } +} + +def resolveExecutable(String candidate, String displayName, String installHint = "Install ${displayName}, or configure its path.") { + def candidateFile = new File(candidate) + if (candidateFile.isAbsolute() || candidate.contains(File.separator)) { + if (!candidateFile.isAbsolute()) { + candidateFile = file(candidate) + } + if (candidateFile.exists() && candidateFile.canExecute()) { + return candidateFile.absolutePath + } + throw new GradleException("${displayName} was not found or is not executable at ${candidateFile}. ${installHint}") + } + + def path = System.getenv('PATH') ?: '' + for (String entry : path.split(File.pathSeparator)) { + if (entry == null || entry.isBlank()) { + continue + } + def executable = new File(entry, candidate) + if (executable.exists() && executable.canExecute()) { + return executable.absolutePath + } + } + + throw new GradleException("${displayName} was not found on PATH. ${installHint}") +} diff --git a/launcher/gradle/wrapper/gradle-wrapper.jar b/launcher/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..61285a65 Binary files /dev/null and b/launcher/gradle/wrapper/gradle-wrapper.jar differ diff --git a/launcher/gradle/wrapper/gradle-wrapper.properties b/launcher/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..a351597e --- /dev/null +++ b/launcher/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/launcher/gradlew b/launcher/gradlew new file mode 100755 index 00000000..adff685a --- /dev/null +++ b/launcher/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/launcher/gradlew.bat b/launcher/gradlew.bat new file mode 100644 index 00000000..c4bdd3ab --- /dev/null +++ b/launcher/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/launcher/settings.gradle b/launcher/settings.gradle new file mode 100644 index 00000000..de0be0d3 --- /dev/null +++ b/launcher/settings.gradle @@ -0,0 +1,2 @@ +rootProject.name = 'modtale-launcher' + diff --git a/launcher/src/main/java/net/modtale/launcher/LauncherMain.java b/launcher/src/main/java/net/modtale/launcher/LauncherMain.java new file mode 100644 index 00000000..292c90de --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/LauncherMain.java @@ -0,0 +1,21 @@ +package net.modtale.launcher; + +import javafx.application.Application; +import net.modtale.launcher.logging.LauncherLogging; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +public final class LauncherMain { + + private static final Logger LOG = LogManager.getLogger(LauncherMain.class); + + private LauncherMain() { + } + + public static void main(String[] args) { + LauncherLogging.initialize(); + LauncherRenderSettings.configure(); + LOG.info("Starting Modtale Launcher " + System.getProperty("modtale.launcherVersion", "dev")); + Application.launch(ModtaleLauncher.class, args); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/LauncherPerformanceProbe.java b/launcher/src/main/java/net/modtale/launcher/LauncherPerformanceProbe.java new file mode 100644 index 00000000..93d11c65 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/LauncherPerformanceProbe.java @@ -0,0 +1,264 @@ +package net.modtale.launcher; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import javafx.animation.AnimationTimer; +import javafx.scene.Scene; + +public final class LauncherPerformanceProbe { + + public static final String PERF_LOG_PROPERTY = "modtale.launcher.perfLog"; + public static final String PERF_REFRESH_RATE_PROPERTY = "modtale.launcher.perfRefreshRate"; + + private static final long REPORT_INTERVAL_NANOS = 1_000_000_000L; + private static final double DEFAULT_REFRESH_RATE = 60.0; + private static final ConcurrentMap OPERATION_STATS = new ConcurrentHashMap<>(); + private static volatile boolean operationTimingEnabled; + + private LauncherPerformanceProbe() { + } + + public static void install(Scene scene) { + String outputPath = System.getProperty(PERF_LOG_PROPERTY); + if (outputPath == null || outputPath.isBlank()) { + return; + } + + operationTimingEnabled = true; + FrameTimer timer = new FrameTimer(Path.of(outputPath), targetFrameMillis()); + scene.windowProperty().addListener((observable, oldWindow, newWindow) -> { + if (newWindow == null) { + timer.stop(); + } else { + timer.start(); + } + }); + if (scene.getWindow() != null) { + timer.start(); + } + } + + public static long operationStartNanos() { + return operationTimingEnabled ? System.nanoTime() : 0; + } + + public static void recordOperation(String name, long startNanos) { + if (!operationTimingEnabled || startNanos == 0 || name == null || name.isBlank()) { + return; + } + OPERATION_STATS.computeIfAbsent(name, ignored -> new OperationStats()).add(System.nanoTime() - startNanos); + } + + private static double targetFrameMillis() { + String configured = System.getProperty(PERF_REFRESH_RATE_PROPERTY, + System.getProperty(LauncherRenderSettings.REFRESH_RATE_OVERRIDE_PROPERTY)); + if (configured != null && !configured.isBlank()) { + try { + double refreshRate = Double.parseDouble(configured.trim()); + if (Double.isFinite(refreshRate) && refreshRate > 0) { + return 1000.0 / refreshRate; + } + } catch (NumberFormatException ignored) { + // Fall back to the default below. + } + } + return 1000.0 / DEFAULT_REFRESH_RATE; + } + + private static final class FrameTimer extends AnimationTimer { + + private final Path outputPath; + private final double targetFrameMillis; + private final List intervals = new ArrayList<>(256); + private long previousFrame; + private long windowStart; + private boolean running; + + private FrameTimer(Path outputPath, double targetFrameMillis) { + this.outputPath = outputPath; + this.targetFrameMillis = targetFrameMillis; + write("start targetFrameMillis=" + format(targetFrameMillis) + " at=" + Instant.now()); + } + + @Override + public void start() { + if (running) { + return; + } + running = true; + previousFrame = 0; + windowStart = 0; + super.start(); + } + + @Override + public void stop() { + if (!running) { + return; + } + report("stop"); + running = false; + previousFrame = 0; + windowStart = 0; + super.stop(); + } + + @Override + public void handle(long now) { + if (previousFrame != 0) { + intervals.add((now - previousFrame) / 1_000_000.0); + } + previousFrame = now; + if (windowStart == 0) { + windowStart = now; + } + if (now - windowStart >= REPORT_INTERVAL_NANOS) { + report("window"); + windowStart = now; + } + } + + private void report(String reason) { + if (intervals.isEmpty()) { + reportOperations(reason); + return; + } + + List sorted = new ArrayList<>(intervals); + Collections.sort(sorted); + double avg = intervals.stream().mapToDouble(Double::doubleValue).average().orElse(0); + double p95 = percentile(sorted, 0.95); + double p99 = percentile(sorted, 0.99); + double max = sorted.get(sorted.size() - 1); + double missedFrameThreshold = targetFrameMillis * 1.35; + long missed = intervals.stream().filter(value -> value > missedFrameThreshold).count(); + long overTwoFrames = intervals.stream().filter(value -> value > targetFrameMillis * 2.0).count(); + write("%s frames=%d avgMs=%s p95Ms=%s p99Ms=%s maxMs=%s missed=%d overTwoFrames=%d thresholdMs=%s at=%s" + .formatted( + reason, + intervals.size(), + format(avg), + format(p95), + format(p99), + format(max), + missed, + overTwoFrames, + format(missedFrameThreshold), + Instant.now() + )); + intervals.clear(); + reportOperations(reason); + } + + private void reportOperations(String reason) { + List reports = OPERATION_STATS.entrySet().stream() + .map(entry -> OperationReport.from(entry.getKey(), entry.getValue().drain())) + .filter(OperationReport::hasSamples) + .sorted(Comparator.comparing(OperationReport::name)) + .toList(); + for (OperationReport report : reports) { + write("operation %s name=%s samples=%d avgUs=%s p95Us=%s p99Us=%s maxUs=%s over1Ms=%d at=%s" + .formatted( + reason, + report.name(), + report.samples(), + format(report.avgMicros()), + format(report.p95Micros()), + format(report.p99Micros()), + format(report.maxMicros()), + report.overOneMillisecond(), + Instant.now() + )); + } + } + + private double percentile(List sorted, double percentile) { + if (sorted.isEmpty()) { + return 0; + } + int index = Math.max(0, Math.min(sorted.size() - 1, + (int) Math.ceil(sorted.size() * percentile) - 1)); + return sorted.get(index); + } + + private void write(String line) { + try { + Path parent = outputPath.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Files.writeString(outputPath, line + System.lineSeparator(), + StandardOpenOption.CREATE, StandardOpenOption.APPEND); + } catch (IOException ignored) { + // Diagnostics must never affect launcher behavior. + } + } + + private static String format(double value) { + return String.format(java.util.Locale.US, "%.3f", value); + } + } + + private static final class OperationStats { + + private final List samples = new ArrayList<>(1024); + + private synchronized void add(long nanos) { + samples.add(Math.max(0, nanos)); + } + + private synchronized List drain() { + if (samples.isEmpty()) { + return List.of(); + } + List drained = new ArrayList<>(samples); + samples.clear(); + return drained; + } + } + + private record OperationReport( + String name, + int samples, + double avgMicros, + double p95Micros, + double p99Micros, + double maxMicros, + long overOneMillisecond + ) { + + private boolean hasSamples() { + return samples > 0; + } + + private static OperationReport from(String name, List samples) { + if (samples.isEmpty()) { + return new OperationReport(name, 0, 0, 0, 0, 0, 0); + } + List sorted = new ArrayList<>(samples); + Collections.sort(sorted); + double avgMicros = samples.stream().mapToDouble(value -> value / 1_000.0).average().orElse(0); + double p95Micros = percentile(sorted, 0.95) / 1_000.0; + double p99Micros = percentile(sorted, 0.99) / 1_000.0; + double maxMicros = sorted.getLast() / 1_000.0; + long overOneMillisecond = samples.stream().filter(value -> value > 1_000_000).count(); + return new OperationReport(name, samples.size(), avgMicros, p95Micros, p99Micros, maxMicros, + overOneMillisecond); + } + + private static long percentile(List sorted, double percentile) { + int index = Math.max(0, Math.min(sorted.size() - 1, + (int) Math.ceil(sorted.size() * percentile) - 1)); + return sorted.get(index); + } + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/LauncherRenderSettings.java b/launcher/src/main/java/net/modtale/launcher/LauncherRenderSettings.java new file mode 100644 index 00000000..964e2104 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/LauncherRenderSettings.java @@ -0,0 +1,125 @@ +package net.modtale.launcher; + +import java.awt.AWTError; +import java.awt.DisplayMode; +import java.awt.GraphicsDevice; +import java.awt.GraphicsEnvironment; +import java.util.OptionalInt; +import java.util.Properties; + +final class LauncherRenderSettings { + + static final String REFRESH_RATE_OVERRIDE_PROPERTY = "modtale.launcher.refreshRate"; + static final String JAVAFX_FRAME_RATE_PROPERTY = "javafx.animation.framerate"; + static final String JAVAFX_PULSE_PROPERTY = "javafx.animation.pulse"; + static final String PRISM_ORDER_PROPERTY = "prism.order"; + static final String PRISM_VSYNC_PROPERTY = "prism.vsync"; + + private static final int DEFAULT_REFRESH_RATE = 60; + private static final int MIN_REFRESH_RATE = 30; + private static final int MAX_REFRESH_RATE = 1000; + private static final String WINDOWS_PRISM_ORDER = "d3d,sw"; + private static final String MACOS_PRISM_ORDER = "es2,sw"; + private static final String LINUX_PRISM_ORDER = "es2,sw"; + + private LauncherRenderSettings() { + } + + static void configure() { + configure(System.getProperties(), System.getProperty("os.name"), detectDisplayRefreshRate()); + } + + static void configure(Properties properties, String osName, OptionalInt detectedRefreshRate) { + OptionalInt configuredRefreshRate = parseRefreshRate(properties.getProperty(REFRESH_RATE_OVERRIDE_PROPERTY)); + OptionalInt targetRefreshRate = configuredRefreshRate; + if (configuredRefreshRate.isEmpty()) { + targetRefreshRate = detectedRefreshRate; + } + configureFrameRate(properties, targetRefreshRate, configuredRefreshRate.isPresent()); + setDefault(properties, PRISM_ORDER_PROPERTY, prismOrder(osName)); + setDefault(properties, PRISM_VSYNC_PROPERTY, "true"); + } + + static OptionalInt preferredRefreshRate(int... refreshRates) { + OptionalInt preferred = OptionalInt.empty(); + for (int refreshRate : refreshRates) { + OptionalInt normalized = normalizeRefreshRate(refreshRate); + if (normalized.isPresent() && (preferred.isEmpty() || normalized.getAsInt() > preferred.getAsInt())) { + preferred = normalized; + } + } + return preferred; + } + + private static void configureFrameRate(Properties properties, OptionalInt targetRefreshRate, boolean explicitRefreshRate) { + if (targetRefreshRate.isEmpty() + || (!explicitRefreshRate && targetRefreshRate.getAsInt() <= DEFAULT_REFRESH_RATE) + || hasSetting(properties, JAVAFX_FRAME_RATE_PROPERTY) + || hasSetting(properties, JAVAFX_PULSE_PROPERTY)) { + return; + } + String refreshRate = Integer.toString(targetRefreshRate.getAsInt()); + properties.setProperty(JAVAFX_FRAME_RATE_PROPERTY, refreshRate); + properties.setProperty(JAVAFX_PULSE_PROPERTY, refreshRate); + } + + private static OptionalInt detectDisplayRefreshRate() { + if (GraphicsEnvironment.isHeadless()) { + return OptionalInt.empty(); + } + try { + GraphicsDevice[] devices = GraphicsEnvironment + .getLocalGraphicsEnvironment() + .getScreenDevices(); + int[] refreshRates = new int[devices.length]; + for (int i = 0; i < devices.length; i++) { + refreshRates[i] = devices[i].getDisplayMode().getRefreshRate(); + } + return preferredRefreshRate(refreshRates); + } catch (AWTError | RuntimeException ex) { + return OptionalInt.empty(); + } + } + + private static OptionalInt parseRefreshRate(String value) { + if (value == null || value.isBlank()) { + return OptionalInt.empty(); + } + try { + return normalizeRefreshRate(Integer.parseInt(value.trim())); + } catch (NumberFormatException ex) { + return OptionalInt.empty(); + } + } + + private static OptionalInt normalizeRefreshRate(int refreshRate) { + if (refreshRate == DisplayMode.REFRESH_RATE_UNKNOWN + || refreshRate < MIN_REFRESH_RATE + || refreshRate > MAX_REFRESH_RATE) { + return OptionalInt.empty(); + } + return OptionalInt.of(refreshRate); + } + + private static String prismOrder(String osName) { + String normalized = osName == null ? "" : osName.toLowerCase(); + if (normalized.contains("win")) { + return WINDOWS_PRISM_ORDER; + } + if (normalized.contains("mac") || normalized.contains("darwin")) { + return MACOS_PRISM_ORDER; + } + return LINUX_PRISM_ORDER; + } + + private static void setDefault(Properties properties, String key, String value) { + if (!hasSetting(properties, key)) { + properties.setProperty(key, value); + } + } + + private static boolean hasSetting(Properties properties, String key) { + String value = properties.getProperty(key); + return value != null && !value.isBlank(); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ModtaleLauncher.java b/launcher/src/main/java/net/modtale/launcher/ModtaleLauncher.java new file mode 100644 index 00000000..2dc1b44b --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ModtaleLauncher.java @@ -0,0 +1,27 @@ +package net.modtale.launcher; + +import javafx.application.Application; +import javafx.stage.Stage; +import net.modtale.launcher.ui.shell.LauncherRuntime; + +public final class ModtaleLauncher extends Application { + + private LauncherRuntime runtime; + + public static void main(String[] args) { + LauncherMain.main(args); + } + + @Override + public void start(Stage stage) { + runtime = LauncherRuntime.create(); + runtime.start(stage, getParameters()); + } + + @Override + public void stop() { + if (runtime != null) { + runtime.shutdown(); + } + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/api/ApiCachePolicy.java b/launcher/src/main/java/net/modtale/launcher/api/ApiCachePolicy.java new file mode 100644 index 00000000..2ad47b2b --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/api/ApiCachePolicy.java @@ -0,0 +1,35 @@ +package net.modtale.launcher.api; + +import java.time.Duration; + +final class ApiCachePolicy { + + private ApiCachePolicy() { + } + + static Duration ttlFor(String pathAndQuery) { + if (!isPublicCacheableGet(pathAndQuery)) { + return Duration.ZERO; + } + if (pathAndQuery.startsWith("/meta/")) { + return Duration.ofHours(24); + } + if (pathAndQuery.startsWith("/projects")) { + return Duration.ofHours(26); + } + return Duration.ofHours(2); + } + + static boolean isEnabled(Duration ttl) { + return ttl != null && !ttl.isZero() && !ttl.isNegative(); + } + + private static boolean isPublicCacheableGet(String pathAndQuery) { + String normalized = pathAndQuery == null ? "" : pathAndQuery; + return normalized.startsWith("/meta/") + || (normalized.startsWith("/projects") + && !normalized.contains("/comments") + && !normalized.contains("/download-url") + && !normalized.contains("/download-bundle-url")); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/api/ApiPathBuilder.java b/launcher/src/main/java/net/modtale/launcher/api/ApiPathBuilder.java new file mode 100644 index 00000000..cf3be589 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/api/ApiPathBuilder.java @@ -0,0 +1,38 @@ +package net.modtale.launcher.api; + +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.List; + +final class ApiPathBuilder { + + private ApiPathBuilder() { + } + + static URI normalizeBaseUri(String apiBaseUrl, String fallbackBaseUrl) { + String value = apiBaseUrl == null || apiBaseUrl.isBlank() ? fallbackBaseUrl : apiBaseUrl.trim(); + return URI.create(value.replaceAll("/+$", "")); + } + + static URI apiUri(URI apiBaseUri, String pathAndQuery) { + String base = apiBaseUri.toString().replaceAll("/+$", ""); + String path = pathAndQuery.startsWith("/") ? pathAndQuery : "/" + pathAndQuery; + return URI.create(base + path); + } + + static void addParam(List params, String name, String value) { + if (value == null || value.isBlank()) { + return; + } + params.add(encodeQuery(name) + "=" + encodeQuery(value)); + } + + static String encodePath(String value) { + return encodeQuery(value).replace("+", "%20"); + } + + static String encodeQuery(String value) { + return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/api/ApiResponseCache.java b/launcher/src/main/java/net/modtale/launcher/api/ApiResponseCache.java new file mode 100644 index 00000000..603b0209 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/api/ApiResponseCache.java @@ -0,0 +1,154 @@ +package net.modtale.launcher.api; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.time.Instant; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.stream.Stream; +import net.modtale.launcher.cache.LauncherCachePaths; + +final class ApiResponseCache { + + private static final Duration STALE_FALLBACK_TTL = Duration.ofDays(7); + + private final Path cacheDirectory; + private final ObjectMapper mapper; + private final ConcurrentMap memory = new ConcurrentHashMap<>(); + + ApiResponseCache() { + this(LauncherCachePaths.cacheDirectory("api")); + } + + ApiResponseCache(Path cacheDirectory) { + this.cacheDirectory = cacheDirectory; + this.mapper = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + } + + Optional getFresh(URI uri, Duration ttl) { + return get(uri, ttl); + } + + Optional getStaleFallback(URI uri) { + return get(uri, STALE_FALLBACK_TTL); + } + + void put(URI uri, String body) { + if (body == null) { + return; + } + String key = uri.toString(); + CacheEntry entry = new CacheEntry(body, Instant.now()); + memory.put(key, entry); + try { + Files.createDirectories(cacheDirectory); + mapper.writeValue(cacheFile(uri).toFile(), CachedBody.from(entry)); + } catch (IOException ignored) { + // The in-memory cache is still useful if the disk cache cannot be written. + } + } + + void invalidate(URI uri) { + memory.remove(uri.toString()); + try { + Files.deleteIfExists(cacheFile(uri)); + } catch (IOException ignored) { + // Cache cleanup is best-effort. + } + } + + void clear() { + memory.clear(); + if (!Files.exists(cacheDirectory)) { + return; + } + try (Stream stream = Files.walk(cacheDirectory)) { + List paths = stream + .filter(path -> !cacheDirectory.equals(path)) + .sorted(Comparator.reverseOrder()) + .toList(); + for (Path path : paths) { + Files.deleteIfExists(path); + } + } catch (IOException ignored) { + // Cache cleanup is best-effort. + } + } + + private Optional get(URI uri, Duration ttl) { + if (ttl == null || ttl.isNegative() || ttl.isZero()) { + return Optional.empty(); + } + + String key = uri.toString(); + CacheEntry memoryEntry = memory.get(key); + if (memoryEntry != null && memoryEntry.isFresh(ttl)) { + return Optional.of(memoryEntry.body()); + } + + Optional diskEntry = readDisk(uri); + diskEntry.ifPresent(entry -> memory.put(key, entry)); + return diskEntry.filter(entry -> entry.isFresh(ttl)).map(CacheEntry::body); + } + + private Optional readDisk(URI uri) { + Path file = cacheFile(uri); + if (!Files.isRegularFile(file)) { + return Optional.empty(); + } + try { + CachedBody cached = mapper.readValue(file.toFile(), CachedBody.class); + if (cached.body() == null || cached.writtenAtEpochMilli() <= 0) { + invalidate(uri); + return Optional.empty(); + } + return Optional.of(new CacheEntry(cached.body(), Instant.ofEpochMilli(cached.writtenAtEpochMilli()))); + } catch (IOException ex) { + invalidate(uri); + return Optional.empty(); + } + } + + private Path cacheFile(URI uri) { + return cacheDirectory.resolve(sha256(uri.toString()) + ".json"); + } + + private static String sha256(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] bytes = digest.digest(value.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(bytes); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException("SHA-256 is not available.", ex); + } + } + + private record CacheEntry(String body, Instant writtenAt) { + + boolean isFresh(Duration ttl) { + return writtenAt != null && writtenAt.plus(ttl).isAfter(Instant.now()); + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private record CachedBody(long writtenAtEpochMilli, String body) { + + static CachedBody from(CacheEntry entry) { + return new CachedBody(entry.writtenAt().toEpochMilli(), entry.body()); + } + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/api/LauncherSessionStore.java b/launcher/src/main/java/net/modtale/launcher/api/LauncherSessionStore.java new file mode 100644 index 00000000..e6f44703 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/api/LauncherSessionStore.java @@ -0,0 +1,188 @@ +package net.modtale.launcher.api; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.net.CookieStore; +import java.net.HttpCookie; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; + +final class LauncherSessionStore { + + private static final Duration MAX_SESSION_AGE = Duration.ofDays(7); + private static final Set SESSION_COOKIE_NAMES = Set.of("SESSION", "JSESSIONID", "XSRF-TOKEN"); + + private final Path sessionPath; + private final ObjectMapper mapper = new ObjectMapper(); + + LauncherSessionStore(Path sessionPath) { + this.sessionPath = sessionPath; + } + + synchronized boolean loadInto(CookieStore cookieStore, URI baseUri) { + if (sessionPath == null || !Files.exists(sessionPath)) { + return false; + } + + try { + List storedCookies = mapper.readValue( + sessionPath.toFile(), + new TypeReference<>() { + } + ); + Instant now = Instant.now(); + int loaded = 0; + for (StoredCookie storedCookie : storedCookies) { + Optional cookie = storedCookie.toHttpCookie(now); + if (cookie.isPresent()) { + cookieStore.add(storedCookie.originUri(baseUri), cookie.get()); + loaded++; + } + } + if (loaded == 0) { + clear(cookieStore); + return false; + } + return true; + } catch (IOException | RuntimeException ex) { + clear(cookieStore); + return false; + } + } + + synchronized void saveFrom(CookieStore cookieStore, URI baseUri) { + if (sessionPath == null) { + return; + } + + Instant now = Instant.now(); + List storedCookies = cookieStore.get(baseUri).stream() + .filter(LauncherSessionStore::isSessionCookie) + .flatMap(cookie -> StoredCookie.from(baseUri, cookie, now).stream()) + .toList(); + + if (storedCookies.isEmpty()) { + clear(cookieStore); + return; + } + + try { + Path parent = sessionPath.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + mapper.writerWithDefaultPrettyPrinter().writeValue(sessionPath.toFile(), storedCookies); + } catch (IOException ex) { + throw new ModtaleApiException("Could not save launcher session to " + sessionPath, ex); + } + } + + synchronized void clear(CookieStore cookieStore) { + cookieStore.removeAll(); + if (sessionPath == null) { + return; + } + try { + Files.deleteIfExists(sessionPath); + } catch (IOException ex) { + throw new ModtaleApiException("Could not clear launcher session from " + sessionPath, ex); + } + } + + boolean hasSessionFile() { + return sessionPath != null && Files.exists(sessionPath); + } + + private static boolean isSessionCookie(HttpCookie cookie) { + return cookie != null + && SESSION_COOKIE_NAMES.contains(cookie.getName().toUpperCase(Locale.ROOT)) + && !cookie.hasExpired(); + } + + private static final class StoredCookie { + public String name; + public String value; + public String domain; + public String path; + public boolean secure; + public boolean httpOnly; + public int version; + public String portList; + public String originUri; + public long expiresAtEpochSecond; + + public StoredCookie() { + } + + private static Optional from(URI baseUri, HttpCookie cookie, Instant now) { + if (!isSessionCookie(cookie)) { + return Optional.empty(); + } + + long maxAge = cookie.getMaxAge(); + long ttlSeconds = maxAge < 0 + ? MAX_SESSION_AGE.toSeconds() + : Math.min(maxAge, MAX_SESSION_AGE.toSeconds()); + if (ttlSeconds <= 0) { + return Optional.empty(); + } + + StoredCookie storedCookie = new StoredCookie(); + storedCookie.name = cookie.getName(); + storedCookie.value = cookie.getValue(); + storedCookie.domain = cookie.getDomain(); + storedCookie.path = cookie.getPath(); + storedCookie.secure = cookie.getSecure(); + storedCookie.httpOnly = cookie.isHttpOnly(); + storedCookie.version = cookie.getVersion(); + storedCookie.portList = cookie.getPortlist(); + storedCookie.originUri = baseUri.toString(); + storedCookie.expiresAtEpochSecond = now.plusSeconds(ttlSeconds).getEpochSecond(); + return Optional.of(storedCookie); + } + + private Optional toHttpCookie(Instant now) { + if (name == null || name.isBlank() || value == null) { + return Optional.empty(); + } + + long remainingSeconds = expiresAtEpochSecond - now.getEpochSecond(); + if (remainingSeconds <= 0) { + return Optional.empty(); + } + + HttpCookie cookie = new HttpCookie(name, value); + if (domain != null && !domain.isBlank()) { + cookie.setDomain(domain); + } + cookie.setPath(path == null || path.isBlank() ? "/" : path); + cookie.setSecure(secure); + cookie.setHttpOnly(httpOnly); + cookie.setVersion(version); + cookie.setMaxAge(remainingSeconds); + if (portList != null && !portList.isBlank()) { + cookie.setPortlist(portList); + } + return Optional.of(cookie); + } + + private URI originUri(URI fallback) { + if (originUri == null || originUri.isBlank()) { + return fallback; + } + try { + return URI.create(originUri); + } catch (IllegalArgumentException ex) { + return fallback; + } + } + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/api/ModtaleApiClient.java b/launcher/src/main/java/net/modtale/launcher/api/ModtaleApiClient.java new file mode 100644 index 00000000..5b596dfb --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/api/ModtaleApiClient.java @@ -0,0 +1,524 @@ +package net.modtale.launcher.api; + +import com.fasterxml.jackson.core.type.TypeReference; +import java.net.CookieManager; +import java.net.CookiePolicy; +import java.net.HttpCookie; +import java.net.URI; +import java.net.http.HttpClient; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import net.modtale.launcher.model.auth.SignInResponse; +import net.modtale.launcher.model.notification.LauncherNotification; +import net.modtale.launcher.model.project.DownloadUrlResponse; +import net.modtale.launcher.model.project.GameVersionCatalog; +import net.modtale.launcher.model.project.ProjectComment; +import net.modtale.launcher.model.project.ProjectDetail; +import net.modtale.launcher.model.project.ProjectGallery; +import net.modtale.launcher.model.project.ProjectMeta; +import net.modtale.launcher.model.project.ProjectPage; +import net.modtale.launcher.model.project.ProjectVersion; +import net.modtale.launcher.model.project.ProjectVersionChangelog; +import net.modtale.launcher.model.project.VersionDependenciesView; +import net.modtale.launcher.model.sync.LauncherSettingsSnapshot; +import net.modtale.launcher.model.user.CreatorProfile; +import net.modtale.launcher.model.user.CurrentUser; +import net.modtale.launcher.model.user.UserSummary; +import net.modtale.launcher.model.worldlist.CreateWorldModListRequest; +import net.modtale.launcher.model.worldlist.WorldModList; + +public class ModtaleApiClient { + + public static final String DEFAULT_API_BASE_URL = "https://api.modtale.net/api/v1"; + public static final String DEFAULT_SITE_BASE_URL = "https://modtale.net"; + + private final CookieManager cookieManager; + private final ModtaleApiTransport transport; + private final ModtaleDownloadClient downloadClient; + private final LauncherSessionStore sessionStore; + private volatile URI apiBaseUri; + private volatile boolean storedSessionLoaded; + + public ModtaleApiClient(String apiBaseUrl) { + this(apiBaseUrl, null); + } + + public ModtaleApiClient(String apiBaseUrl, Path sessionPath) { + this(defaultCookieManager(), apiBaseUrl, new ApiResponseCache(), + sessionPath == null ? null : new LauncherSessionStore(sessionPath)); + } + + private ModtaleApiClient( + CookieManager cookieManager, + String apiBaseUrl, + ApiResponseCache responseCache, + LauncherSessionStore sessionStore + ) { + this(HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(15)) + .cookieHandler(cookieManager) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(), cookieManager, apiBaseUrl, responseCache, sessionStore); + } + + ModtaleApiClient(HttpClient httpClient, String apiBaseUrl) { + this(httpClient, apiBaseUrl, new ApiResponseCache()); + } + + ModtaleApiClient(HttpClient httpClient, String apiBaseUrl, ApiResponseCache responseCache) { + this(httpClient, null, apiBaseUrl, responseCache, null); + } + + private ModtaleApiClient( + HttpClient httpClient, + CookieManager cookieManager, + String apiBaseUrl, + ApiResponseCache responseCache, + LauncherSessionStore sessionStore + ) { + this.cookieManager = cookieManager; + this.transport = new ModtaleApiTransport(httpClient, responseCache, this::csrfToken); + this.downloadClient = new ModtaleDownloadClient(httpClient, this::apiBaseUri); + this.sessionStore = sessionStore; + configure(apiBaseUrl); + if (this.sessionStore != null && this.cookieManager != null) { + storedSessionLoaded = this.sessionStore.loadInto(this.cookieManager.getCookieStore(), apiBaseUri); + } + } + + public final void configure(String apiBaseUrl) { + this.apiBaseUri = ApiPathBuilder.normalizeBaseUri(apiBaseUrl, DEFAULT_API_BASE_URL); + } + + public URI apiBaseUri() { + return apiBaseUri; + } + + public SignInResponse signIn(String username, char[] password) { + SignInResponse response = post("/auth/signin", java.util.Map.of( + "username", username == null ? "" : username.trim(), + "password", password == null ? "" : new String(password) + ), SignInResponse.class); + if (response != null && !response.mfaRequired()) { + saveStoredSession(); + } + return response; + } + + public void validateMfa(String preAuthToken, String code) { + post("/auth/mfa/validate-login", java.util.Map.of( + "pre_auth_token", preAuthToken == null ? "" : preAuthToken, + "code", code == null ? "" : code.trim() + ), Object.class); + saveStoredSession(); + } + + public void exchangeLauncherCode(String code) { + post("/auth/launcher/exchange", java.util.Map.of( + "code", code == null ? "" : code.trim() + ), Object.class); + saveStoredSession(); + } + + public CurrentUser currentUser() { + CurrentUser user = get("/user/me", CurrentUser.class); + saveStoredSession(); + return user; + } + + public void logout() { + try { + post("/auth/logout", java.util.Map.of(), Object.class); + } finally { + clearStoredSession(); + } + } + + public boolean hasStoredSession() { + return storedSessionLoaded || (sessionStore != null && sessionStore.hasSessionFile()); + } + + public void clearStoredSession() { + storedSessionLoaded = false; + if (sessionStore != null && cookieManager != null) { + sessionStore.clear(cookieManager.getCookieStore()); + } + } + + public void clearResponseCache() { + transport.clearResponseCache(); + } + + public ProjectPage searchProjects(ProjectSearchQuery query) { + List params = new ArrayList<>(); + addParam(params, "page", Integer.toString(query.page())); + addParam(params, "size", Integer.toString(query.size())); + addParam(params, "sort", query.sort()); + addParam(params, "search", query.search()); + addParam(params, "classification", query.classification()); + addParam(params, "gameVersion", query.gameVersion()); + addParam(params, "tags", query.tags()); + if (query.minDownloads() != null) { + addParam(params, "minDownloads", Integer.toString(query.minDownloads())); + } + if (query.minFavorites() != null) { + addParam(params, "minFavorites", Integer.toString(query.minFavorites())); + } + addParam(params, "category", query.category()); + addParam(params, "dateRange", query.dateRange()); + if (Boolean.TRUE.equals(query.openSource())) { + addParam(params, "openSource", "true"); + } + return get("/projects" + (params.isEmpty() ? "" : "?" + String.join("&", params)), ProjectPage.class); + } + + public ProjectDetail getProject(String idOrSlug) { + return get("/projects/" + encodePath(idOrSlug), ProjectDetail.class); + } + + public ProjectGallery getProjectGallery(String idOrSlug) { + ProjectGallery gallery = get("/projects/" + encodePath(idOrSlug) + "/gallery", ProjectGallery.class); + return gallery == null ? new ProjectGallery(List.of(), java.util.Map.of()) : gallery; + } + + public ProjectMeta getProjectMeta(String idOrSlug) { + return get("/projects/" + encodePath(idOrSlug) + "/meta", ProjectMeta.class); + } + + public Map getProjectMetaBatch(List projectIds) { + List ids = projectIds == null + ? List.of() + : projectIds.stream() + .filter(id -> id != null && !id.isBlank()) + .map(String::trim) + .distinct() + .limit(50) + .toList(); + if (ids.isEmpty()) { + return Map.of(); + } + List params = new ArrayList<>(); + addParam(params, "ids", String.join(",", ids)); + return get("/projects/meta?" + String.join("&", params), new TypeReference<>() {}); + } + + public CreatorProfile getUserProfile(String idOrHandle) { + return get("/user/profile/" + encodePath(idOrHandle), CreatorProfile.class); + } + + public ProjectPage getCreatorProjects(String userId, int page, int size) { + List params = new ArrayList<>(); + addParam(params, "page", Integer.toString(Math.max(0, page))); + addParam(params, "size", Integer.toString(Math.max(1, size))); + addParam(params, "sort", "relevance"); + return get("/creators/" + encodePath(userId) + "/projects?" + String.join("&", params), ProjectPage.class); + } + + public List getOrganizationMembers(String organizationId) { + return get("/orgs/" + encodePath(organizationId) + "/members", new TypeReference<>() {}); + } + + public List getUserOrganizations(String userId) { + return get("/users/" + encodePath(userId) + "/organizations", new TypeReference<>() {}); + } + + public List getUsersBatch(List userIds) { + List ids = userIds == null + ? List.of() + : userIds.stream() + .filter(id -> id != null && !id.isBlank()) + .distinct() + .toList(); + if (ids.isEmpty()) { + return List.of(); + } + return post("/users/batch", java.util.Map.of("userIds", ids), new TypeReference<>() {}); + } + + public List getComments(String projectId) { + CommentsResponse response = get("/projects/" + encodePath(projectId) + "/comments", CommentsResponse.class); + return response == null ? List.of() : response.comments(); + } + + public void postComment(String projectId, String content) { + post("/projects/" + encodePath(projectId) + "/comments", + java.util.Map.of("content", content == null ? "" : content), Object.class); + } + + public void updateComment(String projectId, String commentId, String content) { + put("/projects/" + encodePath(projectId) + "/comments/" + encodePath(commentId), + java.util.Map.of("content", content == null ? "" : content), Object.class); + } + + public void deleteComment(String projectId, String commentId) { + delete("/projects/" + encodePath(projectId) + "/comments/" + encodePath(commentId), Object.class); + } + + public void replyToComment(String projectId, String commentId, String reply) { + post("/projects/" + encodePath(projectId) + "/comments/" + encodePath(commentId) + "/reply", + java.util.Map.of("reply", reply == null ? "" : reply), Object.class); + } + + public void voteComment(String projectId, String commentId, boolean upvote, boolean reply) { + String target = reply + ? "/projects/" + encodePath(projectId) + "/comments/" + encodePath(commentId) + "/reply/vote" + : "/projects/" + encodePath(projectId) + "/comments/" + encodePath(commentId) + "/vote"; + post(target + "?upvote=" + upvote, java.util.Map.of(), Object.class); + } + + public String submitReport(String targetId, String targetType, String reason, String description) { + ReportResponse response = post("/reports", java.util.Map.of( + "targetId", targetId == null ? "" : targetId, + "targetType", targetType == null ? "" : targetType, + "reason", reason == null ? "" : reason, + "description", description == null ? "" : description + ), ReportResponse.class); + return response == null ? "" : response.id(); + } + + public List getProjectVersionChangelogs(String idOrSlug) { + return get("/projects/" + encodePath(idOrSlug) + "/versions/changelogs", new TypeReference<>() {}); + } + + public List getProjectVersions(String idOrSlug) { + ProjectVersionsResponse response = get("/projects/" + encodePath(idOrSlug) + "/versions", ProjectVersionsResponse.class); + return response == null ? List.of() : response.versions(); + } + + public void toggleFavorite(String projectId) { + post("/projects/" + encodePath(projectId) + "/favorite", java.util.Map.of(), Object.class); + } + + public List getNotifications() { + return get("/notifications", new TypeReference<>() {}); + } + + public void markNotificationRead(String notificationId, boolean read) { + post("/notifications/" + encodePath(notificationId) + "/" + (read ? "read" : "unread"), + java.util.Map.of(), Object.class); + } + + public void markAllNotificationsRead() { + post("/notifications/read-all", java.util.Map.of(), Object.class); + } + + public void deleteNotification(String notificationId) { + delete("/notifications/" + encodePath(notificationId), Object.class); + } + + public void clearNotifications() { + delete("/notifications/clear-all", Object.class); + } + + public void updateNotificationPreferences(CurrentUser.NotificationPreferences preferences) { + put("/user/settings/notifications", + preferences == null ? CurrentUser.NotificationPreferences.defaults() : preferences, + Object.class); + } + + public LauncherSettingsSnapshot getLauncherSettings() { + return get("/user/launcher-settings", LauncherSettingsSnapshot.class); + } + + public LauncherSettingsSnapshot updateLauncherSettings(LauncherSettingsSnapshot snapshot) { + return put("/user/launcher-settings", + snapshot == null ? new LauncherSettingsSnapshot() : snapshot, + LauncherSettingsSnapshot.class); + } + + public LauncherSettingsSnapshot updateLauncherSettingsPreferences(LauncherSettingsSnapshot snapshot) { + LauncherSettingsSnapshot payload = snapshot == null + ? new LauncherSettingsSnapshot() + : snapshot.preferencesOnly(); + return put("/user/launcher-settings/preferences", payload, LauncherSettingsSnapshot.class); + } + + public List getFollowing(String userId) { + return get("/users/" + encodePath(userId) + "/following", new TypeReference<>() {}); + } + + public void followUser(String userId) { + post("/user/follow/" + encodePath(userId), java.util.Map.of(), Object.class); + } + + public void unfollowUser(String userId) { + post("/user/unfollow/" + encodePath(userId), java.util.Map.of(), Object.class); + } + + public void resolveNotificationAction(LauncherNotification notification, boolean accept) { + if (notification == null) { + throw new ModtaleApiException("Notification is missing."); + } + LauncherNotification.ActionType actionType = notification.actionType() + .orElseThrow(() -> new ModtaleApiException("This notification does not have an accept or decline action.")); + java.util.Map metadata = notification.metadata(); + switch (actionType) { + case TRANSFER_REQUEST -> { + String projectId = metadata.get("modId"); + if (projectId == null || projectId.isBlank()) { + throw new ModtaleApiException("Transfer request is missing its project id."); + } + post("/projects/" + encodePath(projectId) + "/transfer/resolve", + java.util.Map.of("accept", accept), Object.class); + } + case ORG_INVITE -> { + String orgId = metadata.get("orgId"); + if (orgId == null || orgId.isBlank()) { + throw new ModtaleApiException("Organization invite is missing its organization id."); + } + post("/orgs/" + encodePath(orgId) + "/invite/" + (accept ? "accept" : "decline"), + java.util.Map.of(), Object.class); + } + case CONTRIBUTOR_INVITE -> { + String projectId = metadata.get("modId"); + if (projectId == null || projectId.isBlank()) { + throw new ModtaleApiException("Contributor invite is missing its project id."); + } + post("/projects/" + encodePath(projectId) + "/invite/" + (accept ? "accept" : "decline"), + java.util.Map.of(), Object.class); + } + } + } + + public List getGameVersions() { + return get("/meta/game-versions", new TypeReference<>() {}); + } + + public GameVersionCatalog getGameVersionCatalog() { + return get("/meta/game-versions/catalog", GameVersionCatalog.class); + } + + public VersionDependenciesView getDependencies(String projectId, String versionNumber, String gameVersion) { + String query = gameVersion == null || gameVersion.isBlank() + ? "" + : "?gameVersion=" + encodeQuery(gameVersion); + return get("/projects/" + encodePath(projectId) + "/versions/" + encodePath(versionNumber) + "/dependencies" + query, + VersionDependenciesView.class); + } + + public WorldModList createWorldModList(CreateWorldModListRequest request) { + return post("/lists", request, WorldModList.class); + } + + public WorldModList getWorldModListForInstall(String listId) { + return get("/lists/" + encodePath(listId) + "/install", WorldModList.class); + } + + public DownloadUrlResponse getDownloadUrl(String projectId, String versionNumber, String gameVersion) { + String query = gameVersion == null || gameVersion.isBlank() + ? "" + : "?gameVersion=" + encodeQuery(gameVersion); + return get("/projects/" + encodePath(projectId) + "/versions/" + encodePath(versionNumber) + "/download-url" + query, + DownloadUrlResponse.class); + } + + public DownloadUrlResponse getBundleDownloadUrl( + String projectId, + String versionNumber, + List dependencyProjectIds, + String gameVersion + ) { + List params = new ArrayList<>(); + if (dependencyProjectIds != null && !dependencyProjectIds.isEmpty()) { + addParam(params, "deps", String.join(",", dependencyProjectIds)); + } + addParam(params, "gameVersion", gameVersion); + String query = params.isEmpty() ? "" : "?" + String.join("&", params); + return get("/projects/" + encodePath(projectId) + "/versions/" + encodePath(versionNumber) + "/download-bundle-url" + query, + DownloadUrlResponse.class); + } + + public DownloadedFile download(String rawUrl) { + return downloadClient.download(rawUrl); + } + + URI resolveDownloadUri(String rawUrl) { + return downloadClient.resolve(rawUrl); + } + + private T get(String pathAndQuery, Class type) { + return transport.get(apiUri(pathAndQuery), type, ApiCachePolicy.ttlFor(pathAndQuery)); + } + + private T get(String pathAndQuery, TypeReference type) { + return transport.get(apiUri(pathAndQuery), type, ApiCachePolicy.ttlFor(pathAndQuery)); + } + + private T post(String pathAndQuery, Object payload, Class type) { + return transport.post(apiUri(pathAndQuery), payload, type); + } + + private T post(String pathAndQuery, Object payload, TypeReference type) { + return transport.post(apiUri(pathAndQuery), payload, type); + } + + private T put(String pathAndQuery, Object payload, Class type) { + return transport.put(apiUri(pathAndQuery), payload, type); + } + + private T delete(String pathAndQuery, Class type) { + return transport.delete(apiUri(pathAndQuery), type); + } + + private void saveStoredSession() { + if (sessionStore != null && cookieManager != null) { + sessionStore.saveFrom(cookieManager.getCookieStore(), apiBaseUri); + storedSessionLoaded = sessionStore.hasSessionFile(); + } + } + + private Optional csrfToken() { + if (cookieManager == null || apiBaseUri == null) { + return Optional.empty(); + } + return cookieManager.getCookieStore().get(apiBaseUri).stream() + .filter(cookie -> "XSRF-TOKEN".equalsIgnoreCase(cookie.getName())) + .filter(cookie -> !cookie.hasExpired()) + .map(HttpCookie::getValue) + .filter(value -> value != null && !value.isBlank()) + .findFirst(); + } + + private static CookieManager defaultCookieManager() { + return new CookieManager(null, CookiePolicy.ACCEPT_ALL); + } + + private URI apiUri(String pathAndQuery) { + return ApiPathBuilder.apiUri(apiBaseUri, pathAndQuery); + } + + private static void addParam(List params, String name, String value) { + ApiPathBuilder.addParam(params, name, value); + } + + private static String encodePath(String value) { + return ApiPathBuilder.encodePath(value); + } + + private static String encodeQuery(String value) { + return ApiPathBuilder.encodeQuery(value); + } + + public record DownloadedFile(Path path, String filename, String contentType) { + } + + private record ProjectVersionsResponse(List versions) { + private ProjectVersionsResponse { + versions = versions == null ? List.of() : List.copyOf(versions); + } + } + + private record CommentsResponse(List comments) { + private CommentsResponse { + comments = comments == null ? List.of() : List.copyOf(comments); + } + } + + private record ReportResponse(String id) { + } + +} diff --git a/launcher/src/main/java/net/modtale/launcher/api/ModtaleApiException.java b/launcher/src/main/java/net/modtale/launcher/api/ModtaleApiException.java new file mode 100644 index 00000000..7a381b2c --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/api/ModtaleApiException.java @@ -0,0 +1,24 @@ +package net.modtale.launcher.api; + +public class ModtaleApiException extends RuntimeException { + + private final int statusCode; + + public ModtaleApiException(String message) { + this(message, -1, null); + } + + public ModtaleApiException(String message, Throwable cause) { + this(message, -1, cause); + } + + public ModtaleApiException(String message, int statusCode, Throwable cause) { + super(message, cause); + this.statusCode = statusCode; + } + + public int statusCode() { + return statusCode; + } +} + diff --git a/launcher/src/main/java/net/modtale/launcher/api/ModtaleApiTransport.java b/launcher/src/main/java/net/modtale/launcher/api/ModtaleApiTransport.java new file mode 100644 index 00000000..8c76589c --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/api/ModtaleApiTransport.java @@ -0,0 +1,299 @@ +package net.modtale.launcher.api; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.function.Supplier; +import net.modtale.launcher.logging.LogSanitizer; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +final class ModtaleApiTransport { + + private static final Logger LOG = LogManager.getLogger(ModtaleApiTransport.class); + private static final String CSRF_HEADER_NAME = "X-XSRF-TOKEN"; + + private final HttpClient httpClient; + private final ObjectMapper mapper; + private final ApiResponseCache responseCache; + private final Supplier> csrfTokenSupplier; + + ModtaleApiTransport(HttpClient httpClient, ApiResponseCache responseCache) { + this(httpClient, responseCache, Optional::empty); + } + + ModtaleApiTransport( + HttpClient httpClient, + ApiResponseCache responseCache, + Supplier> csrfTokenSupplier + ) { + this.httpClient = httpClient; + this.responseCache = responseCache; + this.csrfTokenSupplier = csrfTokenSupplier == null ? Optional::empty : csrfTokenSupplier; + this.mapper = new ObjectMapper() + .registerModule(new JavaTimeModule()) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + } + + T get(URI uri, Class type, Duration cacheTtl) { + return sendJson(requestBuilder(uri).GET().build(), type, cacheTtl); + } + + T get(URI uri, TypeReference type, Duration cacheTtl) { + return sendJson(requestBuilder(uri).GET().build(), type, cacheTtl); + } + + T post(URI uri, Object payload, Class type) { + try { + HttpRequest request = writeRequestBuilder(uri) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(payload), StandardCharsets.UTF_8)) + .build(); + return sendJson(request, type, Duration.ZERO); + } catch (IOException ex) { + LOG.warn("Could not write POST body for " + LogSanitizer.uri(uri), ex); + throw new ModtaleApiException("Could not write API request body for " + LogSanitizer.uri(uri), ex); + } + } + + T post(URI uri, Object payload, TypeReference type) { + try { + HttpRequest request = writeRequestBuilder(uri) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(payload), StandardCharsets.UTF_8)) + .build(); + return sendJson(request, type, Duration.ZERO); + } catch (IOException ex) { + LOG.warn("Could not write POST body for " + LogSanitizer.uri(uri), ex); + throw new ModtaleApiException("Could not write API request body for " + LogSanitizer.uri(uri), ex); + } + } + + T put(URI uri, Object payload, Class type) { + try { + HttpRequest request = writeRequestBuilder(uri) + .header("Content-Type", "application/json") + .PUT(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(payload), StandardCharsets.UTF_8)) + .build(); + return sendJson(request, type); + } catch (IOException ex) { + LOG.warn("Could not write PUT body for " + LogSanitizer.uri(uri), ex); + throw new ModtaleApiException("Could not write API request body for " + LogSanitizer.uri(uri), ex); + } + } + + T delete(URI uri, Class type) { + return sendJson(writeRequestBuilder(uri).DELETE().build(), type); + } + + void clearResponseCache() { + responseCache.clear(); + } + + private T sendJson(HttpRequest request, Class type) { + return sendJson(request, type, Duration.ZERO); + } + + private T sendJson(HttpRequest request, Class type, Duration cacheTtl) { + if (ApiCachePolicy.isEnabled(cacheTtl)) { + Optional cached = responseCache.getFresh(request.uri(), cacheTtl); + if (cached.isPresent()) { + try { + return mapper.readValue(cached.get(), type); + } catch (IOException ex) { + responseCache.invalidate(request.uri()); + } + } + } + + try { + LOG.info(request.method() + " " + LogSanitizer.uri(request.uri())); + Instant started = Instant.now(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + logResponse(request, response.statusCode(), response.body(), started); + ensureSuccess(response.statusCode(), request.uri().toString(), response.body()); + if (ApiCachePolicy.isEnabled(cacheTtl)) { + responseCache.put(request.uri(), response.body()); + } + return readResponseBody(response.body(), type); + } catch (IOException ex) { + LOG.warn("I/O failure reading " + request.method() + " " + LogSanitizer.uri(request.uri()), ex); + Optional stale = readStaleFallback(request, type, cacheTtl); + if (stale.isPresent()) { + LOG.warn("Using stale cached response for " + LogSanitizer.uri(request.uri())); + return stale.get(); + } + throw new ModtaleApiException("Could not read API response from " + LogSanitizer.uri(request.uri()), ex); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + LOG.warn("Interrupted while reading " + request.method() + " " + LogSanitizer.uri(request.uri()), ex); + Optional stale = readStaleFallback(request, type, cacheTtl); + if (stale.isPresent()) { + LOG.warn("Using stale cached response for " + LogSanitizer.uri(request.uri())); + return stale.get(); + } + throw new ModtaleApiException("API request was interrupted.", ex); + } + } + + private T sendJson(HttpRequest request, TypeReference type, Duration cacheTtl) { + if (ApiCachePolicy.isEnabled(cacheTtl)) { + Optional cached = responseCache.getFresh(request.uri(), cacheTtl); + if (cached.isPresent()) { + try { + return mapper.readValue(cached.get(), type); + } catch (IOException ex) { + responseCache.invalidate(request.uri()); + } + } + } + + try { + LOG.info(request.method() + " " + LogSanitizer.uri(request.uri())); + Instant started = Instant.now(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + logResponse(request, response.statusCode(), response.body(), started); + ensureSuccess(response.statusCode(), request.uri().toString(), response.body()); + if (ApiCachePolicy.isEnabled(cacheTtl)) { + responseCache.put(request.uri(), response.body()); + } + return mapper.readValue(response.body(), type); + } catch (IOException ex) { + LOG.warn("I/O failure reading " + request.method() + " " + LogSanitizer.uri(request.uri()), ex); + Optional stale = readStaleFallback(request, type, cacheTtl); + if (stale.isPresent()) { + LOG.warn("Using stale cached response for " + LogSanitizer.uri(request.uri())); + return stale.get(); + } + throw new ModtaleApiException("Could not read API response from " + LogSanitizer.uri(request.uri()), ex); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + LOG.warn("Interrupted while reading " + request.method() + " " + LogSanitizer.uri(request.uri()), ex); + Optional stale = readStaleFallback(request, type, cacheTtl); + if (stale.isPresent()) { + LOG.warn("Using stale cached response for " + LogSanitizer.uri(request.uri())); + return stale.get(); + } + throw new ModtaleApiException("API request was interrupted.", ex); + } + } + + private T readResponseBody(String body, Class type) throws IOException { + if (body == null || body.isBlank()) { + if (type == Void.class || type == Object.class) { + return null; + } + } + return mapper.readValue(body, type); + } + + private Optional readStaleFallback(HttpRequest request, Class type, Duration cacheTtl) { + if (!ApiCachePolicy.isEnabled(cacheTtl)) { + return Optional.empty(); + } + Optional cached = responseCache.getStaleFallback(request.uri()); + if (cached.isEmpty()) { + return Optional.empty(); + } + try { + return Optional.of(mapper.readValue(cached.get(), type)); + } catch (IOException ex) { + responseCache.invalidate(request.uri()); + return Optional.empty(); + } + } + + private Optional readStaleFallback(HttpRequest request, TypeReference type, Duration cacheTtl) { + if (!ApiCachePolicy.isEnabled(cacheTtl)) { + return Optional.empty(); + } + Optional cached = responseCache.getStaleFallback(request.uri()); + if (cached.isEmpty()) { + return Optional.empty(); + } + try { + return Optional.of(mapper.readValue(cached.get(), type)); + } catch (IOException ex) { + responseCache.invalidate(request.uri()); + return Optional.empty(); + } + } + + private void logResponse(HttpRequest request, int status, String body, Instant started) { + long elapsedMs = Duration.between(started, Instant.now()).toMillis(); + String target = LogSanitizer.uri(request.uri()); + if (status >= 200 && status < 300) { + LOG.info(request.method() + " " + target + " -> HTTP " + status + " in " + elapsedMs + "ms"); + return; + } + + LOG.warn(request.method() + " " + target + " -> HTTP " + status + " in " + elapsedMs + + "ms body=" + LogSanitizer.bodyPreview(body)); + } + + static HttpRequest.Builder requestBuilder(URI uri) { + return HttpRequest.newBuilder(uri) + .timeout(Duration.ofSeconds(60)) + .header("Accept", "application/json") + .header("User-Agent", "ModtaleLauncher/0.1"); + } + + private HttpRequest.Builder writeRequestBuilder(URI uri) { + HttpRequest.Builder builder = requestBuilder(uri); + csrfTokenSupplier.get() + .filter(token -> !token.isBlank()) + .ifPresent(token -> builder.header(CSRF_HEADER_NAME, token)); + return builder; + } + + static void ensureSuccess(int status, String target) { + if (status < 200 || status >= 300) { + String safeTarget = LogSanitizer.url(target); + LOG.warn("HTTP " + status + " for " + safeTarget); + throw new ModtaleApiException("Modtale API returned HTTP " + status + " for " + safeTarget, status, null); + } + } + + private void ensureSuccess(int status, String target, String body) { + if (status >= 200 && status < 300) { + return; + } + String serverMessage = serverErrorMessage(body); + if (serverMessage == null || serverMessage.isBlank()) { + String safeTarget = LogSanitizer.url(target); + LOG.warn("HTTP " + status + " for " + safeTarget); + throw new ModtaleApiException("Modtale API returned HTTP " + status + " for " + safeTarget, status, null); + } + throw new ModtaleApiException(serverMessage, status, null); + } + + private String serverErrorMessage(String body) { + if (body == null || body.isBlank()) { + return null; + } + try { + JsonNode root = mapper.readTree(body); + for (String field : List.of("message", "error", "detail", "title")) { + JsonNode value = root.get(field); + if (value != null && value.isTextual() && !value.asText().isBlank()) { + return value.asText(); + } + } + } catch (IOException ignored) { + return null; + } + return null; + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/api/ModtaleDownloadClient.java b/launcher/src/main/java/net/modtale/launcher/api/ModtaleDownloadClient.java new file mode 100644 index 00000000..49d32100 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/api/ModtaleDownloadClient.java @@ -0,0 +1,122 @@ +package net.modtale.launcher.api; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import java.util.function.Supplier; +import net.modtale.launcher.logging.LogSanitizer; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +final class ModtaleDownloadClient { + + private static final Logger LOG = LogManager.getLogger(ModtaleDownloadClient.class); + + private final HttpClient httpClient; + private final Supplier apiBaseUri; + + ModtaleDownloadClient(HttpClient httpClient, Supplier apiBaseUri) { + this.httpClient = httpClient; + this.apiBaseUri = apiBaseUri; + } + + ModtaleApiClient.DownloadedFile download(String rawUrl) { + URI uri = resolve(rawUrl); + HttpRequest request = ModtaleApiTransport.requestBuilder(uri) + .GET() + .header("Accept", "application/octet-stream, application/zip, */*") + .build(); + try { + LOG.info("GET " + LogSanitizer.uri(uri)); + Instant started = Instant.now(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); + long elapsedMs = Duration.between(started, Instant.now()).toMillis(); + LOG.info("GET " + LogSanitizer.uri(uri) + " -> HTTP " + + response.statusCode() + " in " + elapsedMs + "ms"); + ModtaleApiTransport.ensureSuccess(response.statusCode(), uri.toString()); + String filename = filenameFromDisposition(response.headers().firstValue("Content-Disposition")) + .or(() -> filenameFromUri(uri)) + .orElse("download.bin"); + Path tempFile = Files.createTempFile("modtale-", "-" + SafeDownloadName.sanitize(filename)); + try (InputStream body = response.body()) { + Files.copy(body, tempFile, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } + LOG.info("Saved " + LogSanitizer.uri(uri) + + " as " + filename + + " contentType=" + response.headers().firstValue("Content-Type").orElse("") + + " temp=" + tempFile + + " bytes=" + Files.size(tempFile)); + return new ModtaleApiClient.DownloadedFile( + tempFile, + filename, + response.headers().firstValue("Content-Type").orElse("") + ); + } catch (IOException ex) { + LOG.warn("Could not download " + LogSanitizer.uri(uri), ex); + throw new ModtaleApiException("Could not download " + LogSanitizer.uri(uri), ex); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + LOG.warn("Interrupted downloading " + LogSanitizer.uri(uri), ex); + throw new ModtaleApiException("Download was interrupted.", ex); + } + } + + URI resolve(String rawUrl) { + if (rawUrl == null || rawUrl.isBlank()) { + throw new ModtaleApiException("The API returned an empty download URL."); + } + if (rawUrl.startsWith("http://") || rawUrl.startsWith("https://")) { + return URI.create(rawUrl); + } + + String base = apiBaseUri.get().toString().replaceAll("/+$", ""); + String path = rawUrl.startsWith("/") ? rawUrl : "/" + rawUrl; + return URI.create(base + path); + } + + private static Optional filenameFromDisposition(Optional header) { + return header.flatMap(value -> { + for (String part : value.split(";")) { + String trimmed = part.trim(); + if (trimmed.toLowerCase().startsWith("filename=")) { + String filename = trimmed.substring("filename=".length()).trim(); + if (filename.startsWith("\"") && filename.endsWith("\"") && filename.length() >= 2) { + filename = filename.substring(1, filename.length() - 1); + } + if (!filename.isBlank()) { + return Optional.of(filename); + } + } + } + return Optional.empty(); + }); + } + + private static Optional filenameFromUri(URI uri) { + String path = uri.getPath(); + if (path == null || path.isBlank()) { + return Optional.empty(); + } + int slash = path.lastIndexOf('/'); + String filename = slash >= 0 ? path.substring(slash + 1) : path; + return filename.isBlank() ? Optional.empty() : Optional.of(filename); + } + + private static final class SafeDownloadName { + private SafeDownloadName() { + } + + private static String sanitize(String value) { + String sanitized = value == null ? "download.bin" : value.replaceAll("[^A-Za-z0-9._-]+", "-"); + return sanitized.isBlank() ? "download.bin" : sanitized; + } + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/api/ProjectSearchQuery.java b/launcher/src/main/java/net/modtale/launcher/api/ProjectSearchQuery.java new file mode 100644 index 00000000..c796b0d9 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/api/ProjectSearchQuery.java @@ -0,0 +1,28 @@ +package net.modtale.launcher.api; + +public record ProjectSearchQuery( + String search, + String classification, + String gameVersion, + String sort, + int page, + int size, + String tags, + Integer minDownloads, + Integer minFavorites, + String category, + String dateRange, + Boolean openSource +) { + private static final String DEFAULT_SORT = "relevance"; + + public ProjectSearchQuery { + sort = sort == null || sort.isBlank() ? DEFAULT_SORT : sort; + page = Math.max(0, page); + size = Math.max(1, Math.min(100, size)); + minDownloads = minDownloads == null || minDownloads <= 0 ? null : minDownloads; + minFavorites = minFavorites == null || minFavorites <= 0 ? null : minFavorites; + dateRange = dateRange == null || dateRange.isBlank() ? null : dateRange; + openSource = Boolean.TRUE.equals(openSource) ? Boolean.TRUE : null; + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/cache/LauncherCachePaths.java b/launcher/src/main/java/net/modtale/launcher/cache/LauncherCachePaths.java new file mode 100644 index 00000000..43978cda --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/cache/LauncherCachePaths.java @@ -0,0 +1,17 @@ +package net.modtale.launcher.cache; + +import java.nio.file.Path; + +public final class LauncherCachePaths { + + private LauncherCachePaths() { + } + + public static Path rootDirectory() { + return Path.of(System.getProperty("user.home", "."), ".modtale", "launcher", "cache"); + } + + public static Path cacheDirectory(String name) { + return rootDirectory().resolve(name); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/cache/LauncherCacheService.java b/launcher/src/main/java/net/modtale/launcher/cache/LauncherCacheService.java new file mode 100644 index 00000000..d709ade0 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/cache/LauncherCacheService.java @@ -0,0 +1,44 @@ +package net.modtale.launcher.cache; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Stream; + +public final class LauncherCacheService { + + private final Path cacheRoot; + + public LauncherCacheService() { + this(LauncherCachePaths.rootDirectory()); + } + + LauncherCacheService(Path cacheRoot) { + this.cacheRoot = cacheRoot; + } + + public ClearResult clear() throws IOException { + if (!Files.exists(cacheRoot)) { + return new ClearResult(0); + } + + try (Stream stream = Files.walk(cacheRoot)) { + List paths = stream + .filter(path -> !cacheRoot.equals(path)) + .sorted(Comparator.reverseOrder()) + .toList(); + int deletedEntries = 0; + for (Path path : paths) { + if (Files.deleteIfExists(path)) { + deletedEntries++; + } + } + return new ClearResult(deletedEntries); + } + } + + public record ClearResult(int deletedEntries) { + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/discord/DiscordRichPresenceService.java b/launcher/src/main/java/net/modtale/launcher/discord/DiscordRichPresenceService.java new file mode 100644 index 00000000..9e92c47a --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/discord/DiscordRichPresenceService.java @@ -0,0 +1,329 @@ +package net.modtale.launcher.discord; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.net.StandardProtocolFamily; +import java.net.UnixDomainSocketAddress; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.channels.SocketChannel; +import java.nio.file.Path; +import java.time.Instant; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicBoolean; +import net.modtale.launcher.settings.LauncherConfig; + +public final class DiscordRichPresenceService { + + private static final int DISCORD_IPC_PIPE_COUNT = 10; + private static final int OPCODE_HANDSHAKE = 0; + private static final int OPCODE_FRAME = 1; + private static final String OPEN_MODTALE_URL = "https://modtale.net"; + private static final String MODTALE_FAVICON_URL = "https://modtale.net/assets/favicon.svg"; + + private final ObjectMapper mapper = new ObjectMapper(); + private final AtomicBoolean closed = new AtomicBoolean(); + private final Object connectionLock = new Object(); + private final String clientId; + private final ExecutorService executor; + private final long launcherStartedAt; + + private DiscordIpcConnection connection; + + public DiscordRichPresenceService(String clientId) { + this.clientId = LauncherConfig.normalizeDiscordClientId(clientId).orElse(null); + this.launcherStartedAt = Instant.now().getEpochSecond(); + this.executor = Executors.newSingleThreadExecutor(daemonThreadFactory()); + } + + public static DiscordRichPresenceService fromConfig() { + return new DiscordRichPresenceService(LauncherConfig.discordClientId().orElse(null)); + } + + public boolean isEnabled() { + return clientId != null; + } + + public void start() { + showLauncher(); + } + + public void showLauncher() { + if (!isEnabled()) { + return; + } + submit(() -> setActivity(launcherActivity(mapper, launcherStartedAt))); + } + + public void showPlayingHytale(String buildLabel, long startedAtMillis) { + if (!isEnabled()) { + return; + } + long startedAt = Math.max(0, startedAtMillis / 1000); + submit(() -> setActivity(hytaleActivity(mapper, buildLabel, startedAt))); + } + + public void shutdown() { + closed.set(true); + executor.shutdownNow(); + synchronized (connectionLock) { + closeConnection(); + } + } + + private void submit(Runnable task) { + if (closed.get()) { + return; + } + try { + executor.execute(() -> { + if (!closed.get()) { + task.run(); + } + }); + } catch (RejectedExecutionException ignored) { + // The launcher is shutting down; Discord presence is best effort. + } + } + + private void setActivity(ObjectNode activity) { + ObjectNode command = setActivityCommand(mapper, ProcessHandle.current().pid(), activity, UUID.randomUUID().toString()); + sendCommand(command); + } + + private void sendCommand(ObjectNode command) { + byte[] payload; + try { + payload = mapper.writeValueAsBytes(command); + } catch (IOException ignored) { + return; + } + + synchronized (connectionLock) { + for (int attempt = 0; attempt < 2 && !closed.get(); attempt++) { + try { + DiscordIpcConnection activeConnection = ensureConnection(); + activeConnection.write(frame(OPCODE_FRAME, payload)); + return; + } catch (IOException | UnsupportedOperationException ignored) { + closeConnection(); + } + } + } + } + + private DiscordIpcConnection ensureConnection() throws IOException { + if (connection != null) { + return connection; + } + + DiscordIpcConnection opened = DiscordIpcConnection.open(); + ObjectNode handshake = mapper.createObjectNode(); + handshake.put("v", 1); + handshake.put("client_id", clientId); + opened.write(frame(OPCODE_HANDSHAKE, mapper.writeValueAsBytes(handshake))); + connection = opened; + return opened; + } + + private void closeConnection() { + if (connection == null) { + return; + } + try { + connection.close(); + } catch (IOException ignored) { + // Discord may have closed the IPC pipe first. + } finally { + connection = null; + } + } + + static ObjectNode launcherActivity(ObjectMapper mapper, long startedAt) { + ObjectNode activity = baseActivity(mapper, startedAt); + activity.put("details", "Browsing Modtale"); + activity.put("state", "Managing Hytale mods"); + return activity; + } + + static ObjectNode hytaleActivity(ObjectMapper mapper, String buildLabel, long startedAt) { + ObjectNode activity = baseActivity(mapper, startedAt); + activity.put("details", "Playing Hytale"); + String label = buildLabel == null || buildLabel.isBlank() || "Unset".equals(buildLabel) + ? "Launched from Modtale" + : buildLabel.trim(); + activity.put("state", label); + return activity; + } + + static ObjectNode setActivityCommand(ObjectMapper mapper, long pid, ObjectNode activity, String nonce) { + ObjectNode command = mapper.createObjectNode(); + command.put("cmd", "SET_ACTIVITY"); + ObjectNode args = command.putObject("args"); + args.put("pid", pid); + if (activity == null) { + args.putNull("activity"); + } else { + args.set("activity", activity); + } + command.put("nonce", nonce); + return command; + } + + static byte[] frame(int opcode, byte[] payload) { + ByteBuffer buffer = ByteBuffer.allocate(8 + payload.length).order(ByteOrder.LITTLE_ENDIAN); + buffer.putInt(opcode); + buffer.putInt(payload.length); + buffer.put(payload); + return buffer.array(); + } + + private static ObjectNode baseActivity(ObjectMapper mapper, long startedAt) { + ObjectNode activity = mapper.createObjectNode(); + ObjectNode timestamps = activity.putObject("timestamps"); + timestamps.put("start", startedAt); + ObjectNode assets = activity.putObject("assets"); + assets.put("large_image", MODTALE_FAVICON_URL); + assets.put("large_text", "Modtale"); + activity.putArray("buttons") + .addObject() + .put("label", "Open Modtale") + .put("url", OPEN_MODTALE_URL); + return activity; + } + + private static ThreadFactory daemonThreadFactory() { + return runnable -> { + Thread thread = new Thread(runnable, "modtale-discord-rpc"); + thread.setDaemon(true); + return thread; + }; + } + + private interface DiscordIpcConnection extends AutoCloseable { + + void write(byte[] bytes) throws IOException; + + @Override + void close() throws IOException; + + static DiscordIpcConnection open() throws IOException { + IOException lastError = null; + for (String path : ipcPaths()) { + try { + return isWindows() ? WindowsPipeConnection.open(path) : UnixSocketConnection.open(path); + } catch (IOException ex) { + lastError = ex; + } + } + throw lastError == null ? new IOException("Discord IPC pipe was not found.") : lastError; + } + + private static List ipcPaths() { + Set paths = new LinkedHashSet<>(); + for (int index = 0; index < DISCORD_IPC_PIPE_COUNT; index++) { + if (isWindows()) { + paths.add("\\\\.\\pipe\\discord-ipc-" + index); + } else { + for (Path root : unixIpcRoots()) { + paths.add(root.resolve("discord-ipc-" + index).toString()); + } + } + } + return List.copyOf(paths); + } + + private static List unixIpcRoots() { + Set roots = new LinkedHashSet<>(); + addRoot(roots, System.getenv("XDG_RUNTIME_DIR")); + addRoot(roots, System.getProperty("java.io.tmpdir")); + addRoot(roots, System.getenv("TMPDIR")); + addRoot(roots, System.getenv("TEMP")); + addRoot(roots, System.getenv("TMP")); + String xdgRuntime = System.getenv("XDG_RUNTIME_DIR"); + if (xdgRuntime != null && !xdgRuntime.isBlank()) { + Path runtimeRoot = Path.of(xdgRuntime); + roots.add(runtimeRoot.resolve(Path.of("app", "com.discordapp.Discord"))); + roots.add(runtimeRoot.resolve("snap.discord")); + } + roots.add(Path.of("/tmp")); + return List.copyOf(roots); + } + + private static void addRoot(Set roots, String rawPath) { + if (rawPath != null && !rawPath.isBlank()) { + roots.add(Path.of(rawPath)); + } + } + + private static boolean isWindows() { + return System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win"); + } + } + + private static final class UnixSocketConnection implements DiscordIpcConnection { + + private final SocketChannel channel; + + private UnixSocketConnection(SocketChannel channel) { + this.channel = channel; + } + + static UnixSocketConnection open(String path) throws IOException { + SocketChannel channel = SocketChannel.open(StandardProtocolFamily.UNIX); + try { + channel.connect(UnixDomainSocketAddress.of(Path.of(path))); + return new UnixSocketConnection(channel); + } catch (IOException | UnsupportedOperationException ex) { + channel.close(); + throw ex; + } + } + + @Override + public void write(byte[] bytes) throws IOException { + ByteBuffer buffer = ByteBuffer.wrap(bytes); + while (buffer.hasRemaining()) { + channel.write(buffer); + } + } + + @Override + public void close() throws IOException { + channel.close(); + } + } + + private static final class WindowsPipeConnection implements DiscordIpcConnection { + + private final RandomAccessFile pipe; + + private WindowsPipeConnection(RandomAccessFile pipe) { + this.pipe = pipe; + } + + static WindowsPipeConnection open(String path) throws IOException { + return new WindowsPipeConnection(new RandomAccessFile(path, "rw")); + } + + @Override + public void write(byte[] bytes) throws IOException { + pipe.write(bytes); + } + + @Override + public void close() throws IOException { + pipe.close(); + } + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/hytale/HytaleApiClient.java b/launcher/src/main/java/net/modtale/launcher/hytale/HytaleApiClient.java new file mode 100644 index 00000000..cb2bb9ff --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/hytale/HytaleApiClient.java @@ -0,0 +1,1076 @@ +package net.modtale.launcher.hytale; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; +import org.xml.sax.SAXException; + +public class HytaleApiClient { + + static final String AUTH_URL = "https://oauth.accounts.hytale.com/oauth2/auth"; + static final String TOKEN_URL = "https://oauth.accounts.hytale.com/oauth2/token"; + static final String LAUNCHER_DATA_URL = "https://account-data.hytale.com/my-account/get-launcher-data"; + static final String PUBLIC_PROFILE_BY_UUID_URL = "https://account-data.hytale.com/profile/uuid"; + static final String SOCIAL_FRIENDS_URL = "https://social.hytale.com/friends"; + static final String SESSION_URL = "https://sessions.hytale.com/game-session/new"; + static final String PATCHES_BASE_URL = "https://account-data.hytale.com/patches"; + static final String LAUNCHER_INFO_URL = "https://launcher.hytale.com/version/release/launcher.json"; + static final String BLOG_URL = "https://hytale.com/news"; + static final String BLOG_RSS_URL = "https://hytale.com/rss.xml"; + static final String CLIENT_ID = "hytale-launcher"; + static final String REDIRECT_URI = "https://accounts.hytale.com/consent/client"; + static final String SCOPES = "openid offline auth:launcher"; + + private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(60); + private static final Duration BLOG_CACHE_TTL = Duration.ofMinutes(15); + private static final String FALLBACK_LAUNCHER_VERSION = "unknown"; + private static final String RELEASE_PATCHLINE = "release"; + private static final String PRE_RELEASE_PATCHLINE = "pre-release"; + private static final Pattern VERSIONED_PATCHLINE_PATTERN = Pattern.compile("v?\\d+\\.\\d+"); + private static final Pattern BLOG_ARTICLE_PATTERN = Pattern.compile("", Pattern.CASE_INSENSITIVE); + private static final Pattern BLOG_LINK_PATTERN = Pattern.compile("]*href=\"([^\"]+)\"", Pattern.CASE_INSENSITIVE); + private static final Pattern BLOG_IMAGE_PATTERN = Pattern.compile("]*src=\"([^\"]+)\"", Pattern.CASE_INSENSITIVE); + private static final Pattern BLOG_TITLE_PATTERN = Pattern.compile("]*>([\\s\\S]*?)", Pattern.CASE_INSENSITIVE); + private static final Pattern BLOG_DATE_PATTERN = Pattern.compile("\\b((?:January|February|March|April|May|June|July|August|September|October|November|December)\\s+\\d{1,2},\\s+\\d{4})\\b"); + private static final DateTimeFormatter BLOG_PAGE_DATE = new DateTimeFormatterBuilder() + .parseCaseInsensitive() + .appendPattern("MMMM d, yyyy") + .toFormatter(Locale.ENGLISH); + + private final HttpClient httpClient; + private final ObjectMapper mapper; + private volatile String launcherVersion; + private volatile long launcherVersionFetchedAt; + private volatile List blogPostCache = List.of(); + private volatile long blogPostFetchedAt; + + public HytaleApiClient() { + this(HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(15)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build()); + } + + HytaleApiClient(HttpClient httpClient) { + this.httpClient = httpClient; + this.mapper = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + } + + public TokenResponse exchangeCode(String code, String codeVerifier) { + return postForm(TOKEN_URL, Map.of( + "grant_type", "authorization_code", + "code", code == null ? "" : code, + "redirect_uri", REDIRECT_URI, + "client_id", CLIENT_ID, + "code_verifier", codeVerifier == null ? "" : codeVerifier + ), TokenResponse.class); + } + + public TokenResponse refreshToken(String refreshToken) { + return postForm(TOKEN_URL, Map.of( + "grant_type", "refresh_token", + "refresh_token", refreshToken == null ? "" : refreshToken, + "client_id", CLIENT_ID + ), TokenResponse.class); + } + + public HytaleProfile fetchProfile(String accessToken) { + return fetchProfiles(accessToken).getFirst(); + } + + public List fetchProfiles(String accessToken) { + HttpRequest request = officialRequestBuilder(launcherDataUrl(), "release") + .GET() + .header("Authorization", "Bearer " + accessToken) + .build(); + ProfilesResponse response = sendJson(request, ProfilesResponse.class); + if (response.profiles == null || response.profiles.isEmpty()) { + throw new HytaleApiException("No Hytale game profile was returned for this account."); + } + + String owner = response.owner == null ? "" : response.owner; + return response.profiles.stream() + .map(profile -> new HytaleProfile(profile.username, profile.uuid, owner, profile.playtimeSeconds)) + .toList(); + } + + public List fetchFriends(String sessionToken) { + HttpRequest request = officialRequestBuilder(URI.create(SOCIAL_FRIENDS_URL), "release") + .GET() + .header("Authorization", "Bearer " + sessionToken) + .build(); + return parseFriends(sendJson(request, JsonNode.class)); + } + + public Map fetchPublicProfileUsernames(String sessionToken, List uuids) { + if (sessionToken == null || sessionToken.isBlank() || uuids == null || uuids.isEmpty()) { + return Map.of(); + } + Map usernames = new LinkedHashMap<>(); + Set seen = new HashSet<>(); + for (String uuid : uuids) { + String normalizedUuid = uuid == null ? "" : uuid.trim(); + String key = normalizedUuid.toLowerCase(Locale.ROOT); + if (normalizedUuid.isBlank() || !seen.add(key)) { + continue; + } + try { + HttpRequest request = officialRequestBuilder( + URI.create(PUBLIC_PROFILE_BY_UUID_URL + "/" + encodePathSegment(normalizedUuid)), + "release") + .GET() + .header("Authorization", "Bearer " + sessionToken) + .build(); + parsePublicProfileUsername(sendJson(request, JsonNode.class)) + .ifPresent(username -> usernames.put(key, username)); + } catch (HytaleApiException ex) { + if (ex.isAuthFailure()) { + throw ex; + } + } + } + return usernames; + } + + public HytaleGameSession createGameSession(String accessToken, String uuid) { + try { + HttpRequest request = officialRequestBuilder(URI.create(SESSION_URL), "release") + .header("Authorization", "Bearer " + accessToken) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString( + mapper.writeValueAsString(Map.of("uuid", uuid == null ? "" : uuid)), + StandardCharsets.UTF_8)) + .build(); + GameSessionResponse response = sendJson(request, GameSessionResponse.class); + return new HytaleGameSession( + response.sessionToken == null ? "" : response.sessionToken, + response.identityToken == null ? "" : response.identityToken + ); + } catch (IOException ex) { + throw new HytaleApiException("Could not write Hytale game-session request.", ex); + } + } + + public List getAvailableVersions(String accessToken, String branch) { + String normalizedBranch = normalizeBranch(branch); + String os = HytalePlatform.os(); + String arch = HytalePlatform.arch(); + Map versionsByBuild = new LinkedHashMap<>(); + + OfficialPatchesResponse latest = fetchPatches(accessToken, os, arch, normalizedBranch, 0); + if (latest.steps != null) { + latest.steps.stream() + .max(Comparator.comparingInt(step -> step.to)) + .ifPresent(step -> versionsByBuild.put(step.to, toVersion(normalizedBranch, step, true))); + } + + try { + OfficialPatchesResponse chain = fetchPatches(accessToken, os, arch, normalizedBranch, 1); + if (chain.steps != null) { + chain.steps.stream() + .sorted(Comparator.comparingInt((OfficialPatchStep step) -> step.to).reversed()) + .forEach(step -> versionsByBuild.putIfAbsent(step.to, toVersion(normalizedBranch, step, false))); + } + } catch (HytaleApiException ex) { + if (ex.isAuthFailure()) { + throw ex; + } + } + + return versionsByBuild.values().stream() + .sorted(Comparator.comparingInt(HytaleVersion::build).reversed()) + .toList(); + } + + public List getAvailablePatchlines(String accessToken, List candidatePatchlines) { + String os = HytalePlatform.os(); + String arch = HytalePlatform.arch(); + LinkedHashSet patchlines = new LinkedHashSet<>(); + addAvailablePatchline(accessToken, os, arch, RELEASE_PATCHLINE, patchlines, true); + + LinkedHashSet candidates = new LinkedHashSet<>(); + candidates.add(PRE_RELEASE_PATCHLINE); + launcherDataPatchlines(accessToken).forEach(candidates::add); + if (candidatePatchlines != null) { + candidatePatchlines.stream() + .map(HytaleApiClient::normalizePatchlineId) + .flatMap(Optional::stream) + .forEach(candidates::add); + } + + for (String candidate : candidates) { + if (!RELEASE_PATCHLINE.equals(candidate)) { + addAvailablePatchline(accessToken, os, arch, candidate, patchlines, false); + } + } + return List.copyOf(patchlines); + } + + public List getBlogPosts(int count) { + int safeCount = Math.max(1, count); + return getAllBlogPosts().stream().limit(safeCount).toList(); + } + + public List getAllBlogPosts() { + long now = System.currentTimeMillis(); + List cached = blogPostCache; + if (!cached.isEmpty() && now - blogPostFetchedAt < BLOG_CACHE_TTL.toMillis()) { + return cached; + } + + HytaleApiException rssError = null; + List posts; + try { + posts = getBlogPostsFromRss(Integer.MAX_VALUE); + } catch (HytaleApiException ex) { + rssError = ex; + posts = List.of(); + } + if (!posts.isEmpty()) { + posts = enrichRssPostsWithNewsImages(posts); + } + if (posts.isEmpty()) { + try { + posts = getBlogPostsFromHtml(Integer.MAX_VALUE); + } catch (HytaleApiException ex) { + if (rssError != null) { + throw rssError; + } + throw ex; + } + } + blogPostCache = posts; + blogPostFetchedAt = now; + return posts; + } + + private List getBlogPostsFromHtml(int count) { + HttpRequest request = HttpRequest.newBuilder(URI.create(BLOG_URL)) + .timeout(REQUEST_TIMEOUT) + .header("Accept", "text/html, application/xhtml+xml") + .GET() + .build(); + return parseBlogPosts(sendString(request), count); + } + + private List getBlogPostsFromRss(int count) { + HttpRequest request = HttpRequest.newBuilder(URI.create(BLOG_RSS_URL)) + .timeout(REQUEST_TIMEOUT) + .header("Accept", "application/rss+xml, application/xml, text/xml") + .GET() + .build(); + return parseBlogPosts(sendString(request), count); + } + + private List enrichRssPostsWithNewsImages(List rssPosts) { + List htmlPosts; + try { + htmlPosts = getBlogPostsFromHtml(Integer.MAX_VALUE); + } catch (HytaleApiException ex) { + return rssPosts; + } + if (htmlPosts.isEmpty()) { + return rssPosts; + } + + Map imageByUrl = new LinkedHashMap<>(); + Map imageByTitle = new LinkedHashMap<>(); + for (HytaleBlogPost post : htmlPosts) { + if (post.imageUrl().isBlank()) { + continue; + } + imageByUrl.put(normalizedBlogUrl(post.url()), post.imageUrl()); + imageByTitle.put(normalizedBlogTitle(post.title()), post.imageUrl()); + } + + return rssPosts.stream() + .map(post -> { + if (!post.imageUrl().isBlank()) { + return post; + } + String imageUrl = imageByUrl.getOrDefault( + normalizedBlogUrl(post.url()), + imageByTitle.getOrDefault(normalizedBlogTitle(post.title()), "") + ); + return imageUrl.isBlank() + ? post + : new HytaleBlogPost(post.title(), post.url(), imageUrl, post.publishedAt()); + }) + .toList(); + } + + private OfficialPatchesResponse fetchPatches(String accessToken, String os, String arch, String branch, int fromBuild) { + URI uri = URI.create(PATCHES_BASE_URL + "/" + os + "/" + arch + "/" + normalizeBranch(branch) + "/" + fromBuild); + HttpRequest request = officialRequestBuilder(uri, branch) + .GET() + .header("Authorization", "Bearer " + accessToken) + .build(); + return sendJson(request, OfficialPatchesResponse.class); + } + + private void addAvailablePatchline( + String accessToken, + String os, + String arch, + String patchline, + LinkedHashSet patchlines, + boolean required + ) { + try { + OfficialPatchesResponse response = fetchPatches(accessToken, os, arch, patchline, 0); + if (response.steps != null) { + patchlines.add(patchline); + } + } catch (HytaleApiException ex) { + if (required || ex.statusCode() == 429) { + throw ex; + } + } + } + + private List launcherDataPatchlines(String accessToken) { + try { + HttpRequest request = officialRequestBuilder(launcherDataUrl(), RELEASE_PATCHLINE) + .GET() + .header("Authorization", "Bearer " + accessToken) + .build(); + return parsePatchlines(sendJson(request, JsonNode.class)); + } catch (HytaleApiException ex) { + if (ex.isAuthFailure() || ex.statusCode() == 429) { + throw ex; + } + return List.of(); + } + } + + private HytaleVersion toVersion(String branch, OfficialPatchStep step, boolean latest) { + return new HytaleVersion( + branch, + step.to, + step.from, + latest, + emptyIfNull(step.pwr), + emptyIfNull(step.pwrHead), + emptyIfNull(step.sig) + ); + } + + private URI launcherDataUrl() { + return URI.create(LAUNCHER_DATA_URL + "?client_id=" + CLIENT_ID); + } + + private HttpRequest.Builder officialRequestBuilder(URI uri, String branch) { + String version = launcherVersion(); + return HttpRequest.newBuilder(uri) + .timeout(REQUEST_TIMEOUT) + .header("Accept", "application/json") + .header("User-Agent", "hytale-launcher/" + version) + .header("x-hytale-launcher-version", version) + .header("x-hytale-launcher-branch", normalizeHeaderBranch(branch)); + } + + private String launcherVersion() { + long now = System.currentTimeMillis(); + if (launcherVersion != null && now - launcherVersionFetchedAt < Duration.ofHours(6).toMillis()) { + return launcherVersion; + } + + try { + HttpRequest request = HttpRequest.newBuilder(URI.create(LAUNCHER_INFO_URL)) + .timeout(Duration.ofSeconds(10)) + .header("Accept", "application/json") + .GET() + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() >= 200 && response.statusCode() < 300) { + launcherVersion = extractLauncherVersion(response.body()).orElse(FALLBACK_LAUNCHER_VERSION); + } else if (launcherVersion == null) { + launcherVersion = FALLBACK_LAUNCHER_VERSION; + } + } catch (IOException ex) { + if (launcherVersion == null) { + launcherVersion = FALLBACK_LAUNCHER_VERSION; + } + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + if (launcherVersion == null) { + launcherVersion = FALLBACK_LAUNCHER_VERSION; + } + } + + launcherVersionFetchedAt = now; + return launcherVersion; + } + + private Optional extractLauncherVersion(String json) throws IOException { + JsonNode root = mapper.readTree(json); + for (String key : List.of("version", "launcher_version", "build", "id")) { + JsonNode node = root.get(key); + if (node != null && !node.asText("").isBlank()) { + return Optional.of(node.asText()); + } + } + return Optional.empty(); + } + + private T postForm(String url, Map values, Class type) { + HttpRequest request = HttpRequest.newBuilder(URI.create(url)) + .timeout(REQUEST_TIMEOUT) + .header("Accept", "application/json") + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(formEncode(values), StandardCharsets.UTF_8)) + .build(); + return sendJson(request, type); + } + + private T sendJson(HttpRequest request, Class type) { + String body = sendString(request); + try { + return mapper.readValue(body, type); + } catch (IOException ex) { + throw new HytaleApiException("Could not read Hytale API response from " + request.uri(), ex); + } + } + + private String sendString(HttpRequest request) { + try { + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new HytaleApiException("Hytale API returned HTTP " + response.statusCode() + + " for " + request.uri() + responseSnippet(response.body()), + response.statusCode(), + null, + retryAfterMillis(response.headers(), Instant.now())); + } + return response.body(); + } catch (IOException ex) { + throw new HytaleApiException("Could not read Hytale API response from " + request.uri(), ex); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new HytaleApiException("Hytale API request was interrupted.", ex); + } + } + + private static String formEncode(Map values) { + List encoded = new ArrayList<>(); + values.forEach((key, value) -> encoded.add(encode(key) + "=" + encode(value))); + return String.join("&", encoded); + } + + private static String encode(String value) { + return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8); + } + + private static String encodePathSegment(String value) { + return encode(value).replace("+", "%20"); + } + + public static String normalizeBranch(String branch) { + return normalizePatchlineId(branch).orElse(RELEASE_PATCHLINE); + } + + public static Optional normalizePatchlineId(String branch) { + if (branch == null || branch.isBlank()) { + return Optional.empty(); + } + String normalized = branch.trim().toLowerCase(Locale.ROOT); + return switch (normalized) { + case "release", "latest", "latest-release" -> Optional.of(RELEASE_PATCHLINE); + case "pre-release", "pre release", "prerelease", "pre_release" -> Optional.of(PRE_RELEASE_PATCHLINE); + default -> VERSIONED_PATCHLINE_PATTERN.matcher(normalized).matches() + ? Optional.of(normalized.startsWith("v") ? normalized : "v" + normalized) + : Optional.empty(); + }; + } + + private static String normalizeHeaderBranch(String branch) { + return switch (normalizeBranch(branch)) { + case PRE_RELEASE_PATCHLINE -> RELEASE_PATCHLINE; + default -> RELEASE_PATCHLINE; + }; + } + + private static String responseSnippet(String body) { + if (body == null || body.isBlank()) { + return ""; + } + String compact = body.replaceAll("\\s+", " ").trim(); + return ": " + compact.substring(0, Math.min(compact.length(), 240)); + } + + private static long retryAfterMillis(HttpHeaders headers, Instant now) { + if (headers == null) { + return 0; + } + Optional retryAfter = headers.firstValue("Retry-After"); + if (retryAfter.isPresent()) { + long millis = parseRetryAfterMillis(retryAfter.get(), now); + if (millis > 0) { + return millis; + } + } + for (String header : List.of("X-RateLimit-Reset", "RateLimit-Reset")) { + Optional reset = headers.firstValue(header); + if (reset.isPresent()) { + long millis = parseRateLimitResetMillis(reset.get(), now); + if (millis > 0) { + return millis; + } + } + } + return 0; + } + + private static long parseRetryAfterMillis(String value, Instant now) { + if (value == null || value.isBlank()) { + return 0; + } + String trimmed = value.trim(); + try { + return Math.max(0, Long.parseLong(trimmed) * 1000L); + } catch (NumberFormatException ignored) { + // Retry-After may be an HTTP-date. + } + try { + Instant resetAt = ZonedDateTime.parse(trimmed, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant(); + return Math.max(0, resetAt.toEpochMilli() - now.toEpochMilli()); + } catch (DateTimeParseException ignored) { + return 0; + } + } + + private static long parseRateLimitResetMillis(String value, Instant now) { + if (value == null || value.isBlank()) { + return 0; + } + try { + long parsed = Long.parseLong(value.trim()); + long epochSeconds = now.getEpochSecond(); + if (parsed > epochSeconds) { + return Math.max(0, (parsed - epochSeconds) * 1000L); + } + return Math.max(0, parsed * 1000L); + } catch (NumberFormatException ex) { + return parseRetryAfterMillis(value, now); + } + } + + private static String emptyIfNull(String value) { + return value == null ? "" : value; + } + + static List parseBlogPosts(String content, int count) { + if (content == null || content.isBlank() || count <= 0) { + return List.of(); + } + String trimmed = content.trim(); + if (trimmed.startsWith(" parseBlogHtml(String html, int count) { + List posts = new ArrayList<>(); + Set seen = new HashSet<>(); + Matcher articleMatcher = BLOG_ARTICLE_PATTERN.matcher(html); + while (articleMatcher.find() && posts.size() < count) { + String article = articleMatcher.group(); + String link = absoluteHytaleUrl(firstMatch(BLOG_LINK_PATTERN, article)); + String title = cleanRssText(firstMatch(BLOG_TITLE_PATTERN, article)); + if (title.isBlank() || link.isBlank() || !seen.add(link)) { + continue; + } + posts.add(new HytaleBlogPost( + title, + link, + absoluteHytaleUrl(firstMatch(BLOG_IMAGE_PATTERN, article)), + parseBlogPageDate(firstMatch(BLOG_DATE_PATTERN, article)) + )); + } + return posts; + } + + private static List parseBlogRss(String xml, int count) { + if (xml == null || xml.isBlank() || count <= 0) { + return List.of(); + } + try { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + configureSecureXml(factory); + Document document = factory.newDocumentBuilder().parse( + new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)) + ); + NodeList items = document.getElementsByTagName("item"); + List posts = new ArrayList<>(); + Set seen = new HashSet<>(); + for (int index = 0; index < items.getLength() && posts.size() < count; index++) { + if (!(items.item(index) instanceof Element item)) { + continue; + } + String title = cleanRssText(childText(item, "title")); + String link = cleanRssText(childText(item, "link")); + if (title.isBlank() || link.isBlank() || !seen.add(link)) { + continue; + } + posts.add(new HytaleBlogPost( + title, + link, + rssImageUrl(item), + parseRssDate(childText(item, "pubDate")) + )); + } + return posts; + } catch (IOException | ParserConfigurationException | SAXException ex) { + throw new HytaleApiException("Could not parse Hytale blog feed.", ex); + } + } + + private static String rssImageUrl(Element item) { + String imageUrl = childAttribute(item, "enclosure", "url"); + if (imageUrl.isBlank()) { + imageUrl = childAttribute(item, "media:content", "url"); + } + if (imageUrl.isBlank()) { + imageUrl = childAttribute(item, "media:thumbnail", "url"); + } + if (imageUrl.isBlank()) { + imageUrl = firstMatch(BLOG_IMAGE_PATTERN, childText(item, "description")); + } + return absoluteHytaleUrl(imageUrl); + } + + private static String firstMatch(Pattern pattern, String value) { + if (value == null || value.isBlank()) { + return ""; + } + Matcher matcher = pattern.matcher(value); + return matcher.find() ? matcher.group(1) : ""; + } + + private static String absoluteHytaleUrl(String value) { + if (value == null || value.isBlank()) { + return ""; + } + String trimmed = value.trim(); + if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { + return trimmed; + } + if (trimmed.startsWith("//")) { + return "https:" + trimmed; + } + if (trimmed.startsWith("/")) { + return "https://hytale.com" + trimmed; + } + return "https://hytale.com/" + trimmed; + } + + private static String normalizedBlogUrl(String value) { + if (value == null || value.isBlank()) { + return ""; + } + return value.trim().replaceAll("/+$", "").toLowerCase(Locale.ROOT); + } + + private static String normalizedBlogTitle(String value) { + if (value == null || value.isBlank()) { + return ""; + } + return cleanRssText(value).toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]+", " ").trim(); + } + + private static Instant parseBlogPageDate(String value) { + if (value == null || value.isBlank()) { + return Instant.EPOCH; + } + try { + LocalDate date = LocalDate.parse(value.trim(), BLOG_PAGE_DATE); + return date.atTime(LocalTime.NOON).toInstant(ZoneOffset.UTC); + } catch (DateTimeParseException ex) { + return Instant.EPOCH; + } + } + + static List parseFriends(JsonNode root) { + if (root == null || root.isMissingNode() || root.isNull()) { + return List.of(); + } + List friends = new ArrayList<>(); + Set seen = new HashSet<>(); + if (root.isArray()) { + addFriends(root, friends, seen); + } + List friendArrays = new ArrayList<>(); + collectFriendArrays(root, "", friendArrays); + for (JsonNode array : friendArrays) { + addFriends(array, friends, seen); + } + return friends; + } + + static Optional parsePublicProfileUsername(JsonNode root) { + if (root == null || root.isMissingNode() || root.isNull()) { + return Optional.empty(); + } + if (root.isArray()) { + for (JsonNode candidate : root) { + Optional username = parsePublicProfileUsername(candidate); + if (username.isPresent()) { + return username; + } + } + return Optional.empty(); + } + if (!root.isObject()) { + return Optional.empty(); + } + String username = firstText(root, "username", "displayName", "display_name", "name", "playerName", "player_name"); + if (!username.isBlank()) { + return Optional.of(username); + } + JsonNode profile = firstObject(root, "profile", "player", "user"); + username = firstText(profile, "username", "displayName", "display_name", "name", "playerName", "player_name"); + return username.isBlank() ? Optional.empty() : Optional.of(username); + } + + static List parsePatchlines(JsonNode root) { + if (root == null || root.isMissingNode() || root.isNull()) { + return List.of(); + } + LinkedHashSet patchlines = new LinkedHashSet<>(); + collectPatchlines(root, "", patchlines); + return List.copyOf(patchlines); + } + + private static void collectPatchlines(JsonNode node, String fieldName, LinkedHashSet patchlines) { + if (node == null || node.isNull()) { + return; + } + String field = fieldName == null ? "" : fieldName.toLowerCase(Locale.ROOT); + boolean patchlineContext = field.contains("patchline") || field.contains("channel"); + if (node.isTextual()) { + if (patchlineContext) { + normalizePatchlineId(node.asText()).ifPresent(patchlines::add); + } + return; + } + if (node.isArray()) { + node.forEach(child -> collectPatchlines(child, fieldName, patchlines)); + return; + } + if (!node.isObject()) { + return; + } + if (patchlineContext) { + for (String identityField : List.of("id", "name", "patchline", "channel", "selected_channel")) { + JsonNode identity = node.get(identityField); + if (identity != null && identity.isTextual()) { + normalizePatchlineId(identity.asText()).ifPresent(patchlines::add); + } + } + } + node.fields().forEachRemaining(entry -> { + if (patchlineContext) { + normalizePatchlineId(entry.getKey()).ifPresent(patchlines::add); + } + collectPatchlines(entry.getValue(), entry.getKey(), patchlines); + }); + } + + private static void addFriends(JsonNode array, List friends, Set seen) { + for (JsonNode candidate : array) { + Optional friend = toFriend(candidate); + if (friend.isEmpty()) { + continue; + } + HytaleFriend value = friend.get(); + String key = value.uuid().isBlank() + ? value.displayName().toLowerCase(Locale.ROOT) + : value.uuid().toLowerCase(Locale.ROOT); + if (seen.add(key)) { + friends.add(value); + } + } + } + + private static void collectFriendArrays(JsonNode node, String fieldName, List friendArrays) { + if (node == null || node.isNull()) { + return; + } + String normalized = fieldName == null ? "" : fieldName.toLowerCase(); + if (node.isArray()) { + if (normalized.contains("friend")) { + friendArrays.add(node); + return; + } + node.forEach(child -> collectFriendArrays(child, "", friendArrays)); + return; + } + if (!node.isObject()) { + return; + } + node.fields().forEachRemaining(entry -> collectFriendArrays(entry.getValue(), entry.getKey(), friendArrays)); + } + + private static Optional toFriend(JsonNode node) { + if (node == null || !node.isObject()) { + return Optional.empty(); + } + JsonNode identity = firstObject(node, "friend", "profile", "player", "user", "account", "member"); + String username = firstIdentityText(node, identity, + "username", "displayName", "display_name", "name", "playerName", "player_name"); + String uuid = firstIdentityText(node, identity, + "uuid", "profileUuid", "profile_uuid", "playerUuid", "player_uuid", "id", "playerId", "player_id"); + if (username.isBlank() && uuid.isBlank()) { + return Optional.empty(); + } + String status = firstText(node, "status", "presence", "activity", "state"); + JsonNode presence = firstObject(node, "presence", "activity", "state"); + String normalizedStatus = status.toLowerCase(Locale.ROOT).replace('_', ' ').replace('-', ' '); + boolean online = firstBoolean(node, "online", "isOnline", "is_online") + || firstBoolean(presence, "online", "isOnline", "is_online") + || normalizedStatus.contains("online") + || normalizedStatus.contains("playing") + || normalizedStatus.contains("in game"); + return Optional.of(new HytaleFriend( + username, + uuid, + readablePresence(status, online), + firstIdentityText(node, identity, "avatarUrl", "avatar_url", "avatar", "imageUrl", "image_url"), + online + )); + } + + private static String firstIdentityText(JsonNode node, JsonNode identity, String... fields) { + String direct = firstText(node, fields); + if (!direct.isBlank()) { + return direct; + } + String nested = firstText(identity, fields); + if (!nested.isBlank()) { + return nested; + } + JsonNode nestedIdentity = firstObject(identity, "friend", "profile", "player", "user", "account", "member"); + return firstText(nestedIdentity, fields); + } + + private static JsonNode firstObject(JsonNode node, String... fields) { + if (node == null || !node.isObject()) { + return null; + } + for (String field : fields) { + JsonNode child = node.get(field); + if (child != null && child.isObject()) { + return child; + } + } + return null; + } + + private static String firstText(JsonNode node, String... fields) { + if (node == null) { + return ""; + } + for (String field : fields) { + JsonNode child = node.get(field); + String value = textValue(child); + if (!value.isBlank()) { + return value; + } + } + return ""; + } + + private static String textValue(JsonNode node) { + if (node == null || node.isNull()) { + return ""; + } + if (node.isObject()) { + return firstText(node, "status", "state", "activity", "text", "value", "username", + "displayName", "display_name", "name"); + } + if (node.isArray()) { + return ""; + } + return node.asText("").trim(); + } + + private static boolean firstBoolean(JsonNode node, String... fields) { + if (node == null) { + return false; + } + for (String field : fields) { + JsonNode child = node.get(field); + if (child != null && child.isBoolean() && child.asBoolean()) { + return true; + } + } + return false; + } + + private static String readablePresence(String rawStatus, boolean online) { + if (rawStatus == null || rawStatus.isBlank()) { + return online ? "Online" : "Offline"; + } + String compact = rawStatus.trim().replace('_', ' ').replace('-', ' '); + if (compact.equalsIgnoreCase("online")) { + return "Online"; + } + if (compact.equalsIgnoreCase("offline")) { + return "Offline"; + } + if (compact.equalsIgnoreCase("playing")) { + return "Playing"; + } + if (compact.equalsIgnoreCase("in game")) { + return "In game"; + } + return compact; + } + + private static void configureSecureXml(DocumentBuilderFactory factory) throws ParserConfigurationException { + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + setXmlFeature(factory, "http://apache.org/xml/features/disallow-doctype-decl", true); + setXmlFeature(factory, "http://xml.org/sax/features/external-general-entities", false); + setXmlFeature(factory, "http://xml.org/sax/features/external-parameter-entities", false); + factory.setExpandEntityReferences(false); + } + + private static void setXmlFeature(DocumentBuilderFactory factory, String feature, boolean value) throws ParserConfigurationException { + try { + factory.setFeature(feature, value); + } catch (ParserConfigurationException ex) { + throw ex; + } catch (Exception ignored) { + // Some XML implementations do not support every hardening flag. + } + } + + private static String childText(Element element, String tagName) { + NodeList nodes = element.getElementsByTagName(tagName); + if (nodes.getLength() == 0 || nodes.item(0) == null) { + return ""; + } + return nodes.item(0).getTextContent(); + } + + private static String childAttribute(Element element, String tagName, String attributeName) { + NodeList nodes = element.getElementsByTagName(tagName); + if (nodes.getLength() == 0 || !(nodes.item(0) instanceof Element child)) { + return ""; + } + return child.getAttribute(attributeName); + } + + private static String cleanRssText(String value) { + if (value == null || value.isBlank()) { + return ""; + } + return value + .replaceAll("<[^>]+>", " ") + .replace(""", "\"") + .replace(""", "\"") + .replace("'", "'") + .replace("'", "'") + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replaceAll("\\s+", " ") + .trim(); + } + + private static Instant parseRssDate(String value) { + if (value == null || value.isBlank()) { + return Instant.EPOCH; + } + try { + return ZonedDateTime.parse(value.trim(), DateTimeFormatter.RFC_1123_DATE_TIME).toInstant(); + } catch (DateTimeParseException ex) { + return Instant.EPOCH; + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class TokenResponse { + @JsonProperty("access_token") + public String accessToken = ""; + + @JsonProperty("refresh_token") + public String refreshToken = ""; + + @JsonProperty("expires_in") + public long expiresIn = 0; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private static final class ProfilesResponse { + public String owner = ""; + public List profiles = List.of(); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private static final class ProfileEntry { + public String uuid = ""; + public String username = ""; + public long playtimeSeconds; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private static final class GameSessionResponse { + public String sessionToken = ""; + public String identityToken = ""; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private static final class OfficialPatchesResponse { + public List steps = List.of(); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private static final class OfficialPatchStep { + public int from; + public int to; + public String pwr = ""; + public String pwrHead = ""; + public String sig = ""; + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/hytale/HytaleApiException.java b/launcher/src/main/java/net/modtale/launcher/hytale/HytaleApiException.java new file mode 100644 index 00000000..ff6e1dbe --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/hytale/HytaleApiException.java @@ -0,0 +1,43 @@ +package net.modtale.launcher.hytale; + +public class HytaleApiException extends RuntimeException { + + private final int statusCode; + private final long retryAfterMillis; + + public HytaleApiException(String message) { + this(message, -1, null); + } + + public HytaleApiException(String message, Throwable cause) { + this(message, -1, cause); + } + + public HytaleApiException(String message, int statusCode, Throwable cause) { + this(message, statusCode, cause, 0); + } + + public HytaleApiException(String message, int statusCode, Throwable cause, long retryAfterMillis) { + super(message, cause); + this.statusCode = statusCode; + this.retryAfterMillis = Math.max(0, retryAfterMillis); + } + + public int statusCode() { + return statusCode; + } + + public long retryAfterMillis() { + return retryAfterMillis; + } + + public boolean isAuthFailure() { + return statusCode == 401 || statusCode == 403; + } + + public boolean requiresSignIn() { + String message = getMessage(); + return isAuthFailure() + || (statusCode == 400 && message != null && message.contains("invalid_grant")); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/hytale/HytaleAuthService.java b/launcher/src/main/java/net/modtale/launcher/hytale/HytaleAuthService.java new file mode 100644 index 00000000..e9bb3504 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/hytale/HytaleAuthService.java @@ -0,0 +1,599 @@ +package net.modtale.launcher.hytale; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import java.awt.Desktop; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Instant; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import net.modtale.launcher.settings.LauncherSettings; +import net.modtale.launcher.settings.SettingsStore; + +public class HytaleAuthService { + + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + private static final long LOGIN_TIMEOUT_MINUTES = 15; + private static final Pattern JWT_EXP_PATTERN = Pattern.compile("\"exp\"\\s*:\\s*(\\d+)"); + private static final Pattern GAME_VERSION_PATTERN = Pattern.compile("^(\\d+)\\.(\\d+)(?:\\.(\\d+))?.*$"); + + private final HytaleApiClient apiClient; + private final SettingsStore settingsStore; + + public HytaleAuthService(HytaleApiClient apiClient, SettingsStore settingsStore) { + this.apiClient = apiClient; + this.settingsStore = settingsStore; + } + + public HytaleAuthSession loginAndSave(LauncherSettings settings) { + OAuthGrant grant = requestAuthorizationCode(); + HytaleApiClient.TokenResponse token = apiClient.exchangeCode(grant.code(), grant.codeVerifier()); + List profiles = apiClient.fetchProfiles(token.accessToken); + HytaleProfile profile = profiles.getFirst(); + HytaleGameSession gameSession = apiClient.createGameSession(token.accessToken, profile.uuid()); + if (!gameSession.hasLaunchTokens()) { + throw new HytaleApiException("Hytale did not return launch session tokens. The game was not launched."); + } + + HytaleAuthSession session = new HytaleAuthSession(); + session.setAccessToken(token.accessToken); + session.setRefreshToken(token.refreshToken); + session.setExpiresAt(expiresAt(token.expiresIn)); + session.setUsername(profile.username()); + session.setUuid(profile.uuid()); + session.setAccountOwnerId(profile.owner()); + session.setProfiles(profiles); + session.setSessionToken(gameSession.sessionToken()); + session.setIdentityToken(gameSession.identityToken()); + settings.upsertHytaleAuthSession(session); + settingsStore.save(settings); + return session; + } + + public void selectAccount(LauncherSettings settings, String accountId) { + if (settings == null || accountId == null || accountId.isBlank()) { + return; + } + settings.selectHytaleAccount(accountId); + settingsStore.save(settings); + } + + public void selectProfile(LauncherSettings settings, HytaleProfile selectedProfile) { + if (settings == null || selectedProfile == null || selectedProfile.uuid().isBlank()) { + return; + } + HytaleAuthSession session = settings.getHytaleAuthSession(); + if (session == null) { + throw new HytaleApiException("Sign in with Hytale before choosing a profile."); + } + HytaleProfile profile = session.getProfiles().stream() + .filter(candidate -> selectedProfile.uuid().equals(candidate.uuid())) + .findFirst() + .orElse(selectedProfile); + session.setUsername(profile.username()); + session.setUuid(profile.uuid()); + settings.upsertHytaleAuthSession(session); + settingsStore.save(settings); + } + + public void logout(LauncherSettings settings) { + settings.removeActiveHytaleAuthSession(); + settingsStore.save(settings); + } + + public void logoutAccount(LauncherSettings settings, String accountId) { + if (settings == null || accountId == null || accountId.isBlank()) { + return; + } + settings.removeHytaleAuthSession(accountId); + settingsStore.save(settings); + } + + public List getAvailableVersions(LauncherSettings settings, String branch) { + HytaleAuthSession session = ensureValidAccessToken(settings); + try { + return HytaleGameVersionResolver.labelVersions(settings, branch, + apiClient.getAvailableVersions(session.getAccessToken(), branch)); + } catch (HytaleApiException ex) { + if (!ex.isAuthFailure()) { + throw ex; + } + session = refresh(settings, session); + return HytaleGameVersionResolver.labelVersions(settings, branch, + apiClient.getAvailableVersions(session.getAccessToken(), branch)); + } + } + + public List getAvailablePatchlines(LauncherSettings settings) { + HytaleAuthSession session = ensureValidAccessToken(settings); + List candidates = previousPatchlineCandidates(settings); + try { + return apiClient.getAvailablePatchlines(session.getAccessToken(), candidates); + } catch (HytaleApiException ex) { + if (!ex.isAuthFailure()) { + throw ex; + } + session = refresh(settings, session); + return apiClient.getAvailablePatchlines(session.getAccessToken(), candidates); + } + } + + public List getFriends(LauncherSettings settings) { + HytaleAuthSession cachedSession = settings.getHytaleAuthSession(); + if (canUseCachedFriendsSession(cachedSession)) { + try { + return enrichFriendsWithPublicProfiles( + cachedSession.getSessionToken(), + apiClient.fetchFriends(cachedSession.getSessionToken()) + ); + } catch (HytaleApiException ex) { + if (!ex.isAuthFailure()) { + throw ex; + } + } + } + + HytaleAuthSession session = ensureValidAccessToken(settings); + try { + return fetchFriendsWithFreshGameSession(settings, session); + } catch (HytaleApiException ex) { + if (!ex.isAuthFailure()) { + throw ex; + } + session = refresh(settings, session); + return fetchFriendsWithFreshGameSession(settings, session); + } + } + + public long getProfilePlaytimeSeconds(LauncherSettings settings) { + HytaleAuthSession session = ensureValidAccessToken(settings); + try { + return refreshProfilesAndGetPlaytime(settings, session); + } catch (HytaleApiException ex) { + if (!ex.isAuthFailure()) { + throw ex; + } + session = refresh(settings, session); + return refreshProfilesAndGetPlaytime(settings, session); + } + } + + public List getBlogPosts(int count) { + return apiClient.getBlogPosts(count); + } + + public List getAllBlogPosts() { + return apiClient.getAllBlogPosts(); + } + + public HytaleAuthSession ensureFreshSessionForLaunch(LauncherSettings settings) { + HytaleAuthSession existingSession = settings.getHytaleAuthSession(); + if (existingSession == null || !existingSession.hasRefreshToken()) { + throw new HytaleApiException("Sign in with Hytale before launching or loading Hytale versions."); + } + try { + HytaleAuthSession session = ensureValidAccessToken(settings); + HytaleGameSession gameSession = createGameSessionWithRefresh(settings, session); + if (!gameSession.hasLaunchTokens()) { + throw new HytaleApiException("Hytale did not return launch session tokens. The game was not launched."); + } + return saveGameSession(settings, session, gameSession); + } catch (HytaleApiException ex) { + HytaleAuthSession cachedSession = settings.getHytaleAuthSession(); + if (canUseCachedLaunchSession(cachedSession, ex)) { + return cachedSession; + } + throw ex; + } + } + + private List fetchFriendsWithFreshGameSession(LauncherSettings settings, HytaleAuthSession session) { + HytaleGameSession gameSession = createGameSessionWithRefresh(settings, session); + if (!gameSession.hasLaunchTokens()) { + throw new HytaleApiException("Hytale did not return social session tokens."); + } + HytaleAuthSession updatedSession = saveGameSession(settings, session, gameSession); + return enrichFriendsWithPublicProfiles( + updatedSession.getSessionToken(), + apiClient.fetchFriends(updatedSession.getSessionToken()) + ); + } + + private long refreshProfilesAndGetPlaytime(LauncherSettings settings, HytaleAuthSession session) { + List profiles = apiClient.fetchProfiles(session.getAccessToken()); + String selectedUuid = session.getUuid(); + HytaleProfile selectedProfile = profiles.stream() + .filter(profile -> profile.uuid().equals(selectedUuid)) + .findFirst() + .orElseGet(() -> profiles.isEmpty() ? null : profiles.getFirst()); + session.setProfiles(profiles); + if (selectedProfile != null) { + session.setUsername(selectedProfile.username()); + session.setUuid(selectedProfile.uuid()); + session.setAccountOwnerId(selectedProfile.owner()); + } + settings.upsertHytaleAuthSession(session); + settingsStore.save(settings); + return selectedProfile == null ? 0 : selectedProfile.playtimeSeconds(); + } + + static List previousPatchlineCandidates(LauncherSettings settings) { + LinkedHashSet candidates = new LinkedHashSet<>(); + if (settings != null) { + HytaleApiClient.normalizePatchlineId(settings.getHytaleBranch()) + .filter(patchline -> patchline.startsWith("v")) + .ifPresent(candidates::add); + } + HytaleGameVersionResolver.resolveBuildVersions(settings).values().stream() + .map(HytaleAuthService::parseGameVersion) + .flatMap(Optional::stream) + .max(GameVersion::compareTo) + .flatMap(GameVersion::previousPatchline) + .ifPresent(candidates::add); + return List.copyOf(candidates); + } + + private static Optional parseGameVersion(String value) { + if (value == null || value.isBlank()) { + return Optional.empty(); + } + Matcher matcher = GAME_VERSION_PATTERN.matcher(value.trim()); + if (!matcher.matches()) { + return Optional.empty(); + } + return Optional.of(new GameVersion( + parseVersionPart(matcher.group(1)), + parseVersionPart(matcher.group(2)), + parseVersionPart(matcher.group(3)) + )); + } + + private static int parseVersionPart(String value) { + if (value == null || value.isBlank()) { + return 0; + } + try { + return Integer.parseInt(value); + } catch (NumberFormatException ex) { + return 0; + } + } + + private List enrichFriendsWithPublicProfiles(String sessionToken, List friends) { + if (friends == null || friends.isEmpty()) { + return List.of(); + } + List unresolvedUuids = friends.stream() + .filter(friend -> friend.username().isBlank() && !friend.uuid().isBlank()) + .map(HytaleFriend::uuid) + .toList(); + if (unresolvedUuids.isEmpty()) { + return friends; + } + Map usernamesByUuid = apiClient.fetchPublicProfileUsernames(sessionToken, unresolvedUuids); + if (usernamesByUuid.isEmpty()) { + return friends; + } + return friends.stream() + .map(friend -> { + if (!friend.username().isBlank() || friend.uuid().isBlank()) { + return friend; + } + String username = usernamesByUuid.get(friend.uuid().toLowerCase(Locale.ROOT)); + return username == null || username.isBlank() ? friend : friend.withUsername(username); + }) + .toList(); + } + + private HytaleAuthSession saveGameSession( + LauncherSettings settings, + HytaleAuthSession session, + HytaleGameSession gameSession + ) { + session.setSessionToken(gameSession.sessionToken()); + session.setIdentityToken(gameSession.identityToken()); + settings.upsertHytaleAuthSession(session); + settingsStore.save(settings); + return session; + } + + private HytaleGameSession createGameSessionWithRefresh(LauncherSettings settings, HytaleAuthSession session) { + try { + return apiClient.createGameSession(session.getAccessToken(), session.getUuid()); + } catch (HytaleApiException ex) { + if (!ex.isAuthFailure()) { + throw ex; + } + HytaleAuthSession refreshed = refresh(settings, session); + return apiClient.createGameSession(refreshed.getAccessToken(), refreshed.getUuid()); + } + } + + private HytaleAuthSession ensureValidAccessToken(LauncherSettings settings) { + HytaleAuthSession session = settings.getHytaleAuthSession(); + if (session == null || !session.hasRefreshToken()) { + throw new HytaleApiException("Sign in with Hytale before launching or loading Hytale versions."); + } + if (!session.hasAccessToken() || session.getExpiresAt().isBefore(Instant.now().plusSeconds(60))) { + return refresh(settings, session); + } + return session; + } + + private HytaleAuthSession refresh(LauncherSettings settings, HytaleAuthSession session) { + try { + HytaleApiClient.TokenResponse token = apiClient.refreshToken(session.getRefreshToken()); + session.setAccessToken(token.accessToken); + if (token.refreshToken != null && !token.refreshToken.isBlank()) { + session.setRefreshToken(token.refreshToken); + } + session.setExpiresAt(expiresAt(token.expiresIn)); + settings.upsertHytaleAuthSession(session); + settingsStore.save(settings); + return session; + } catch (HytaleApiException ex) { + if (ex.requiresSignIn()) { + removeExpiredSession(settings, session); + } + throw ex; + } + } + + private void removeExpiredSession(LauncherSettings settings, HytaleAuthSession session) { + String accountId = LauncherSettings.hytaleAccountId(session); + if (accountId.isBlank()) { + settings.removeActiveHytaleAuthSession(); + } else { + settings.removeHytaleAuthSession(accountId); + } + settingsStore.save(settings); + } + + private static boolean canUseCachedLaunchSession(HytaleAuthSession session, HytaleApiException failure) { + return session != null + && session.hasRefreshToken() + && session.hasLaunchTokens() + && permitsCachedLaunchSession(failure); + } + + private static boolean permitsCachedLaunchSession(HytaleApiException failure) { + return failure != null + && (failure.isAuthFailure() || failure.statusCode() < 0 || failure.statusCode() >= 500); + } + + private static boolean canUseCachedFriendsSession(HytaleAuthSession session) { + return session != null + && session.hasLaunchTokens() + && jwtExpiresAfter(session.getSessionToken(), Instant.now().plusSeconds(60)); + } + + private static boolean jwtExpiresAfter(String token, Instant threshold) { + if (token == null || token.isBlank() || threshold == null) { + return false; + } + String[] parts = token.split("\\."); + if (parts.length < 2) { + return false; + } + try { + String payload = new String(Base64.getUrlDecoder().decode(padBase64Url(parts[1])), StandardCharsets.UTF_8); + Matcher matcher = JWT_EXP_PATTERN.matcher(payload); + if (!matcher.find()) { + return false; + } + return Instant.ofEpochSecond(Long.parseLong(matcher.group(1))).isAfter(threshold); + } catch (IllegalArgumentException ex) { + return false; + } + } + + private static String padBase64Url(String value) { + int padding = (4 - value.length() % 4) % 4; + return value + "=".repeat(padding); + } + + private OAuthGrant requestAuthorizationCode() { + String codeVerifier = generateCodeVerifier(); + String codeChallenge = codeChallenge(codeVerifier); + CompletableFuture codeFuture = new CompletableFuture<>(); + HttpServer server = null; + try { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 1); + int port = server.getAddress().getPort(); + OAuthState state = stateForPort(port); + server.createContext("/", exchange -> handleCallback(exchange, codeFuture, state)); + server.start(); + + URI authUri = URI.create(HytaleApiClient.AUTH_URL + + "?access_type=offline" + + "&client_id=" + encode(HytaleApiClient.CLIENT_ID) + + "&code_challenge=" + encode(codeChallenge) + + "&code_challenge_method=S256" + + "&redirect_uri=" + encode(HytaleApiClient.REDIRECT_URI) + + "&response_type=code" + + "&scope=" + encode(HytaleApiClient.SCOPES) + + "&state=" + encode(state.encodedState())); + openBrowser(authUri); + return new OAuthGrant(codeFuture.get(LOGIN_TIMEOUT_MINUTES, TimeUnit.MINUTES), codeVerifier); + } catch (TimeoutException ex) { + throw new HytaleApiException("Hytale sign-in timed out. Please try again.", ex); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new HytaleApiException("Hytale sign-in was interrupted.", ex); + } catch (Exception ex) { + if (ex instanceof HytaleApiException hytaleEx) { + throw hytaleEx; + } + throw new HytaleApiException("Hytale sign-in failed: " + ex.getMessage(), ex); + } finally { + if (server != null) { + server.stop(0); + } + } + } + + private void handleCallback(HttpExchange exchange, CompletableFuture codeFuture, OAuthState expectedState) throws IOException { + Map query = parseQuery(exchange.getRequestURI().getRawQuery()); + String code = query.get("code"); + String error = query.get("error"); + String returnedState = query.get("state"); + + String html; + if (error != null && !error.isBlank()) { + codeFuture.completeExceptionally(new HytaleApiException("Hytale authorization failed: " + error)); + html = page("Authorization failed", "Return to Modtale and try signing in again."); + } else if (code != null && !code.isBlank()) { + if (expectedState.matches(returnedState)) { + codeFuture.complete(code); + html = page("Authorization successful", "You can close this window and return to Modtale."); + } else { + codeFuture.completeExceptionally(new HytaleApiException("Hytale authorization returned an unexpected state.")); + html = page("Authorization failed", "Return to Modtale and try signing in again."); + } + } else { + html = page("Waiting for authorization", "Return to the Hytale authorization page to finish signing in."); + } + + byte[] body = html.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "text/html; charset=utf-8"); + exchange.sendResponseHeaders(200, body.length); + try (OutputStream output = exchange.getResponseBody()) { + output.write(body); + } + } + + private static void openBrowser(URI uri) { + if (!Desktop.isDesktopSupported() || !Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { + throw new HytaleApiException("Desktop browser integration is not available. Could not open Hytale sign-in."); + } + try { + Desktop.getDesktop().browse(uri); + } catch (IOException ex) { + throw new HytaleApiException("Could not open Hytale sign-in in your browser.", ex); + } + } + + private static Map parseQuery(String rawQuery) { + Map values = new LinkedHashMap<>(); + if (rawQuery == null || rawQuery.isBlank()) { + return values; + } + for (String pair : rawQuery.split("&")) { + int separator = pair.indexOf('='); + String key = separator >= 0 ? pair.substring(0, separator) : pair; + String value = separator >= 0 ? pair.substring(separator + 1) : ""; + values.put(decode(key), decode(value)); + } + return values; + } + + private static String page(String title, String message) { + return """ + + %s +

%s

%s

+ + """.formatted(title, title, message); + } + + private static String generateCodeVerifier() { + byte[] bytes = new byte[32]; + SECURE_RANDOM.nextBytes(bytes); + return base64Url(bytes); + } + + private static String codeChallenge(String verifier) { + try { + return base64Url(MessageDigest.getInstance("SHA-256").digest(verifier.getBytes(StandardCharsets.US_ASCII))); + } catch (NoSuchAlgorithmException ex) { + throw new HytaleApiException("SHA-256 is not available for Hytale sign-in.", ex); + } + } + + static OAuthState stateForPort(int port) { + String callbackState = randomBase32(26); + String json = "{\"state\":\"" + callbackState + "\",\"port\":\"" + port + "\"}"; + String encodedState = Base64.getEncoder().encodeToString(json.getBytes(StandardCharsets.UTF_8)); + return new OAuthState(encodedState, callbackState, port); + } + + private static String randomBase32(int length) { + char[] chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".toCharArray(); + byte[] bytes = new byte[length]; + SECURE_RANDOM.nextBytes(bytes); + char[] result = new char[length]; + for (int index = 0; index < length; index++) { + result[index] = chars[Byte.toUnsignedInt(bytes[index]) % chars.length]; + } + return new String(result); + } + + private static String base64Url(byte[] bytes) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private static String encode(String value) { + return java.net.URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8); + } + + private static String decode(String value) { + return URLDecoder.decode(value == null ? "" : value, StandardCharsets.UTF_8); + } + + private static Instant expiresAt(long expiresInSeconds) { + long seconds = expiresInSeconds <= 0 ? 3600 : expiresInSeconds; + return Instant.now().plusSeconds(seconds); + } + + private record OAuthGrant(String code, String codeVerifier) { + } + + private record GameVersion(int major, int minor, int patch) implements Comparable { + + private Optional previousPatchline() { + if (minor <= 0) { + return Optional.empty(); + } + return Optional.of("v" + major + "." + (minor - 1)); + } + + @Override + public int compareTo(GameVersion other) { + int majorCompare = Integer.compare(major, other.major); + if (majorCompare != 0) { + return majorCompare; + } + int minorCompare = Integer.compare(minor, other.minor); + if (minorCompare != 0) { + return minorCompare; + } + return Integer.compare(patch, other.patch); + } + } + + record OAuthState(String encodedState, String callbackState, int port) { + + boolean matches(String returnedState) { + return returnedState != null && !returnedState.isBlank() + && (returnedState.equals(callbackState) || returnedState.equals(encodedState)); + } + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/hytale/HytaleAuthSession.java b/launcher/src/main/java/net/modtale/launcher/hytale/HytaleAuthSession.java new file mode 100644 index 00000000..263295b7 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/hytale/HytaleAuthSession.java @@ -0,0 +1,111 @@ +package net.modtale.launcher.hytale; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class HytaleAuthSession { + + private String accessToken = ""; + private String refreshToken = ""; + private Instant expiresAt = Instant.EPOCH; + private String sessionToken = ""; + private String identityToken = ""; + private String username = ""; + private String uuid = ""; + private String accountOwnerId = ""; + private List profiles = new ArrayList<>(); + + public String getAccessToken() { + return accessToken; + } + + public void setAccessToken(String accessToken) { + this.accessToken = accessToken == null ? "" : accessToken; + } + + public String getRefreshToken() { + return refreshToken; + } + + public void setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken == null ? "" : refreshToken; + } + + public Instant getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(Instant expiresAt) { + this.expiresAt = expiresAt == null ? Instant.EPOCH : expiresAt; + } + + public String getSessionToken() { + return sessionToken; + } + + public void setSessionToken(String sessionToken) { + this.sessionToken = sessionToken == null ? "" : sessionToken; + } + + public String getIdentityToken() { + return identityToken; + } + + public void setIdentityToken(String identityToken) { + this.identityToken = identityToken == null ? "" : identityToken; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username == null ? "" : username; + } + + public String getUuid() { + return uuid; + } + + public void setUuid(String uuid) { + this.uuid = uuid == null ? "" : uuid; + } + + public String getAccountOwnerId() { + return accountOwnerId; + } + + public void setAccountOwnerId(String accountOwnerId) { + this.accountOwnerId = accountOwnerId == null ? "" : accountOwnerId; + } + + public List getProfiles() { + return List.copyOf(profiles); + } + + public void setProfiles(List profiles) { + this.profiles = profiles == null ? new ArrayList<>() : new ArrayList<>(profiles); + } + + public boolean hasRefreshToken() { + return refreshToken != null && !refreshToken.isBlank(); + } + + public boolean hasAccessToken() { + return accessToken != null && !accessToken.isBlank(); + } + + public boolean hasLaunchTokens() { + return identityToken != null && !identityToken.isBlank() + && sessionToken != null && !sessionToken.isBlank() + && uuid != null && !uuid.isBlank(); + } + + @Override + public String toString() { + return username == null || username.isBlank() ? "Hytale account" : username; + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/hytale/HytaleBlogPost.java b/launcher/src/main/java/net/modtale/launcher/hytale/HytaleBlogPost.java new file mode 100644 index 00000000..6802f0f6 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/hytale/HytaleBlogPost.java @@ -0,0 +1,13 @@ +package net.modtale.launcher.hytale; + +import java.time.Instant; + +public record HytaleBlogPost(String title, String url, String imageUrl, Instant publishedAt) { + + public HytaleBlogPost { + title = title == null ? "" : title.trim(); + url = url == null ? "" : url.trim(); + imageUrl = imageUrl == null ? "" : imageUrl.trim(); + publishedAt = publishedAt == null ? Instant.EPOCH : publishedAt; + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/hytale/HytaleFriend.java b/launcher/src/main/java/net/modtale/launcher/hytale/HytaleFriend.java new file mode 100644 index 00000000..1c161ab2 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/hytale/HytaleFriend.java @@ -0,0 +1,29 @@ +package net.modtale.launcher.hytale; + +public record HytaleFriend(String username, String uuid, String status, String avatarUrl, boolean online) { + + public HytaleFriend { + username = username == null ? "" : username.trim(); + uuid = uuid == null ? "" : uuid.trim(); + status = status == null ? "" : status.trim(); + avatarUrl = avatarUrl == null ? "" : avatarUrl.trim(); + } + + public String displayName() { + if (!username.isBlank()) { + return username; + } + return uuid.isBlank() ? "Hytale friend" : uuid; + } + + public HytaleFriend withUsername(String username) { + return new HytaleFriend(username, uuid, status, avatarUrl, online); + } + + public String displayStatus() { + if (!status.isBlank()) { + return status; + } + return online ? "Online" : "Offline"; + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/hytale/HytaleGameLauncher.java b/launcher/src/main/java/net/modtale/launcher/hytale/HytaleGameLauncher.java new file mode 100644 index 00000000..2f20e3d6 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/hytale/HytaleGameLauncher.java @@ -0,0 +1,264 @@ +package net.modtale.launcher.hytale; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import net.modtale.launcher.settings.HytalePathDetector; +import net.modtale.launcher.settings.LauncherSettings; + +public class HytaleGameLauncher { + + private final HytaleAuthService authService; + + public HytaleGameLauncher(HytaleAuthService authService) { + this.authService = authService; + } + + public HytaleLaunchResult launch(LauncherSettings settings) { + HytaleAuthSession session = authService.ensureFreshSessionForLaunch(settings); + if (!session.hasLaunchTokens()) { + throw new HytaleApiException("A fresh Hytale-authenticated session is required before launch."); + } + + LaunchPaths paths = resolvePaths(settings); + try { + Files.createDirectories(paths.userDataDirectory()); + ProcessBuilder builder = new ProcessBuilder(command(paths, session)); + builder.directory(paths.workingDirectory().toFile()); + builder.redirectErrorStream(true); + applyEnvironment(builder.environment(), paths); + Process process = builder.start(); + return new HytaleLaunchResult(process, paths.executable(), session.getUsername(), session.getUuid()); + } catch (IOException ex) { + throw new HytaleApiException("Could not launch Hytale: " + ex.getMessage(), ex); + } + } + + static List command(LaunchPaths paths, HytaleAuthSession session) { + List command = new ArrayList<>(); + command.add(paths.executable().toString()); + command.addAll(authenticatedArguments(paths.gameDirectory(), paths.userDataDirectory(), paths.javaExecutable(), session)); + return command; + } + + static List authenticatedArguments( + Path gameDirectory, + Path userDataDirectory, + Path javaExecutable, + HytaleAuthSession session + ) { + if (session == null || !session.hasLaunchTokens()) { + throw new HytaleApiException("A fresh Hytale-authenticated session is required before launch."); + } + List args = new ArrayList<>(); + args.add("--app-dir"); + args.add(gameDirectory.toString()); + args.add("--user-dir"); + args.add(userDataDirectory.toString()); + args.add("--java-exec"); + args.add(javaExecutable.toString()); + args.add("--name"); + args.add(session.getUsername()); + args.add("--auth-mode"); + args.add("authenticated"); + args.add("--uuid"); + args.add(session.getUuid()); + args.add("--identity-token"); + args.add(session.getIdentityToken()); + args.add("--session-token"); + args.add(session.getSessionToken()); + return args; + } + + static LaunchPaths resolvePaths(LauncherSettings settings) { + Path configuredGameDirectory = settings.hytaleGameDirectory(); + if (configuredGameDirectory.toString().isBlank()) { + throw new HytaleApiException("Choose the Hytale game folder before launching."); + } + Path gameDirectory = resolveGameDirectory( + configuredGameDirectory, + settings.getHytaleBranch(), + settings.getHytaleBuild() + ); + Path executable = resolveExecutable(gameDirectory); + if (!Files.isRegularFile(executable)) { + throw new HytaleApiException("Hytale client was not found. Choose the Hytale data folder or a game folder containing " + + resolveExecutable(configuredGameDirectory)); + } + + Path userDataDirectory = settings.hytaleUserDataDirectory(); + Path javaExecutable = resolveJavaExecutable( + settings.hytaleJavaExecutable(), + gameDirectory, + userDataDirectory, + settings.getHytaleBranch() + ); + if (!Files.isRegularFile(javaExecutable)) { + throw new HytaleApiException("Java executable was not found at " + javaExecutable); + } + + Path workingDirectory = HytalePlatform.isMac() + ? executable.getParent() + : gameDirectory.resolve("Client"); + return new LaunchPaths(gameDirectory, userDataDirectory, javaExecutable, executable, workingDirectory); + } + + static Path resolveGameDirectory(Path configuredDirectory, String branch, int build) { + return gameDirectoryCandidates(configuredDirectory, branch, build).stream() + .filter(HytaleGameLauncher::containsClientExecutable) + .findFirst() + .orElse(configuredDirectory); + } + + static Path resolveJavaExecutable(Path configuredJava, Path gameDirectory, Path userDataDirectory, String branch) { + if (Files.isRegularFile(configuredJava) && !HytalePathDetector.isCurrentJavaExecutable(configuredJava)) { + return configuredJava; + } + + Optional bundledJava = bundledJavaCandidates(gameDirectory, userDataDirectory, branch).stream() + .filter(Files::isRegularFile) + .findFirst(); + if (bundledJava.isPresent()) { + return bundledJava.get(); + } + + if (Files.isRegularFile(configuredJava)) { + return configuredJava; + } + + return HytalePathDetector.detectExistingJavaExecutable().orElse(configuredJava); + } + + static Path resolveExecutable(Path gameDirectory) { + if (HytalePlatform.isMac()) { + return gameDirectory.resolve(Path.of("Client", "Hytale.app", "Contents", "MacOS", "HytaleClient")); + } + if (HytalePlatform.isWindows()) { + return gameDirectory.resolve(Path.of("Client", "HytaleClient.exe")); + } + return gameDirectory.resolve(Path.of("Client", "HytaleClient")); + } + + private static void applyEnvironment(Map environment, LaunchPaths paths) { + Path clientDirectory = paths.gameDirectory().resolve("Client"); + if (HytalePlatform.isWindows()) { + return; + } + + String separator = System.getProperty("path.separator", ":"); + String existingLd = environment.getOrDefault("LD_LIBRARY_PATH", ""); + environment.put("LD_LIBRARY_PATH", clientDirectory + (existingLd.isBlank() ? "" : separator + existingLd)); + + if (HytalePlatform.isMac()) { + String existingDyld = environment.getOrDefault("DYLD_LIBRARY_PATH", ""); + environment.put("DYLD_LIBRARY_PATH", clientDirectory + (existingDyld.isBlank() ? "" : separator + existingDyld)); + } + } + + private static boolean containsClientExecutable(Path path) { + return Files.isRegularFile(resolveExecutable(path)); + } + + private static List gameDirectoryCandidates(Path configuredDirectory, String branch, int build) { + LinkedHashSet paths = new LinkedHashSet<>(); + paths.add(configuredDirectory); + addVersionFolders(paths, configuredDirectory, build); + addOfficialGameFolders(paths, configuredDirectory, branch, build); + dataRootFromOfficialGameDirectory(configuredDirectory).ifPresent(root -> addOfficialGameFolders(paths, root, branch, build)); + return List.copyOf(paths); + } + + private static void addOfficialGameFolders(LinkedHashSet paths, Path hytaleDataRoot, String branch, int build) { + for (String branchName : branchNames(branch)) { + Path gameRoot = hytaleDataRoot.resolve(Path.of("install", branchName, "package", "game")); + addVersionFolders(paths, gameRoot, build); + paths.add(gameRoot); + } + } + + private static void addVersionFolders(LinkedHashSet paths, Path root, int build) { + if (build > 0) { + paths.add(root.resolve(Integer.toString(build))); + } + paths.add(root.resolve("latest")); + } + + private static List bundledJavaCandidates(Path gameDirectory, Path userDataDirectory, String branch) { + LinkedHashSet paths = new LinkedHashSet<>(); + dataRootFromOfficialGameDirectory(gameDirectory).ifPresent(root -> addOfficialJavaFolders(paths, root, branch)); + dataRootFromUserDataDirectory(userDataDirectory).ifPresent(root -> addOfficialJavaFolders(paths, root, branch)); + return List.copyOf(paths); + } + + private static void addOfficialJavaFolders(LinkedHashSet paths, Path hytaleDataRoot, String branch) { + String executable = HytalePlatform.isWindows() ? "java.exe" : "java"; + for (String branchName : branchNames(branch)) { + paths.add(hytaleDataRoot.resolve(Path.of("install", branchName, "package", "jre", "latest", "bin", executable))); + } + paths.add(hytaleDataRoot.resolve(Path.of("jre", "latest", "bin", executable))); + } + + private static List branchNames(String branch) { + String normalized = HytaleApiClient.normalizeBranch(branch); + if ("pre-release".equals(normalized)) { + return List.of("pre-release", "prerelease", "release"); + } + if (!"release".equals(normalized)) { + return List.of(normalized); + } + return List.of("release"); + } + + private static Optional dataRootFromUserDataDirectory(Path userDataDirectory) { + if (userDataDirectory == null || userDataDirectory.getFileName() == null) { + return Optional.empty(); + } + String name = userDataDirectory.getFileName().toString(); + if ("UserData".equalsIgnoreCase(name) || "userdata".equalsIgnoreCase(name)) { + return Optional.ofNullable(userDataDirectory.getParent()); + } + return Optional.empty(); + } + + private static Optional dataRootFromOfficialGameDirectory(Path gameDirectory) { + if (gameDirectory == null) { + return Optional.empty(); + } + Path candidate = gameDirectory.toAbsolutePath().normalize(); + if (candidate.getFileName() != null && !"game".equals(candidate.getFileName().toString())) { + candidate = candidate.getParent(); + } + if (candidate == null || candidate.getFileName() == null || !"game".equals(candidate.getFileName().toString())) { + return Optional.empty(); + } + Path packageDirectory = candidate.getParent(); + if (packageDirectory == null || packageDirectory.getFileName() == null + || !"package".equals(packageDirectory.getFileName().toString())) { + return Optional.empty(); + } + Path branchDirectory = packageDirectory.getParent(); + if (branchDirectory == null) { + return Optional.empty(); + } + Path installDirectory = branchDirectory.getParent(); + if (installDirectory == null || installDirectory.getFileName() == null + || !"install".equals(installDirectory.getFileName().toString())) { + return Optional.empty(); + } + return Optional.ofNullable(installDirectory.getParent()); + } + + public record LaunchPaths( + Path gameDirectory, + Path userDataDirectory, + Path javaExecutable, + Path executable, + Path workingDirectory + ) { + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/hytale/HytaleGameSession.java b/launcher/src/main/java/net/modtale/launcher/hytale/HytaleGameSession.java new file mode 100644 index 00000000..bab828d0 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/hytale/HytaleGameSession.java @@ -0,0 +1,9 @@ +package net.modtale.launcher.hytale; + +public record HytaleGameSession(String sessionToken, String identityToken) { + + public boolean hasLaunchTokens() { + return sessionToken != null && !sessionToken.isBlank() + && identityToken != null && !identityToken.isBlank(); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/hytale/HytaleGameVersionResolver.java b/launcher/src/main/java/net/modtale/launcher/hytale/HytaleGameVersionResolver.java new file mode 100644 index 00000000..a884bbd6 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/hytale/HytaleGameVersionResolver.java @@ -0,0 +1,226 @@ +package net.modtale.launcher.hytale; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.jar.JarFile; +import java.util.jar.Manifest; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; +import net.modtale.launcher.settings.HytalePathDetector; +import net.modtale.launcher.settings.LauncherSettings; + +public final class HytaleGameVersionResolver { + + private static final Pattern LOG_GAME_PROCESS = Pattern.compile("\\bgame_build=(\\d+)\\s+game_version=([^\\s\"]+)"); + private static final Pattern LOG_RELEASE = Pattern.compile("\\{build:(\\d+)\\s+version:([^}]+)}"); + private static final Pattern BUILD_DIRECTORY = Pattern.compile("build-(\\d+)"); + + private HytaleGameVersionResolver() { + } + + public static List labelVersions(LauncherSettings settings, List versions) { + return labelVersions(settings, settings == null ? "release" : settings.getHytaleBranch(), versions); + } + + public static List labelVersions(LauncherSettings settings, String branch, List versions) { + if (versions == null || versions.isEmpty()) { + return List.of(); + } + + Map labels = resolveBuildVersions(settings, branch); + if (labels.isEmpty()) { + return List.copyOf(versions); + } + + return versions.stream() + .map(version -> { + String label = labels.get(version.build()); + return label == null || label.isBlank() ? version : version.withGameVersion(label); + }) + .toList(); + } + + public static Map resolveBuildVersions(LauncherSettings settings) { + return resolveBuildVersions(settings, settings == null ? "release" : settings.getHytaleBranch()); + } + + public static Map resolveBuildVersions(LauncherSettings settings, String branch) { + Map labels = new LinkedHashMap<>(); + hytaleRoots(settings).forEach(root -> readOfficialLauncherLog(root.resolve("hytale-launcher.log"), labels)); + installedBuild(settings, branch).ifPresent(build -> installedServerVersion(settings, branch) + .ifPresent(version -> labels.put(build, version))); + return labels; + } + + public static Optional installedServerVersion(LauncherSettings settings) { + return installedServerVersion(settings, settings == null ? "release" : settings.getHytaleBranch()); + } + + public static Optional installedServerVersion(LauncherSettings settings, String branch) { + if (settings == null) { + return Optional.empty(); + } + return installedGameDirectory(settings, branch) + .flatMap(gameDirectory -> serverVersionFromJar(gameDirectory.resolve(Path.of("Server", "HytaleServer.jar")))); + } + + static Optional serverVersionFromJar(Path jarPath) { + if (jarPath == null || !Files.isRegularFile(jarPath)) { + return Optional.empty(); + } + try (JarFile jar = new JarFile(jarPath.toFile())) { + Manifest manifest = jar.getManifest(); + if (manifest != null) { + String implementationVersion = manifest.getMainAttributes().getValue("Implementation-Version"); + if (implementationVersion != null && !implementationVersion.isBlank()) { + return Optional.of(implementationVersion.trim()); + } + } + } catch (IOException ignored) { + return Optional.empty(); + } + return Optional.empty(); + } + + static void readOfficialLauncherLog(Path logPath, Map labels) { + if (logPath == null || !Files.isRegularFile(logPath)) { + return; + } + try (Stream lines = Files.lines(logPath)) { + lines.forEach(line -> { + Matcher process = LOG_GAME_PROCESS.matcher(line); + while (process.find()) { + labels.put(parseBuild(process.group(1)), process.group(2).trim()); + } + Matcher release = LOG_RELEASE.matcher(line); + while (release.find()) { + labels.put(parseBuild(release.group(1)), release.group(2).trim()); + } + }); + } catch (IOException ignored) { + // Best-effort: the official launcher may be writing this file while we read. + } + } + + static Optional installedBuild(LauncherSettings settings) { + return installedBuild(settings, settings == null ? "release" : settings.getHytaleBranch()); + } + + static Optional installedBuild(LauncherSettings settings, String branch) { + if (settings == null) { + return Optional.empty(); + } + + Path packageDirectory = installedGameDirectory(settings, branch) + .flatMap(HytaleGameVersionResolver::packageDirectory) + .orElse(null); + if (packageDirectory == null) { + return Optional.empty(); + } + Path sigDirectory = packageDirectory.resolve("sig"); + if (!Files.isDirectory(sigDirectory)) { + return Optional.empty(); + } + try (Stream children = Files.list(sigDirectory)) { + return children + .filter(Files::isDirectory) + .map(path -> BUILD_DIRECTORY.matcher(path.getFileName().toString())) + .filter(Matcher::matches) + .map(matcher -> parseBuild(matcher.group(1))) + .max(Integer::compareTo); + } catch (IOException ignored) { + return Optional.empty(); + } + } + + private static Optional installedGameDirectory(LauncherSettings settings, String branch) { + if (settings == null) { + return Optional.empty(); + } + String normalizedBranch = HytaleApiClient.normalizeBranch(branch); + for (Path root : hytaleRoots(settings)) { + for (String branchName : branchDirectoryNames(normalizedBranch)) { + Path gameRoot = root.resolve(Path.of("install", branchName, "package", "game")); + Path latest = gameRoot.resolve("latest"); + if (Files.isDirectory(latest)) { + return Optional.of(latest); + } + if (Files.isDirectory(gameRoot)) { + return Optional.of(gameRoot); + } + } + } + if (normalizedBranch.equals(HytaleApiClient.normalizeBranch(settings.getHytaleBranch())) + && Files.isDirectory(settings.hytaleGameDirectory())) { + return Optional.of(settings.hytaleGameDirectory()); + } + return Optional.empty(); + } + + private static List branchDirectoryNames(String branch) { + String normalized = HytaleApiClient.normalizeBranch(branch); + if ("pre-release".equals(normalized)) { + return List.of("pre-release", "prerelease"); + } + return List.of(normalized); + } + + private static Optional packageDirectory(Path gameDirectory) { + if (gameDirectory == null) { + return Optional.empty(); + } + + Path current = gameDirectory.toAbsolutePath().normalize(); + while (current != null) { + if ("package".equals(current.getFileName() == null ? "" : current.getFileName().toString())) { + return Optional.of(current); + } + current = current.getParent(); + } + return Optional.empty(); + } + + private static List hytaleRoots(LauncherSettings settings) { + List roots = new ArrayList<>(); + if (settings != null) { + dataRootFromPackageDirectory(settings.hytaleGameDirectory()).ifPresent(roots::add); + Path userData = settings.hytaleUserDataDirectory(); + if (userData != null && userData.getParent() != null) { + roots.add(userData.getParent()); + } + } + roots.addAll(HytalePathDetector.hytaleDataDirectoryCandidates()); + return roots.stream().map(path -> path.toAbsolutePath().normalize()).distinct().toList(); + } + + private static Optional dataRootFromPackageDirectory(Path gameDirectory) { + Optional packageDirectory = packageDirectory(gameDirectory); + if (packageDirectory.isEmpty()) { + return Optional.empty(); + } + Path branchDirectory = packageDirectory.get().getParent(); + if (branchDirectory == null) { + return Optional.empty(); + } + Path installDirectory = branchDirectory.getParent(); + if (installDirectory == null) { + return Optional.empty(); + } + return Optional.ofNullable(installDirectory.getParent()); + } + + private static int parseBuild(String value) { + try { + return Integer.parseInt(value); + } catch (NumberFormatException ex) { + return 0; + } + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/hytale/HytaleLaunchResult.java b/launcher/src/main/java/net/modtale/launcher/hytale/HytaleLaunchResult.java new file mode 100644 index 00000000..7a4485eb --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/hytale/HytaleLaunchResult.java @@ -0,0 +1,6 @@ +package net.modtale.launcher.hytale; + +import java.nio.file.Path; + +public record HytaleLaunchResult(Process process, Path executable, String username, String uuid) { +} diff --git a/launcher/src/main/java/net/modtale/launcher/hytale/HytalePlatform.java b/launcher/src/main/java/net/modtale/launcher/hytale/HytalePlatform.java new file mode 100644 index 00000000..0d934ab1 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/hytale/HytalePlatform.java @@ -0,0 +1,36 @@ +package net.modtale.launcher.hytale; + +import java.util.Locale; + +public final class HytalePlatform { + + private HytalePlatform() { + } + + public static String os() { + String osName = System.getProperty("os.name", "").toLowerCase(Locale.ROOT); + if (osName.contains("win")) { + return "windows"; + } + if (osName.contains("mac") || osName.contains("darwin")) { + return "darwin"; + } + return "linux"; + } + + public static String arch() { + String arch = System.getProperty("os.arch", "").toLowerCase(Locale.ROOT); + if (arch.contains("aarch64") || arch.contains("arm64")) { + return "arm64"; + } + return "amd64"; + } + + public static boolean isWindows() { + return "windows".equals(os()); + } + + public static boolean isMac() { + return "darwin".equals(os()); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/hytale/HytaleProfile.java b/launcher/src/main/java/net/modtale/launcher/hytale/HytaleProfile.java new file mode 100644 index 00000000..8d47ace2 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/hytale/HytaleProfile.java @@ -0,0 +1,24 @@ +package net.modtale.launcher.hytale; + +public record HytaleProfile(String username, String uuid, String owner, long playtimeSeconds) { + + public HytaleProfile(String username, String uuid, String owner) { + this(username, uuid, owner, 0); + } + + public HytaleProfile { + username = username == null ? "" : username; + uuid = uuid == null ? "" : uuid; + owner = owner == null ? "" : owner; + playtimeSeconds = Math.max(0, playtimeSeconds); + } + + public String displayName() { + return username.isBlank() ? "Hytale profile" : username; + } + + @Override + public String toString() { + return displayName(); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/hytale/HytaleVersion.java b/launcher/src/main/java/net/modtale/launcher/hytale/HytaleVersion.java new file mode 100644 index 00000000..3c52cee3 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/hytale/HytaleVersion.java @@ -0,0 +1,45 @@ +package net.modtale.launcher.hytale; + +public record HytaleVersion( + String branch, + int build, + int fromBuild, + boolean latest, + String gameVersion, + String pwrUrl, + String pwrHeadUrl, + String sigUrl +) { + + public HytaleVersion( + String branch, + int build, + int fromBuild, + boolean latest, + String pwrUrl, + String pwrHeadUrl, + String sigUrl + ) { + this(branch, build, fromBuild, latest, "", pwrUrl, pwrHeadUrl, sigUrl); + } + + public HytaleVersion withGameVersion(String gameVersion) { + return new HytaleVersion(branch, build, fromBuild, latest, gameVersion, pwrUrl, pwrHeadUrl, sigUrl); + } + + public String displayVersion() { + if (gameVersion != null && !gameVersion.isBlank()) { + return gameVersion; + } + return "Build " + build; + } + + @Override + public String toString() { + String suffix = latest ? " (latest)" : ""; + if (gameVersion != null && !gameVersion.isBlank()) { + return gameVersion + " - Build " + build + suffix; + } + return displayVersion() + suffix; + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/hytale/HytaleWorldManager.java b/launcher/src/main/java/net/modtale/launcher/hytale/HytaleWorldManager.java new file mode 100644 index 00000000..4067fc2f --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/hytale/HytaleWorldManager.java @@ -0,0 +1,378 @@ +package net.modtale.launcher.hytale; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayDeque; +import java.util.Collection; +import java.util.Comparator; +import java.util.Deque; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import net.modtale.launcher.api.ModtaleApiException; +import net.modtale.launcher.settings.HytalePathDetector; +import net.modtale.launcher.settings.LauncherSettings; + +public final class HytaleWorldManager { + + private static final ObjectMapper MAPPER = new ObjectMapper() + .enable(SerializationFeature.INDENT_OUTPUT); + + public List loadWorlds(LauncherSettings settings) { + Path savesDirectory = savesDirectory(settings); + if (!Files.isDirectory(savesDirectory)) { + return List.of(); + } + try (var stream = Files.list(savesDirectory)) { + return stream + .filter(Files::isDirectory) + .map(this::worldFromDirectory) + .sorted(Comparator.comparing(HytaleWorld::name, String.CASE_INSENSITIVE_ORDER)) + .toList(); + } catch (IOException ex) { + throw new ModtaleApiException("Could not read Hytale worlds from " + savesDirectory, ex); + } + } + + public HytaleWorldConfig loadConfig(Path configPath) { + ObjectNode root = readConfigRoot(configPath); + JsonNode mods = root.get("Mods"); + Map enabledByMod = new LinkedHashMap<>(); + if (mods != null && mods.isObject()) { + mods.fields().forEachRemaining(entry -> { + JsonNode enabled = entry.getValue().get("Enabled"); + enabledByMod.put(entry.getKey(), enabled != null && enabled.asBoolean(false)); + }); + } + return new HytaleWorldConfig(configPath, enabledByMod); + } + + public void setModEnabled(Path configPath, String modId, boolean enabled) { + setModsEnabled(configPath, List.of(modId), enabled); + } + + public void setModsEnabled(Path configPath, Collection modIds, boolean enabled) { + if (configPath == null || modIds == null || modIds.isEmpty()) { + return; + } + ObjectNode root = readConfigRoot(configPath); + ObjectNode mods = objectNode(root, "Mods"); + for (String modId : modIds) { + if (modId == null || modId.isBlank()) { + continue; + } + ObjectNode mod = objectNode(mods, modId.trim()); + mod.put("Enabled", enabled); + } + try { + Files.createDirectories(configPath.getParent()); + MAPPER.writeValue(configPath.toFile(), root); + } catch (IOException ex) { + throw new ModtaleApiException("Could not update Hytale world config " + configPath, ex); + } + } + + public List loadInstalledMods(LauncherSettings settings) { + Path modsDirectory = modsDirectory(settings); + if (!Files.isDirectory(modsDirectory)) { + return List.of(); + } + try (var stream = Files.list(modsDirectory)) { + return stream + .filter(Files::isRegularFile) + .filter(HytaleWorldManager::isJar) + .map(this::installedModFromJar) + .sorted(Comparator.comparing(HytaleInstalledMod::name, String.CASE_INSENSITIVE_ORDER)) + .toList(); + } catch (IOException ex) { + throw new ModtaleApiException("Could not read Hytale mods from " + modsDirectory, ex); + } + } + + public Path savesDirectory(LauncherSettings settings) { + Path configured = settings.hytaleUserDataDirectory().resolve("Saves"); + if (Files.isDirectory(configured)) { + return configured; + } + return HytalePathDetector.detectExistingSavesDirectory().orElse(configured); + } + + public Path modsDirectory(LauncherSettings settings) { + Path paired = pairedModsDirectory(settings); + if (paired != null && Files.isDirectory(paired)) { + return paired; + } + Path configured = settings.hytaleModsDirectory(); + if (Files.isDirectory(configured)) { + return configured; + } + return HytalePathDetector.detectExistingModsDirectory().orElse(configured); + } + + private Path pairedModsDirectory(LauncherSettings settings) { + Path savesDirectory = savesDirectory(settings); + Path userDataDirectory = savesDirectory.getParent(); + return userDataDirectory == null ? null : userDataDirectory.resolve("Mods"); + } + + private HytaleWorld worldFromDirectory(Path directory) { + Path configPath = directory.resolve("config.json"); + HytaleWorldConfig config = loadConfig(configPath); + HytaleWorldMetadata metadata = readMetadata(directory); + long enabledMods = config.enabledByMod().values().stream().filter(Boolean::booleanValue).count(); + return new HytaleWorld( + directory.toAbsolutePath().normalize(), + directory.getFileName() == null ? directory.toString() : directory.getFileName().toString(), + configPath.toAbsolutePath().normalize(), + metadata.patchline(), + metadata.previewImage(), + (int) enabledMods, + config.enabledByMod().size(), + lastModified(directory, configPath) + ); + } + + private HytaleWorldMetadata readMetadata(Path directory) { + Path metadata = directory.resolve("client_metadata.json"); + JsonNode root = null; + if (!Files.isRegularFile(metadata)) { + return new HytaleWorldMetadata("", previewImage(directory, null)); + } + try { + root = MAPPER.readTree(metadata.toFile()); + return new HytaleWorldMetadata( + root.path("CreatedWithPatchline").asText(""), + previewImage(directory, root) + ); + } catch (IOException ignored) { + return new HytaleWorldMetadata("", previewImage(directory, root)); + } + } + + private String previewImage(Path directory, JsonNode metadata) { + String fromMetadata = previewImageFromMetadata(directory, metadata); + if (!fromMetadata.isBlank()) { + return fromMetadata; + } + for (String filename : List.of( + "preview.png", + "preview.jpg", + "preview.jpeg", + "preview.webp", + "thumbnail.png", + "thumbnail.jpg", + "screenshot.png", + "screenshot.jpg" + )) { + Path candidate = directory.resolve(filename); + if (Files.isRegularFile(candidate)) { + return candidate.toAbsolutePath().normalize().toUri().toString(); + } + } + return ""; + } + + private String previewImageFromMetadata(Path directory, JsonNode metadata) { + if (metadata == null || metadata.isMissingNode() || metadata.isNull()) { + return ""; + } + Deque nodes = new ArrayDeque<>(); + nodes.add(metadata); + while (!nodes.isEmpty()) { + JsonNode node = nodes.removeFirst(); + if (node == null || node.isNull() || node.isMissingNode()) { + continue; + } + if (node.isObject()) { + Iterator> fields = node.fields(); + while (fields.hasNext()) { + Map.Entry field = fields.next(); + if (isPreviewField(field.getKey())) { + String resolved = resolveImageValue(directory, field.getValue().asText("")); + if (!resolved.isBlank()) { + return resolved; + } + } + nodes.addLast(field.getValue()); + } + } else if (node.isArray()) { + node.forEach(nodes::addLast); + } + } + return ""; + } + + private boolean isPreviewField(String field) { + if (field == null || field.isBlank()) { + return false; + } + String normalized = field.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]+", ""); + return List.of( + "preview", + "previewimage", + "previewimagepath", + "previewpath", + "thumbnail", + "thumbnailimage", + "thumbnailimagepath", + "thumbnailpath", + "screenshot", + "screenshotimage", + "screenshotpath", + "image", + "imagepath" + ).contains(normalized); + } + + private String resolveImageValue(Path directory, String value) { + if (value == null || value.isBlank()) { + return ""; + } + String normalized = value.trim(); + if (normalized.startsWith("http://") + || normalized.startsWith("https://") + || normalized.startsWith("file:") + || normalized.startsWith("data:")) { + return normalized; + } + try { + Path path = Path.of(normalized); + if (!path.isAbsolute()) { + path = directory.resolve(path); + } + path = path.toAbsolutePath().normalize(); + return Files.isRegularFile(path) ? path.toUri().toString() : ""; + } catch (IllegalArgumentException ignored) { + try { + URI uri = URI.create(normalized); + return uri.isAbsolute() ? normalized : ""; + } catch (IllegalArgumentException ignoredAgain) { + return ""; + } + } + } + + private Instant lastModified(Path directory, Path configPath) { + try { + Instant directoryTime = Files.getLastModifiedTime(directory).toInstant(); + if (!Files.exists(configPath)) { + return directoryTime; + } + Instant configTime = Files.getLastModifiedTime(configPath).toInstant(); + return configTime.isAfter(directoryTime) ? configTime : directoryTime; + } catch (IOException ignored) { + return Instant.EPOCH; + } + } + + private HytaleInstalledMod installedModFromJar(Path jar) { + String fallbackName = jar.getFileName() == null ? jar.toString() : jar.getFileName().toString(); + String baseName = fallbackName.replaceFirst("(?i)\\.jar$", ""); + try (ZipFile zip = new ZipFile(jar.toFile())) { + ZipEntry manifest = zip.getEntry("manifest.json"); + if (manifest == null) { + return new HytaleInstalledMod(baseName, baseName, "", "", jar.toAbsolutePath().normalize()); + } + try (InputStream input = zip.getInputStream(manifest)) { + JsonNode root = MAPPER.readTree(input); + String group = root.path("Group").asText(""); + String name = root.path("Name").asText(baseName); + String id = group.isBlank() || name.isBlank() ? baseName : group + ":" + name; + return new HytaleInstalledMod( + id, + name.isBlank() ? baseName : name, + root.path("Version").asText(""), + root.path("Description").asText(""), + jar.toAbsolutePath().normalize() + ); + } + } catch (IOException ex) { + return new HytaleInstalledMod(baseName, baseName, "", "", jar.toAbsolutePath().normalize()); + } + } + + private ObjectNode readConfigRoot(Path configPath) { + if (configPath == null || !Files.exists(configPath)) { + ObjectNode root = MAPPER.createObjectNode(); + root.put("Version", 4); + root.set("Mods", MAPPER.createObjectNode()); + return root; + } + try { + JsonNode root = MAPPER.readTree(configPath.toFile()); + if (root instanceof ObjectNode objectNode) { + return objectNode; + } + } catch (IOException ex) { + throw new ModtaleApiException("Could not read Hytale world config " + configPath, ex); + } + ObjectNode replacement = MAPPER.createObjectNode(); + replacement.put("Version", 4); + replacement.set("Mods", MAPPER.createObjectNode()); + return replacement; + } + + private static ObjectNode objectNode(ObjectNode parent, String field) { + JsonNode existing = parent.get(field); + if (existing instanceof ObjectNode objectNode) { + return objectNode; + } + ObjectNode replacement = MAPPER.createObjectNode(); + parent.set(field, replacement); + return replacement; + } + + private static boolean isJar(Path path) { + String name = path.getFileName() == null ? "" : path.getFileName().toString().toLowerCase(Locale.ROOT); + return name.endsWith(".jar"); + } + + public record HytaleWorld( + Path directory, + String name, + Path configPath, + String patchline, + String previewImage, + int enabledMods, + int totalMods, + Instant updatedAt + ) { + public HytaleWorld { + previewImage = previewImage == null ? "" : previewImage.trim(); + } + } + + private record HytaleWorldMetadata(String patchline, String previewImage) { + private HytaleWorldMetadata { + patchline = patchline == null ? "" : patchline.trim(); + previewImage = previewImage == null ? "" : previewImage.trim(); + } + } + + public record HytaleWorldConfig(Path configPath, Map enabledByMod) { + public HytaleWorldConfig { + enabledByMod = enabledByMod == null ? Map.of() : Map.copyOf(enabledByMod); + } + } + + public record HytaleInstalledMod( + String id, + String name, + String version, + String description, + Path file + ) { + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/install/ArchiveInstaller.java b/launcher/src/main/java/net/modtale/launcher/install/ArchiveInstaller.java new file mode 100644 index 00000000..2805d1b0 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/install/ArchiveInstaller.java @@ -0,0 +1,172 @@ +package net.modtale.launcher.install; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.stream.Stream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +public class ArchiveInstaller { + + public List installDownloadedFile(Path downloadedFile, String filename, Path modsDirectory, boolean unpackArchive) + throws IOException { + Files.createDirectories(modsDirectory); + if (unpackArchive) { + return extractInstallableEntries(downloadedFile, modsDirectory); + } + Path destination = uniqueDestination(modsDirectory, safeFilename(filename)); + Files.copy(downloadedFile, destination, StandardCopyOption.REPLACE_EXISTING); + return List.of(destination); + } + + public List installModpackArchive(Path archive, Path modsDirectory) throws IOException { + Files.createDirectories(modsDirectory); + Path stagingDirectory = Files.createTempDirectory("modtale-modpack-"); + try { + Path extractedDirectory = stagingDirectory.resolve("extracted"); + Files.createDirectories(extractedDirectory); + extractArchive(archive, extractedDirectory); + + Path contentRoot = modpackContentRoot(extractedDirectory); + List installed = new ArrayList<>(); + try (Stream files = Files.walk(contentRoot)) { + for (Path source : files + .filter(Files::isRegularFile) + .sorted(Comparator.comparing(Path::toString)) + .toList()) { + Path destination = uniqueDestination(modsDirectory, safeFilename(source.getFileName().toString())); + Files.move(source, destination, StandardCopyOption.REPLACE_EXISTING); + installed.add(destination); + } + } + return installed; + } finally { + deleteRecursively(stagingDirectory); + } + } + + public List extractInstallableEntries(Path archive, Path modsDirectory) throws IOException { + Files.createDirectories(modsDirectory); + List installed = new ArrayList<>(); + try (InputStream input = Files.newInputStream(archive); + ZipInputStream zip = new ZipInputStream(input)) { + ZipEntry entry; + while ((entry = zip.getNextEntry()) != null) { + if (entry.isDirectory() || !isInstallable(entry.getName())) { + continue; + } + Path destination = resolveSafeDestination(modsDirectory, entry.getName()); + Files.createDirectories(destination.getParent()); + Files.copy(zip, destination, StandardCopyOption.REPLACE_EXISTING); + installed.add(destination); + } + } + return installed; + } + + private static void extractArchive(Path archive, Path extractionRoot) throws IOException { + try (InputStream input = Files.newInputStream(archive); + ZipInputStream zip = new ZipInputStream(input)) { + ZipEntry entry; + while ((entry = zip.getNextEntry()) != null) { + Path destination = resolveExtractionDestination(extractionRoot, entry.getName()); + if (entry.isDirectory()) { + Files.createDirectories(destination); + continue; + } + Files.createDirectories(destination.getParent()); + Files.copy(zip, destination, StandardCopyOption.REPLACE_EXISTING); + } + } + } + + private static Path modpackContentRoot(Path extractedDirectory) throws IOException { + try (Stream children = Files.list(extractedDirectory)) { + List entries = children + .filter(path -> !isArchiveMetadataDirectory(path)) + .toList(); + if (entries.size() == 1 && Files.isDirectory(entries.getFirst())) { + return entries.getFirst(); + } + } + return extractedDirectory; + } + + private static boolean isInstallable(String entryName) { + String filename = Path.of(entryName).getFileName().toString(); + String lower = filename.toLowerCase(Locale.ROOT); + return lower.endsWith(".jar") || lower.endsWith(".zip") || lower.endsWith(".hmasset") || lower.endsWith(".hymod"); + } + + private static Path resolveSafeDestination(Path modsDirectory, String entryName) throws IOException { + String filename = safeFilename(Path.of(entryName).getFileName().toString()); + Path destination = uniqueDestination(modsDirectory, filename); + Path normalizedTarget = destination.normalize(); + Path normalizedRoot = modsDirectory.toRealPath().normalize(); + if (!normalizedTarget.toAbsolutePath().normalize().startsWith(normalizedRoot.toAbsolutePath())) { + throw new IOException("Archive entry escapes the target mods directory: " + entryName); + } + return destination; + } + + private static Path resolveExtractionDestination(Path extractionRoot, String entryName) throws IOException { + Path destination = extractionRoot.resolve(entryName).normalize(); + Path normalizedRoot = extractionRoot.toAbsolutePath().normalize(); + if (!destination.toAbsolutePath().normalize().startsWith(normalizedRoot)) { + throw new IOException("Archive entry escapes the extraction directory: " + entryName); + } + return destination; + } + + private static Path uniqueDestination(Path modsDirectory, String filename) { + Path candidate = modsDirectory.resolve(filename); + if (!Files.exists(candidate)) { + return candidate; + } + int extensionStart = filename.lastIndexOf('.'); + String base = extensionStart > 0 ? filename.substring(0, extensionStart) : filename; + String extension = extensionStart > 0 ? filename.substring(extensionStart) : ""; + int counter = 2; + while (true) { + Path next = modsDirectory.resolve(base + "-" + counter + extension); + if (!Files.exists(next)) { + return next; + } + counter++; + } + } + + private static boolean isArchiveMetadataDirectory(Path path) { + return Files.isDirectory(path) && "__MACOSX".equals(path.getFileName().toString()); + } + + private static void deleteRecursively(Path directory) throws IOException { + if (directory == null || Files.notExists(directory)) { + return; + } + try (Stream paths = Files.walk(directory)) { + List cleanup = paths + .sorted(Comparator.reverseOrder()) + .toList(); + for (Path path : cleanup) { + Files.deleteIfExists(path); + } + } + } + + static String safeFilename(String filename) { + String base = filename == null || filename.isBlank() ? "modtale-download.jar" : Path.of(filename).getFileName().toString(); + String sanitized = base.replaceAll("[^A-Za-z0-9._-]+", "-") + .replaceAll("-+", "-") + .replaceAll("-+\\.", ".") + .replaceAll("(^-|-$)", ""); + return sanitized.isBlank() ? "modtale-download.jar" : sanitized; + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/install/ModInstaller.java b/launcher/src/main/java/net/modtale/launcher/install/ModInstaller.java new file mode 100644 index 00000000..26b30a44 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/install/ModInstaller.java @@ -0,0 +1,444 @@ +package net.modtale.launcher.install; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import net.modtale.launcher.api.ModtaleApiClient.DownloadedFile; +import net.modtale.launcher.api.ModtaleApiClient; +import net.modtale.launcher.api.ModtaleApiException; +import net.modtale.launcher.logging.LogSanitizer; +import net.modtale.launcher.model.install.InstallOptions; +import net.modtale.launcher.model.install.InstallResult; +import net.modtale.launcher.model.install.InstalledProject; +import net.modtale.launcher.model.install.InstalledProjectReference; +import net.modtale.launcher.model.project.DownloadUrlResponse; +import net.modtale.launcher.model.project.ProjectClassification; +import net.modtale.launcher.model.project.ProjectDependency; +import net.modtale.launcher.model.project.ProjectDetail; +import net.modtale.launcher.model.project.ProjectVersion; +import net.modtale.launcher.model.project.VersionDependenciesView; +import net.modtale.launcher.settings.LauncherSettings; +import net.modtale.launcher.settings.SettingsStore; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +public class ModInstaller { + + private static final Logger LOG = LogManager.getLogger(ModInstaller.class); + + private final ModtaleApiClient apiClient; + private final SettingsStore settingsStore; + private final ArchiveInstaller archiveInstaller; + + public ModInstaller(ModtaleApiClient apiClient, SettingsStore settingsStore) { + this(apiClient, settingsStore, new ArchiveInstaller()); + } + + ModInstaller(ModtaleApiClient apiClient, SettingsStore settingsStore, ArchiveInstaller archiveInstaller) { + this.apiClient = apiClient; + this.settingsStore = settingsStore; + this.archiveInstaller = archiveInstaller; + } + + public InstallResult installLatest(ProjectDetail project, LauncherSettings settings) { + ProjectVersion version = VersionSelector.latestCompatible(project, settings.getGameVersion()) + .orElseThrow(() -> new ModtaleApiException("No compatible version was found for " + project.title())); + return install(project, version, optionsFrom(settings)); + } + + public InstallResult install(ProjectDetail project, ProjectVersion version, InstallOptions options) { + if (project == null || version == null) { + throw new ModtaleApiException("Select a project and version before installing."); + } + LOG.info("Starting install projectId=" + project.id() + + " title=\"" + project.title() + "\"" + + " classification=" + project.classification() + + " version=" + version.versionNumber() + + " versionId=" + version.id() + + " gameVersion=" + options.gameVersion() + + " modsDirectory=" + options.modsDirectory() + + " includeDependencies=" + options.includeDependencies() + + " includeOptionalDependencies=" + options.includeOptionalDependencies() + + " selectedDependencies=" + (options.selectedDependencies() == null ? 0 : options.selectedDependencies().size())); + try { + Files.createDirectories(options.modsDirectory()); + } catch (IOException ex) { + LOG.warn("Could not create mods directory " + options.modsDirectory(), ex); + throw new ModtaleApiException("Could not create Hytale mods directory " + options.modsDirectory(), ex); + } + + List dependencies = dependencies(project, version, options); + boolean exactDependencySelection = options.hasSelectedDependencies(); + boolean includeOptional = exactDependencySelection || options.includeOptionalDependencies(); + List selectedModtaleDependencies = selectedModtaleDependencies(dependencies, includeOptional); + List selectedExternalDependencies = selectedExternalDependencies(dependencies, includeOptional); + boolean hasDependencySelection = exactDependencySelection + ? !dependencies.isEmpty() + : options.includeDependencies(); + boolean isBundle = hasDependencySelection && !selectedModtaleDependencies.isEmpty(); + boolean isModpack = ProjectClassification.isModpack(project.classification()); + List selectedReferences = selectedDependencyReferences(dependencies, includeOptional); + LOG.info("Resolved dependencies projectId=" + project.id() + + " total=" + dependencies.size() + + " selectedModtale=" + selectedModtaleDependencies.size() + + " selectedExternal=" + selectedExternalDependencies.size() + + " installMode=" + (isModpack ? "MODPACK" : isBundle ? "BUNDLE" : "DIRECT")); + + List installedFiles = new ArrayList<>(); + List warnings = new ArrayList<>(); + List externalNames = new ArrayList<>(); + + DownloadUrlResponse downloadUrl = isBundle + ? apiClient.getBundleDownloadUrl(project.id(), version.versionNumber(), selectedModtaleDependencies, options.gameVersion()) + : apiClient.getDownloadUrl(project.id(), version.versionNumber(), options.gameVersion()); + LOG.info("Resolved download URL projectId=" + project.id() + + " mode=" + (isBundle ? "BUNDLE" : "DIRECT") + + " url=" + LogSanitizer.url(downloadUrl == null ? "" : downloadUrl.downloadUrl())); + + DownloadedFile mainDownload = apiClient.download(downloadUrl.downloadUrl()); + boolean unpackMainDownload = isBundle || looksLikeGeneratedArchive(mainDownload); + LOG.info("Installing main download projectId=" + project.id() + + " filename=" + mainDownload.filename() + + " contentType=" + mainDownload.contentType() + + " unpack=" + unpackMainDownload + + " temp=" + mainDownload.path()); + try { + if (isModpack || isBundle) { + installedFiles.addAll(archiveInstaller.installModpackArchive(mainDownload.path(), options.modsDirectory())); + } else { + installedFiles.addAll(archiveInstaller.installDownloadedFile( + mainDownload.path(), + mainDownload.filename(), + options.modsDirectory(), + unpackMainDownload + )); + } + LOG.info("Installed main download projectId=" + project.id() + + " fileCount=" + installedFiles.size() + + " files=" + installedFiles); + } catch (IOException ex) { + LOG.warn("Could not install main download projectId=" + project.id() + + " into " + options.modsDirectory(), ex); + throw new ModtaleApiException("Could not install " + project.title() + " into " + options.modsDirectory(), ex); + } finally { + deleteTemp(mainDownload.path()); + } + + for (ProjectDependency dependency : selectedExternalDependencies) { + if (dependency.externalFileUrl() == null || dependency.externalFileUrl().isBlank()) { + warnings.add("External dependency needs manual install: " + displayName(dependency)); + LOG.warn("External dependency missing file URL: " + displayName(dependency)); + continue; + } + LOG.info("Downloading external dependency " + displayName(dependency) + + " url=" + LogSanitizer.url(dependency.externalFileUrl())); + DownloadedFile externalDownload = apiClient.download(dependency.externalFileUrl()); + try { + String filename = dependency.externalFileName() == null || dependency.externalFileName().isBlank() + ? externalDownload.filename() + : dependency.externalFileName(); + installedFiles.addAll(archiveInstaller.installDownloadedFile( + externalDownload.path(), + filename, + options.modsDirectory(), + false + )); + externalNames.add(displayName(dependency)); + LOG.info("Installed external dependency " + displayName(dependency) + + " filename=" + filename + + " totalFileCount=" + installedFiles.size()); + } catch (IOException ex) { + warnings.add("External dependency failed: " + displayName(dependency) + " (" + ex.getMessage() + ")"); + LOG.warn("External dependency failed: " + displayName(dependency), ex); + } finally { + deleteTemp(externalDownload.path()); + } + } + + InstalledProject installedProject = new InstalledProject( + project.id(), + project.slug(), + project.title(), + project.classification(), + version.versionNumber(), + version.id(), + options.gameVersion(), + Instant.now(), + Instant.now(), + installedFiles.stream().map(Path::toString).toList(), + selectedModtaleDependencies, + externalNames, + InstalledProject.SOURCE_MODTALE, + isModpack ? InstalledProject.INSTALL_MODPACK : isBundle ? InstalledProject.INSTALL_BUNDLE : InstalledProject.INSTALL_DIRECT, + false, + selectedReferences + ); + LOG.info("Completed install projectId=" + project.id() + + " installedVersion=" + version.versionNumber() + + " fileCount=" + installedFiles.size() + + " warnings=" + warnings.size()); + return new InstallResult(installedProject, installedFiles, warnings); + } + + public InstallResult installAndRecord(ProjectDetail project, LauncherSettings settings) { + ProjectVersion version = VersionSelector.latestCompatible(project, settings.getGameVersion()) + .orElseThrow(() -> new ModtaleApiException("No compatible version was found for " + project.title())); + InstalledProject previous = removePreviousInstall(project.id(), settings); + InstallResult result = install(project, version, optionsFrom(settings)); + return recordInstall(result, settings, previous); + } + + public InstallResult installAndRecord(ProjectDetail project, ProjectVersion version, LauncherSettings settings, String gameVersion) { + InstalledProject previous = removePreviousInstall(project.id(), settings); + InstallResult result = install(project, version, new InstallOptions( + settings.hytaleModsDirectory(), + gameVersion == null || gameVersion.isBlank() ? settings.getGameVersion() : gameVersion, + settings.isIncludeDependencies(), + settings.isIncludeOptionalDependencies() + )); + return recordInstall(result, settings, previous); + } + + public InstallResult installAndRecord( + ProjectDetail project, + ProjectVersion version, + LauncherSettings settings, + String gameVersion, + List selectedDependencies + ) { + InstalledProject previous = removePreviousInstall(project.id(), settings); + List dependencies = selectedDependencies == null ? null : List.copyOf(selectedDependencies); + InstallResult result = install(project, version, new InstallOptions( + settings.hytaleModsDirectory(), + gameVersion == null || gameVersion.isBlank() ? settings.getGameVersion() : gameVersion, + dependencies != null && !dependencies.isEmpty(), + true, + dependencies + )); + return recordInstall(result, settings, previous); + } + + public InstallResult updateAndRecord(ProjectDetail project, ProjectVersion version, LauncherSettings settings) { + InstalledProject previous = existingInstall(project.id(), settings).orElse(null); + removePreviousInstall(project.id(), settings); + InstallResult result = install(project, version, previous == null ? optionsFrom(settings) : optionsFrom(settings, previous)); + return recordInstall(result, settings, previous); + } + + public InstallResult switchVersionAndRecord( + InstalledProject installed, + ProjectDetail project, + ProjectVersion version, + LauncherSettings settings + ) { + return switchVersionAndRecord(installed, project, version, settings, ""); + } + + public InstallResult switchVersionAndRecord( + InstalledProject installed, + ProjectDetail project, + ProjectVersion version, + LauncherSettings settings, + String gameVersion + ) { + if (installed == null) { + return updateAndRecord(project, version, settings); + } + removePreviousInstall(installed.projectId(), settings); + InstallResult result = install(project, version, optionsFrom(settings, installed, gameVersion)); + return recordInstall(result, settings, installed); + } + + public void uninstallAndRecord(InstalledProject installed, LauncherSettings settings) { + if (installed == null || settings == null) { + return; + } + deleteRecordedFiles(installed); + settings.removeInstalledProject(installed.projectId()); + settingsStore.removeInstalledProject(installed.projectId()); + settingsStore.save(settings); + } + + private InstallResult recordInstall(InstallResult result, LauncherSettings settings, InstalledProject previous) { + InstalledProject recorded = mergeInstallMetadata(result.installedProject(), previous); + settings.upsertInstalledProject(recorded); + settingsStore.save(settings); + return new InstallResult(recorded, result.installedFiles(), result.warnings()); + } + + private InstalledProject mergeInstallMetadata(InstalledProject fresh, InstalledProject previous) { + if (previous == null) { + return fresh; + } + boolean unlocked = previous.modpackUnlocked() && (fresh.isModpack() || !fresh.bundledProjects().isEmpty()); + return new InstalledProject( + fresh.projectId(), + fresh.slug(), + fresh.title(), + fresh.classification(), + fresh.installedVersion(), + fresh.installedVersionId(), + fresh.gameVersion(), + previous.installedAt(), + fresh.updatedAt(), + fresh.files(), + fresh.dependencyProjectIds(), + fresh.externalDependencies(), + fresh.source(), + fresh.installType(), + unlocked, + fresh.bundledProjects() + ); + } + + private Optional existingInstall(String projectId, LauncherSettings settings) { + if (projectId == null || settings == null) { + return Optional.empty(); + } + return settings.getInstalledProjects().stream() + .filter(installed -> projectId.equals(installed.projectId())) + .findFirst(); + } + + private InstalledProject removePreviousInstall(String projectId, LauncherSettings settings) { + InstalledProject previous = existingInstall(projectId, settings).orElse(null); + if (previous != null) { + LOG.info("Removing previous install projectId=" + projectId + + " fileCount=" + previous.files().size()); + deleteRecordedFiles(previous); + } + return previous; + } + + private static void deleteRecordedFiles(InstalledProject installed) { + installed.files().forEach(file -> { + try { + Files.deleteIfExists(Path.of(file)); + } catch (IOException ignored) { + LOG.warn("Could not delete stale installed file " + file); + // Stale files should not block an update; the new install can still succeed. + } + }); + } + + private static InstallOptions optionsFrom(LauncherSettings settings) { + return new InstallOptions( + settings.hytaleModsDirectory(), + settings.getGameVersion(), + settings.isIncludeDependencies(), + settings.isIncludeOptionalDependencies() + ); + } + + private static InstallOptions optionsFrom(LauncherSettings settings, InstalledProject installed) { + return optionsFrom(settings, installed, ""); + } + + private static InstallOptions optionsFrom(LauncherSettings settings, InstalledProject installed, String selectedGameVersion) { + String gameVersion = selectedGameVersion == null || selectedGameVersion.isBlank() + ? installed.gameVersion() + : selectedGameVersion.trim(); + if (gameVersion == null || gameVersion.isBlank()) { + gameVersion = settings.getGameVersion(); + } + if (!installed.bundledProjects().isEmpty()) { + return new InstallOptions( + settings.hytaleModsDirectory(), + gameVersion, + true, + true, + installed.bundledProjects().stream() + .map(InstalledProjectReference::toDependency) + .toList() + ); + } + return new InstallOptions( + settings.hytaleModsDirectory(), + gameVersion, + settings.isIncludeDependencies(), + settings.isIncludeOptionalDependencies() + ); + } + + private List dependencies(ProjectDetail project, ProjectVersion version, InstallOptions options) { + if (options.hasSelectedDependencies()) { + return options.selectedDependencies(); + } + VersionDependenciesView dependenciesView = options.includeDependencies() + ? apiClient.getDependencies(project.id(), version.versionNumber(), options.gameVersion()) + : new VersionDependenciesView(List.of()); + LOG.info("Loaded dependency view projectId=" + project.id() + + " version=" + version.versionNumber() + + " count=" + dependenciesView.dependencies().size()); + return dependenciesView.dependencies(); + } + + private static List selectedDependencyReferences( + List dependencies, + boolean includeOptional + ) { + return dependencies.stream() + .filter(dependency -> !dependency.isEmbedded()) + .filter(dependency -> includeOptional || !dependency.isOptional()) + .map(InstalledProjectReference::fromDependency) + .toList(); + } + + private static List selectedModtaleDependencies(List dependencies, boolean includeOptional) { + Set ids = new LinkedHashSet<>(); + for (ProjectDependency dependency : dependencies) { + if (dependency.isExternal() || dependency.isEmbedded() || dependency.projectId() == null || dependency.projectId().isBlank()) { + continue; + } + if (dependency.isOptional() && !includeOptional) { + continue; + } + ids.add(dependency.projectId()); + } + return List.copyOf(ids); + } + + private static List selectedExternalDependencies(List dependencies, boolean includeOptional) { + return dependencies.stream() + .filter(ProjectDependency::isExternal) + .filter(dependency -> !dependency.isEmbedded()) + .filter(dependency -> includeOptional || !dependency.isOptional()) + .toList(); + } + + private static boolean looksLikeGeneratedArchive(DownloadedFile file) { + String lowerName = file.filename() == null ? "" : file.filename().toLowerCase(java.util.Locale.ROOT); + String lowerType = file.contentType() == null ? "" : file.contentType().toLowerCase(java.util.Locale.ROOT); + return lowerName.endsWith("-unzip-me.zip") + || lowerName.contains("modpack") + || lowerType.contains("application/zip"); + } + + private static void deleteTemp(Path path) { + try { + Files.deleteIfExists(path); + } catch (IOException ignored) { + LOG.warn("Could not delete temporary download " + path); + // Temporary download cleanup is best-effort. + } + } + + private static String displayName(ProjectDependency dependency) { + if (dependency.projectTitle() != null && !dependency.projectTitle().isBlank()) { + return dependency.projectTitle(); + } + if (dependency.title() != null && !dependency.title().isBlank()) { + return dependency.title(); + } + if (dependency.externalId() != null && !dependency.externalId().isBlank()) { + return dependency.source() + ":" + dependency.externalId(); + } + return dependency.id() == null ? "external dependency" : dependency.id(); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/install/UpdateService.java b/launcher/src/main/java/net/modtale/launcher/install/UpdateService.java new file mode 100644 index 00000000..23bf24c0 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/install/UpdateService.java @@ -0,0 +1,75 @@ +package net.modtale.launcher.install; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import net.modtale.launcher.api.ModtaleApiClient; +import net.modtale.launcher.model.install.InstalledProject; +import net.modtale.launcher.model.install.UpdateCandidate; +import net.modtale.launcher.model.project.ProjectDetail; +import net.modtale.launcher.model.project.ProjectVersion; +import net.modtale.launcher.settings.LauncherSettings; + +public class UpdateService { + + private final ModtaleApiClient apiClient; + + public UpdateService(ModtaleApiClient apiClient) { + this.apiClient = apiClient; + } + + public List checkForUpdates(LauncherSettings settings) { + List updates = new ArrayList<>(); + for (InstalledProject installed : settings.getInstalledProjects()) { + checkForUpdate(settings, installed).ifPresent(updates::add); + } + return updates; + } + + public Optional checkForUpdate(LauncherSettings settings, InstalledProject installed) { + if (installed == null || !isModtaleProject(installed)) { + return Optional.empty(); + } + ProjectDetail project = projectWithVersions(routeKey(installed)); + ProjectVersion newest = VersionSelector.latestCompatible(project, effectiveGameVersion(settings, installed)).orElse(null); + if (newest == null || sameVersion(installed, newest)) { + return Optional.empty(); + } + return Optional.of(new UpdateCandidate(installed, project, newest)); + } + + public ProjectDetail projectWithVersions(String routeKey) { + ProjectDetail project = apiClient.getProject(routeKey); + if (project.versions().isEmpty()) { + project = project.withVersions(apiClient.getProjectVersions(project.routeKey())); + } + return project; + } + + private static String routeKey(InstalledProject installed) { + return installed.slug() != null && !installed.slug().isBlank() ? installed.slug() : installed.projectId(); + } + + private static boolean isModtaleProject(InstalledProject installed) { + String source = installed.source() == null ? "" : installed.source().trim(); + return source.isBlank() || InstalledProject.SOURCE_MODTALE.equalsIgnoreCase(source); + } + + private static String effectiveGameVersion(LauncherSettings settings, InstalledProject installed) { + if (installed.gameVersion() != null && !installed.gameVersion().isBlank()) { + return installed.gameVersion(); + } + return settings.getGameVersion(); + } + + public static boolean sameVersion(InstalledProject installed, ProjectVersion version) { + if (hasText(installed.installedVersionId()) && hasText(version.id())) { + return installed.installedVersionId().equals(version.id()); + } + return hasText(installed.installedVersion()) && installed.installedVersion().equals(version.versionNumber()); + } + + private static boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/install/VersionSelector.java b/launcher/src/main/java/net/modtale/launcher/install/VersionSelector.java new file mode 100644 index 00000000..d6a208c7 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/install/VersionSelector.java @@ -0,0 +1,59 @@ +package net.modtale.launcher.install; + +import java.time.Instant; +import java.time.format.DateTimeParseException; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import net.modtale.launcher.model.project.ProjectDetail; +import net.modtale.launcher.model.project.ProjectVersion; + +public final class VersionSelector { + + private VersionSelector() { + } + + public static Optional latestCompatible(ProjectDetail project, String gameVersion) { + if (project == null) { + return Optional.empty(); + } + return latestCompatible(project.versions(), gameVersion); + } + + public static Optional latestCompatible(List versions, String gameVersion) { + if (versions == null || versions.isEmpty()) { + return Optional.empty(); + } + return versions.stream() + .filter(version -> version.supportsGameVersion(gameVersion)) + .max(Comparator + .comparingInt(VersionSelector::channelRank) + .thenComparing(VersionSelector::releaseInstant, Comparator.nullsFirst(Comparator.naturalOrder())) + .thenComparing(ProjectVersion::versionNumber, Comparator.nullsFirst(Comparator.naturalOrder()))); + } + + private static int channelRank(ProjectVersion version) { + String channel = version.channel(); + if (channel == null || channel.isBlank() || "RELEASE".equalsIgnoreCase(channel)) { + return 3; + } + if ("BETA".equalsIgnoreCase(channel)) { + return 2; + } + if ("ALPHA".equalsIgnoreCase(channel)) { + return 1; + } + return 0; + } + + private static Instant releaseInstant(ProjectVersion version) { + if (version.releaseDate() == null || version.releaseDate().isBlank()) { + return null; + } + try { + return Instant.parse(version.releaseDate()); + } catch (DateTimeParseException ignored) { + return null; + } + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/install/WorldModListInstaller.java b/launcher/src/main/java/net/modtale/launcher/install/WorldModListInstaller.java new file mode 100644 index 00000000..840a1742 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/install/WorldModListInstaller.java @@ -0,0 +1,80 @@ +package net.modtale.launcher.install; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import net.modtale.launcher.api.ModtaleApiClient; +import net.modtale.launcher.api.ModtaleApiClient.DownloadedFile; +import net.modtale.launcher.api.ModtaleApiException; +import net.modtale.launcher.model.worldlist.WorldModList; +import net.modtale.launcher.model.worldlist.WorldModListInstallResult; +import net.modtale.launcher.settings.LauncherSettings; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +public final class WorldModListInstaller { + + private static final Logger LOG = LogManager.getLogger(WorldModListInstaller.class); + + private final ModtaleApiClient apiClient; + private final ArchiveInstaller archiveInstaller; + + public WorldModListInstaller(ModtaleApiClient apiClient) { + this(apiClient, new ArchiveInstaller()); + } + + WorldModListInstaller(ModtaleApiClient apiClient, ArchiveInstaller archiveInstaller) { + this.apiClient = apiClient; + this.archiveInstaller = archiveInstaller; + } + + public WorldModListInstallResult install(WorldModList list, LauncherSettings settings) { + if (list == null || list.id().isBlank()) { + throw new ModtaleApiException("Select a shared mod list before installing."); + } + if (settings == null) { + throw new ModtaleApiException("Launcher settings are unavailable."); + } + + LOG.info("Starting shared list install listId=" + list.id() + + " title=\"" + list.title() + "\"" + + " modsDirectory=" + settings.hytaleModsDirectory() + + " itemCount=" + list.mods().size()); + DownloadedFile download = apiClient.download("/lists/" + encodePathSegment(list.id()) + "/download"); + try { + LOG.info("Extracting shared list archive listId=" + list.id() + + " filename=" + download.filename() + + " temp=" + download.path()); + List installedFiles = archiveInstaller.extractInstallableEntries(download.path(), settings.hytaleModsDirectory()); + if (installedFiles.isEmpty()) { + LOG.warn("Shared list archive had no installable files listId=" + list.id()); + throw new ModtaleApiException("This shared list did not include any installable files."); + } + LOG.info("Completed shared list install listId=" + list.id() + + " fileCount=" + installedFiles.size() + + " files=" + installedFiles); + return new WorldModListInstallResult(list, installedFiles); + } catch (IOException ex) { + LOG.warn("Could not install shared list listId=" + list.id() + + " into " + settings.hytaleModsDirectory(), ex); + throw new ModtaleApiException("Could not install " + list.title() + " into " + settings.hytaleModsDirectory(), ex); + } finally { + deleteTemp(download.path()); + } + } + + private static String encodePathSegment(String value) { + return java.net.URLEncoder.encode(value == null ? "" : value.trim(), java.nio.charset.StandardCharsets.UTF_8) + .replace("+", "%20"); + } + + private static void deleteTemp(Path path) { + try { + Files.deleteIfExists(path); + } catch (IOException ignored) { + LOG.warn("Could not delete temporary shared list archive " + path); + // Temporary download cleanup is best-effort. + } + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/logging/LauncherLogging.java b/launcher/src/main/java/net/modtale/launcher/logging/LauncherLogging.java new file mode 100644 index 00000000..59bb90e3 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/logging/LauncherLogging.java @@ -0,0 +1,169 @@ +package net.modtale.launcher.logging; + +import java.io.IOException; +import java.io.OutputStream; +import java.io.PrintStream; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.core.LoggerContext; +import org.apache.logging.log4j.core.appender.RollingFileAppender; + +public final class LauncherLogging { + + private static final Logger LOG = LogManager.getLogger(LauncherLogging.class); + private static final Duration MAX_ACTIVE_LOG_AGE = Duration.ofHours(24); + private static final Path LATEST_LOG_PATH = Path.of( + System.getProperty("user.home", "."), + ".modtale", + "launcher", + "logs", + "latest.log" + ); + private static final Object LOCK = new Object(); + + private static ScheduledExecutorService rolloverExecutor; + private static Instant activeLogStartedAt; + private static boolean systemErrorMirrorInstalled; + + private LauncherLogging() { + } + + public static Path latestLogPath() { + return LATEST_LOG_PATH; + } + + public static void initialize() { + synchronized (LOCK) { + if (activeLogStartedAt == null) { + activeLogStartedAt = Instant.now(); + installGlobalExceptionHandler(); + installSystemErrorMirror(); + startRolloverScheduler(); + } + } + LOG.info("Logging to {}", LATEST_LOG_PATH.toAbsolutePath()); + } + + private static void installGlobalExceptionHandler() { + Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> + LOG.error("Unhandled exception on thread {}", thread.getName(), throwable)); + } + + private static void startRolloverScheduler() { + if (rolloverExecutor != null) { + return; + } + rolloverExecutor = Executors.newSingleThreadScheduledExecutor(task -> { + Thread thread = new Thread(task, "modtale-log-rollover"); + thread.setDaemon(true); + return thread; + }); + rolloverExecutor.scheduleAtFixedRate(LauncherLogging::rollIfExpired, 1, 1, TimeUnit.MINUTES); + } + + private static void rollIfExpired() { + synchronized (LOCK) { + Instant startedAt = activeLogStartedAt; + if (startedAt == null || Duration.between(startedAt, Instant.now()).compareTo(MAX_ACTIVE_LOG_AGE) < 0) { + return; + } + if (Files.notExists(LATEST_LOG_PATH)) { + activeLogStartedAt = Instant.now(); + return; + } + if (rollActiveLog()) { + activeLogStartedAt = Instant.now(); + LOG.info("Started a new latest.log after the previous log was active for 24 hours."); + } + } + } + + private static boolean rollActiveLog() { + try { + LoggerContext context = (LoggerContext) LogManager.getContext(false); + RollingFileAppender appender = context.getConfiguration().getAppender("LatestFile"); + if (appender == null) { + return false; + } + appender.getManager().rollover(); + return true; + } catch (RuntimeException ex) { + LOG.warn("Could not rotate launcher log.", ex); + return false; + } + } + + private static void installSystemErrorMirror() { + if (systemErrorMirrorInstalled) { + return; + } + PrintStream originalError = System.err; + System.setErr(new PrintStream( + new ErrorMirrorOutputStream(originalError), + true, + StandardCharsets.UTF_8 + )); + systemErrorMirrorInstalled = true; + } + + private static final class ErrorMirrorOutputStream extends OutputStream { + + private final PrintStream delegate; + private final java.io.ByteArrayOutputStream line = new java.io.ByteArrayOutputStream(); + + private ErrorMirrorOutputStream(PrintStream delegate) { + this.delegate = delegate; + } + + @Override + public synchronized void write(int value) throws IOException { + delegate.write(value); + if (value == '\n') { + flushLine(); + return; + } + if (value != '\r') { + line.write(value); + } + } + + @Override + public synchronized void write(byte[] buffer, int offset, int length) throws IOException { + delegate.write(buffer, offset, length); + for (int index = offset; index < offset + length; index++) { + int value = buffer[index] & 0xff; + if (value == '\n') { + flushLine(); + } else if (value != '\r') { + line.write(value); + } + } + } + + @Override + public synchronized void flush() throws IOException { + delegate.flush(); + flushLine(); + } + + private void flushLine() { + if (line.size() == 0) { + return; + } + String message = line.toString(Charset.defaultCharset()); + line.reset(); + if (!message.isBlank()) { + LOG.error("System.err: {}", message); + } + } + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/logging/LogSanitizer.java b/launcher/src/main/java/net/modtale/launcher/logging/LogSanitizer.java new file mode 100644 index 00000000..eff19fd3 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/logging/LogSanitizer.java @@ -0,0 +1,99 @@ +package net.modtale.launcher.logging; + +import java.net.URI; +import java.util.Locale; +import java.util.Set; + +public final class LogSanitizer { + + private static final Set SENSITIVE_QUERY_KEYS = Set.of( + "code", + "password", + "pre_auth_token", + "preauthtoken", + "state", + "token", + "xsrf_token" + ); + + private LogSanitizer() { + } + + public static String uri(URI uri) { + return uri == null ? "" : url(uri.toString()); + } + + public static String url(String rawUrl) { + if (rawUrl == null || rawUrl.isBlank()) { + return ""; + } + + try { + URI uri = URI.create(rawUrl); + StringBuilder sanitized = new StringBuilder(); + if (uri.getScheme() != null) { + sanitized.append(uri.getScheme()).append("://"); + } + if (uri.getRawAuthority() != null) { + sanitized.append(uri.getRawAuthority()); + } + if (uri.getRawPath() != null) { + sanitized.append(uri.getRawPath()); + } + String query = sanitizeQuery(uri.getRawQuery()); + if (!query.isBlank()) { + sanitized.append('?').append(query); + } + return sanitized.toString(); + } catch (RuntimeException ignored) { + return rawUrl.replaceAll("(?i)(token|code|password|secret)=([^&\\s]+)", "$1=[redacted]"); + } + } + + public static String bodyPreview(String body) { + if (body == null || body.isBlank()) { + return ""; + } + String compact = body + .replaceAll("(?i)\"(password|token|secret|preAuthToken|pre_auth_token)\"\\s*:\\s*\"[^\"]*\"", "\"$1\":\"[redacted]\"") + .replace('\n', ' ') + .replace('\r', ' ') + .replace('\t', ' ') + .replaceAll("\\s{2,}", " ") + .trim(); + return compact.length() <= 1200 ? compact : compact.substring(0, 1200) + "..."; + } + + private static String sanitizeQuery(String rawQuery) { + if (rawQuery == null || rawQuery.isBlank()) { + return ""; + } + + StringBuilder sanitized = new StringBuilder(); + for (String part : rawQuery.split("&")) { + if (part.isBlank()) { + continue; + } + int equals = part.indexOf('='); + String key = equals >= 0 ? part.substring(0, equals) : part; + String value = equals >= 0 ? part.substring(equals + 1) : ""; + if (!sanitized.isEmpty()) { + sanitized.append('&'); + } + sanitized.append(key); + if (equals >= 0) { + sanitized.append('='); + sanitized.append(isSensitiveQueryKey(key) ? "[redacted]" : value); + } + } + return sanitized.toString(); + } + + private static boolean isSensitiveQueryKey(String key) { + String normalized = key == null ? "" : key.toLowerCase(Locale.ROOT).replace("-", "_"); + return SENSITIVE_QUERY_KEYS.contains(normalized) + || normalized.contains("token") + || normalized.contains("secret") + || normalized.contains("password"); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/model/auth/SignInResponse.java b/launcher/src/main/java/net/modtale/launcher/model/auth/SignInResponse.java new file mode 100644 index 00000000..6de20817 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/model/auth/SignInResponse.java @@ -0,0 +1,7 @@ +package net.modtale.launcher.model.auth; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record SignInResponse(String status, boolean mfaRequired, String preAuthToken) { +} diff --git a/launcher/src/main/java/net/modtale/launcher/model/install/InstallOptions.java b/launcher/src/main/java/net/modtale/launcher/model/install/InstallOptions.java new file mode 100644 index 00000000..9e48af3e --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/model/install/InstallOptions.java @@ -0,0 +1,30 @@ +package net.modtale.launcher.model.install; + +import java.nio.file.Path; +import java.util.List; +import net.modtale.launcher.model.project.ProjectDependency; + +public record InstallOptions( + Path modsDirectory, + String gameVersion, + boolean includeDependencies, + boolean includeOptionalDependencies, + List selectedDependencies +) { + public InstallOptions { + selectedDependencies = selectedDependencies == null ? null : List.copyOf(selectedDependencies); + } + + public InstallOptions( + Path modsDirectory, + String gameVersion, + boolean includeDependencies, + boolean includeOptionalDependencies + ) { + this(modsDirectory, gameVersion, includeDependencies, includeOptionalDependencies, null); + } + + public boolean hasSelectedDependencies() { + return selectedDependencies != null; + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/model/install/InstallResult.java b/launcher/src/main/java/net/modtale/launcher/model/install/InstallResult.java new file mode 100644 index 00000000..84e88d3f --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/model/install/InstallResult.java @@ -0,0 +1,11 @@ +package net.modtale.launcher.model.install; + +import java.nio.file.Path; +import java.util.List; + +public record InstallResult(InstalledProject installedProject, List installedFiles, List warnings) { + public InstallResult { + installedFiles = installedFiles == null ? List.of() : List.copyOf(installedFiles); + warnings = warnings == null ? List.of() : List.copyOf(warnings); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/model/install/InstalledProject.java b/launcher/src/main/java/net/modtale/launcher/model/install/InstalledProject.java new file mode 100644 index 00000000..f41cbf0c --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/model/install/InstalledProject.java @@ -0,0 +1,110 @@ +package net.modtale.launcher.model.install; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.time.Instant; +import java.util.List; +import net.modtale.launcher.model.project.ProjectClassification; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record InstalledProject( + String projectId, + String slug, + String title, + String classification, + String installedVersion, + String installedVersionId, + String gameVersion, + Instant installedAt, + Instant updatedAt, + List files, + List dependencyProjectIds, + List externalDependencies, + String source, + String installType, + boolean modpackUnlocked, + List bundledProjects +) { + public static final String SOURCE_MODTALE = "MODTALE"; + public static final String SOURCE_LOCAL = "LOCAL"; + public static final String INSTALL_DIRECT = "DIRECT"; + public static final String INSTALL_BUNDLE = "BUNDLE"; + public static final String INSTALL_MODPACK = "MODPACK"; + + public InstalledProject { + projectId = value(projectId); + slug = value(slug); + title = value(title); + classification = value(classification); + installedVersion = value(installedVersion); + installedVersionId = value(installedVersionId); + gameVersion = value(gameVersion); + installedAt = installedAt == null ? Instant.EPOCH : installedAt; + updatedAt = updatedAt == null ? installedAt : updatedAt; + files = files == null ? List.of() : List.copyOf(files); + dependencyProjectIds = dependencyProjectIds == null ? List.of() : List.copyOf(dependencyProjectIds); + externalDependencies = externalDependencies == null ? List.of() : List.copyOf(externalDependencies); + source = value(source, SOURCE_MODTALE); + bundledProjects = bundledProjects == null ? List.of() : List.copyOf(bundledProjects); + installType = value(installType, defaultInstallType(classification, bundledProjects)); + } + + public InstalledProject( + String projectId, + String slug, + String title, + String classification, + String installedVersion, + String installedVersionId, + String gameVersion, + Instant installedAt, + Instant updatedAt, + List files, + List dependencyProjectIds, + List externalDependencies + ) { + this(projectId, slug, title, classification, installedVersion, installedVersionId, gameVersion, + installedAt, updatedAt, files, dependencyProjectIds, externalDependencies, + SOURCE_MODTALE, "", false, List.of()); + } + + public InstalledProject withModpackUnlocked(boolean unlocked) { + return new InstalledProject(projectId, slug, title, classification, installedVersion, installedVersionId, + gameVersion, installedAt, updatedAt, files, dependencyProjectIds, externalDependencies, + source, installType, unlocked, bundledProjects); + } + + public List bundledModtaleProjectIds() { + if (!bundledProjects.isEmpty()) { + return bundledProjects.stream() + .filter(InstalledProjectReference::isModtaleProject) + .map(InstalledProjectReference::projectId) + .filter(id -> id != null && !id.isBlank()) + .distinct() + .toList(); + } + return dependencyProjectIds; + } + + public boolean isModpack() { + return ProjectClassification.isModpack(classification); + } + + private static String defaultInstallType(String classification, List bundledProjects) { + if (ProjectClassification.isModpack(classification)) { + return INSTALL_MODPACK; + } + if (bundledProjects != null && !bundledProjects.isEmpty()) { + return INSTALL_BUNDLE; + } + return INSTALL_DIRECT; + } + + private static String value(String value) { + return value == null ? "" : value.trim(); + } + + private static String value(String value, String fallback) { + String normalized = value(value); + return normalized.isBlank() ? fallback : normalized; + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/model/install/InstalledProjectReference.java b/launcher/src/main/java/net/modtale/launcher/model/install/InstalledProjectReference.java new file mode 100644 index 00000000..7d661f03 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/model/install/InstalledProjectReference.java @@ -0,0 +1,126 @@ +package net.modtale.launcher.model.install; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import net.modtale.launcher.model.project.ProjectDependency; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record InstalledProjectReference( + String id, + String projectId, + String slug, + String title, + String classification, + String versionNumber, + String dependencyType, + String source, + String externalId, + String externalUrl, + String externalFileUrl, + String externalFileName, + String cachedFileUrl, + String icon, + Boolean optional, + Boolean embedded +) { + public InstalledProjectReference { + id = value(id); + projectId = value(projectId); + slug = value(slug); + title = value(title); + classification = value(classification); + versionNumber = value(versionNumber); + dependencyType = value(dependencyType); + source = value(source); + externalId = value(externalId); + externalUrl = value(externalUrl); + externalFileUrl = value(externalFileUrl); + externalFileName = value(externalFileName); + cachedFileUrl = value(cachedFileUrl); + icon = value(icon); + } + + public static InstalledProjectReference fromDependency(ProjectDependency dependency) { + if (dependency == null) { + return empty(); + } + return new InstalledProjectReference( + dependency.id(), + dependency.projectId(), + dependency.slug(), + displayTitle(dependency), + dependency.classification(), + dependency.versionNumber(), + dependency.dependencyType(), + dependency.source(), + dependency.externalId(), + dependency.externalUrl(), + dependency.externalFileUrl(), + dependency.externalFileName(), + dependency.cachedFileUrl(), + dependency.icon(), + dependency.optional(), + dependency.embedded() + ); + } + + public ProjectDependency toDependency() { + return new ProjectDependency( + id, + projectId, + title, + versionNumber, + dependencyType, + source, + externalId, + externalUrl, + externalFileUrl, + externalFileName, + cachedFileUrl, + false, + icon, + title, + classification, + slug, + optional, + embedded + ); + } + + public boolean isModtaleProject() { + return !projectId.isBlank() && (source.isBlank() || "MODTALE".equalsIgnoreCase(source)); + } + + public String routeKey() { + return !slug.isBlank() ? slug : projectId; + } + + public String displayName() { + if (!title.isBlank()) { + return title; + } + if (!externalId.isBlank()) { + return source.isBlank() ? externalId : source + ":" + externalId; + } + return !projectId.isBlank() ? projectId : value(id, "Bundled project"); + } + + private static InstalledProjectReference empty() { + return new InstalledProjectReference("", "", "", "", "", "", "", "", "", "", "", "", "", "", null, null); + } + + private static String displayTitle(ProjectDependency dependency) { + if (dependency.title() != null && !dependency.title().isBlank()) { + return dependency.title(); + } + return dependency.projectTitle(); + } + + private static String value(String value) { + return value == null ? "" : value.trim(); + } + + private static String value(String value, String fallback) { + String normalized = value(value); + return normalized.isBlank() ? fallback : normalized; + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/model/install/UpdateCandidate.java b/launcher/src/main/java/net/modtale/launcher/model/install/UpdateCandidate.java new file mode 100644 index 00000000..cf028f2d --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/model/install/UpdateCandidate.java @@ -0,0 +1,18 @@ +package net.modtale.launcher.model.install; + +import net.modtale.launcher.model.project.ProjectDetail; +import net.modtale.launcher.model.project.ProjectVersion; + +public record UpdateCandidate(InstalledProject installedProject, ProjectDetail project, ProjectVersion newestVersion) { + public String title() { + return installedProject.title(); + } + + public String currentVersion() { + return installedProject.installedVersion(); + } + + public String newestVersionNumber() { + return newestVersion == null ? "" : newestVersion.versionNumber(); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/model/notification/LauncherNotification.java b/launcher/src/main/java/net/modtale/launcher/model/notification/LauncherNotification.java new file mode 100644 index 00000000..6abe6e00 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/model/notification/LauncherNotification.java @@ -0,0 +1,57 @@ +package net.modtale.launcher.model.notification; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Map; +import java.util.Optional; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record LauncherNotification( + String id, + String title, + String message, + String link, + String iconUrl, + boolean read, + String type, + Map metadata, + LocalDateTime createdAt +) { + public LauncherNotification { + metadata = metadata == null ? Map.of() : Map.copyOf(metadata); + } + + public boolean actionable() { + return actionType().isPresent(); + } + + public Optional actionType() { + return ActionType.fromType(type); + } + + public enum ActionType { + TRANSFER_REQUEST("TRANSFER_REQUEST"), + ORG_INVITE("ORG_INVITE"), + CONTRIBUTOR_INVITE("CONTRIBUTOR_INVITE"); + + private final String type; + + ActionType(String type) { + this.type = type; + } + + public String type() { + return type; + } + + static Optional fromType(String type) { + if (type == null || type.isBlank()) { + return Optional.empty(); + } + return Arrays.stream(values()) + .filter(actionType -> actionType.type.equals(type.trim())) + .findFirst(); + } + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/model/project/DownloadUrlResponse.java b/launcher/src/main/java/net/modtale/launcher/model/project/DownloadUrlResponse.java new file mode 100644 index 00000000..15d322c8 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/model/project/DownloadUrlResponse.java @@ -0,0 +1,7 @@ +package net.modtale.launcher.model.project; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record DownloadUrlResponse(String downloadUrl, int expiresIn) { +} diff --git a/launcher/src/main/java/net/modtale/launcher/model/project/GameVersionCatalog.java b/launcher/src/main/java/net/modtale/launcher/model/project/GameVersionCatalog.java new file mode 100644 index 00000000..5045a0ec --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/model/project/GameVersionCatalog.java @@ -0,0 +1,29 @@ +package net.modtale.launcher.model.project; + +import java.util.List; + +public record GameVersionCatalog( + List releaseVersions, + List preReleaseVersions, + List allVersions, + List versions +) { + public GameVersionCatalog { + releaseVersions = releaseVersions == null ? List.of() : List.copyOf(releaseVersions); + preReleaseVersions = preReleaseVersions == null ? List.of() : List.copyOf(preReleaseVersions); + allVersions = allVersions == null ? List.of() : List.copyOf(allVersions); + versions = versions == null ? List.of() : List.copyOf(versions); + } + + public static GameVersionCatalog fromVersions(List versions) { + List safeVersions = versions == null ? List.of() : versions; + return new GameVersionCatalog(safeVersions, List.of(), safeVersions, List.of()); + } + + public record GameVersionEntry( + String version, + boolean preRelease, + String sourceUrl + ) { + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/model/project/ProjectClassification.java b/launcher/src/main/java/net/modtale/launcher/model/project/ProjectClassification.java new file mode 100644 index 00000000..fe27018a --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/model/project/ProjectClassification.java @@ -0,0 +1,80 @@ +package net.modtale.launcher.model.project; + +import java.util.Arrays; +import java.util.Optional; + +public enum ProjectClassification { + MODPACK("MODPACK", "Modpack", "Modpack", "modpack"), + PLUGIN("PLUGIN", "Plugin", "Plugin", "mod"), + SAVE("SAVE", "World", "World", "world"), + ART("ART", "Art Asset", "Art", "mod"), + DATA("DATA", "Data Asset", "Data", "mod"); + + private final String apiValue; + private final String label; + private final String compactLabel; + private final String routePrefix; + + ProjectClassification(String apiValue, String label, String compactLabel, String routePrefix) { + this.apiValue = apiValue; + this.label = label; + this.compactLabel = compactLabel; + this.routePrefix = routePrefix; + } + + public String apiValue() { + return apiValue; + } + + public String label() { + return label; + } + + public String compactLabel() { + return compactLabel; + } + + public String routePrefix() { + return routePrefix; + } + + public boolean isModpack() { + return this == MODPACK; + } + + public static Optional fromApiValue(String value) { + if (value == null || value.isBlank()) { + return Optional.empty(); + } + String normalized = value.trim(); + return Arrays.stream(values()) + .filter(classification -> classification.apiValue.equalsIgnoreCase(normalized)) + .findFirst(); + } + + public static boolean isModpack(String value) { + return fromApiValue(value).map(ProjectClassification::isModpack).orElse(false); + } + + public static String labelFor(String value) { + return fromApiValue(value) + .map(ProjectClassification::label) + .orElseGet(() -> fallback(value)); + } + + public static String compactLabelFor(String value) { + return fromApiValue(value) + .map(ProjectClassification::compactLabel) + .orElseGet(() -> fallback(value)); + } + + public static String routePrefixFor(String value) { + return fromApiValue(value) + .map(ProjectClassification::routePrefix) + .orElse(PLUGIN.routePrefix); + } + + private static String fallback(String value) { + return value == null || value.isBlank() ? "Project" : value; + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/model/project/ProjectComment.java b/launcher/src/main/java/net/modtale/launcher/model/project/ProjectComment.java new file mode 100644 index 00000000..fbd1585c --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/model/project/ProjectComment.java @@ -0,0 +1,96 @@ +package net.modtale.launcher.model.project; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record ProjectComment( + String id, + @JsonAlias("authorId") String userId, + String user, + Author author, + String content, + String date, + String updatedAt, + Integer upvoteCount, + Integer downvoteCount, + String userVote, + List upvotes, + List downvotes, + Reply developerReply +) { + public ProjectComment { + upvotes = upvotes == null ? List.of() : List.copyOf(upvotes); + downvotes = downvotes == null ? List.of() : List.copyOf(downvotes); + } + + public int score() { + if (upvoteCount != null || downvoteCount != null) { + return Math.max(0, upvoteCount == null ? 0 : upvoteCount) + - Math.max(0, downvoteCount == null ? 0 : downvoteCount); + } + return upvotes.size() - downvotes.size(); + } + + public String userVoteFor(String currentUserId) { + if ("up".equalsIgnoreCase(userVote) || "down".equalsIgnoreCase(userVote)) { + return userVote.toLowerCase(java.util.Locale.ROOT); + } + if (currentUserId != null && upvotes.contains(currentUserId)) { + return "up"; + } + if (currentUserId != null && downvotes.contains(currentUserId)) { + return "down"; + } + return null; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Reply( + @JsonAlias("authorId") String userId, + String user, + Author author, + String content, + String date, + Integer upvoteCount, + Integer downvoteCount, + String userVote, + List upvotes, + List downvotes + ) { + public Reply { + upvotes = upvotes == null ? List.of() : List.copyOf(upvotes); + downvotes = downvotes == null ? List.of() : List.copyOf(downvotes); + } + + public int score() { + if (upvoteCount != null || downvoteCount != null) { + return Math.max(0, upvoteCount == null ? 0 : upvoteCount) + - Math.max(0, downvoteCount == null ? 0 : downvoteCount); + } + return upvotes.size() - downvotes.size(); + } + + public String userVoteFor(String currentUserId) { + if ("up".equalsIgnoreCase(userVote) || "down".equalsIgnoreCase(userVote)) { + return userVote.toLowerCase(java.util.Locale.ROOT); + } + if (currentUserId != null && upvotes.contains(currentUserId)) { + return "up"; + } + if (currentUserId != null && downvotes.contains(currentUserId)) { + return "down"; + } + return null; + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Author( + String id, + String username, + String avatarUrl + ) { + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/model/project/ProjectDependency.java b/launcher/src/main/java/net/modtale/launcher/model/project/ProjectDependency.java new file mode 100644 index 00000000..6b57f879 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/model/project/ProjectDependency.java @@ -0,0 +1,74 @@ +package net.modtale.launcher.model.project; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record ProjectDependency( + String id, + String projectId, + String projectTitle, + String versionNumber, + String dependencyType, + String source, + String externalId, + String externalUrl, + String externalFileUrl, + String externalFileName, + String cachedFileUrl, + boolean hytaleProjectConfirmed, + String icon, + String title, + String classification, + String slug, + @JsonProperty("isOptional") Boolean optional, + @JsonProperty("isEmbedded") Boolean embedded +) { + public ProjectDependency( + String id, + String projectId, + String projectTitle, + String versionNumber, + String dependencyType, + String source, + String externalId, + String externalUrl, + String externalFileUrl, + String externalFileName, + String cachedFileUrl, + boolean hytaleProjectConfirmed + ) { + this(id, projectId, projectTitle, versionNumber, dependencyType, source, externalId, externalUrl, + externalFileUrl, externalFileName, cachedFileUrl, hytaleProjectConfirmed, + null, null, null, null, null, null); + } + + public boolean isOptional() { + return Boolean.TRUE.equals(optional) || DependencyType.OPTIONAL.matches(dependencyType); + } + + public boolean isEmbedded() { + return Boolean.TRUE.equals(embedded) || DependencyType.EMBEDDED.matches(dependencyType); + } + + public boolean isExternal() { + return source != null && !DependencySource.MODTALE.matches(source); + } + + private enum DependencyType { + OPTIONAL, + EMBEDDED; + + boolean matches(String value) { + return value != null && name().equalsIgnoreCase(value.trim()); + } + } + + private enum DependencySource { + MODTALE; + + boolean matches(String value) { + return value != null && name().equalsIgnoreCase(value.trim()); + } + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/model/project/ProjectDetail.java b/launcher/src/main/java/net/modtale/launcher/model/project/ProjectDetail.java new file mode 100644 index 00000000..d25b1ad5 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/model/project/ProjectDetail.java @@ -0,0 +1,67 @@ +package net.modtale.launcher.model.project; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.List; +import java.util.Map; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record ProjectDetail( + String id, + String slug, + String title, + String about, + String description, + String authorId, + String author, + String imageUrl, + String bannerUrl, + String classification, + int downloadCount, + int favoriteCount, + String updatedAt, + String license, + String repositoryUrl, + Map links, + List tags, + List galleryImages, + Map galleryImageCaptions, + Boolean allowComments, + boolean hmWikiEnabled, + String hmWikiSlug, + List versions +) { + public ProjectDetail { + links = links == null ? Map.of() : Map.copyOf(links); + tags = tags == null ? List.of() : List.copyOf(tags); + galleryImages = galleryImages == null ? List.of() : List.copyOf(galleryImages); + galleryImageCaptions = galleryImageCaptions == null ? Map.of() : Map.copyOf(galleryImageCaptions); + versions = versions == null ? List.of() : List.copyOf(versions); + } + + public ProjectDetail( + String id, + String slug, + String title, + String description, + String author, + String classification, + String updatedAt, + String license, + String repositoryUrl, + List tags, + List versions + ) { + this(id, slug, title, null, description, null, author, null, null, classification, 0, 0, updatedAt, + license, repositoryUrl, Map.of(), tags, List.of(), Map.of(), null, false, null, versions); + } + + public String routeKey() { + return slug != null && !slug.isBlank() ? slug : id; + } + + public ProjectDetail withVersions(List nextVersions) { + return new ProjectDetail(id, slug, title, about, description, authorId, author, imageUrl, bannerUrl, + classification, downloadCount, favoriteCount, updatedAt, license, repositoryUrl, links, tags, + galleryImages, galleryImageCaptions, allowComments, hmWikiEnabled, hmWikiSlug, nextVersions); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/model/project/ProjectGallery.java b/launcher/src/main/java/net/modtale/launcher/model/project/ProjectGallery.java new file mode 100644 index 00000000..1fbf830b --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/model/project/ProjectGallery.java @@ -0,0 +1,16 @@ +package net.modtale.launcher.model.project; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.List; +import java.util.Map; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record ProjectGallery( + List galleryImages, + Map galleryImageCaptions +) { + public ProjectGallery { + galleryImages = galleryImages == null ? List.of() : List.copyOf(galleryImages); + galleryImageCaptions = galleryImageCaptions == null ? Map.of() : Map.copyOf(galleryImageCaptions); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/model/project/ProjectMeta.java b/launcher/src/main/java/net/modtale/launcher/model/project/ProjectMeta.java new file mode 100644 index 00000000..f79f6456 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/model/project/ProjectMeta.java @@ -0,0 +1,16 @@ +package net.modtale.launcher.model.project; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record ProjectMeta( + String title, + String description, + String icon, + String author, + String classification, + int downloads, + String repositoryUrl, + String slug +) { +} diff --git a/launcher/src/main/java/net/modtale/launcher/model/project/ProjectPage.java b/launcher/src/main/java/net/modtale/launcher/model/project/ProjectPage.java new file mode 100644 index 00000000..f783e135 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/model/project/ProjectPage.java @@ -0,0 +1,17 @@ +package net.modtale.launcher.model.project; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record ProjectPage( + List content, + int totalPages, + long totalElements, + int number, + boolean last +) { + public ProjectPage { + content = content == null ? List.of() : List.copyOf(content); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/model/project/ProjectSummary.java b/launcher/src/main/java/net/modtale/launcher/model/project/ProjectSummary.java new file mode 100644 index 00000000..154e599d --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/model/project/ProjectSummary.java @@ -0,0 +1,39 @@ +package net.modtale.launcher.model.project; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record ProjectSummary( + String id, + String slug, + String title, + String description, + String authorId, + String author, + String imageUrl, + String bannerUrl, + String classification, + int downloadCount, + int favoriteCount, + String updatedAt, + List versions +) { + public ProjectSummary { + versions = versions == null ? List.of() : List.copyOf(versions); + } + + public String routeKey() { + return slug != null && !slug.isBlank() ? slug : id; + } + + public ProjectSummary withFavoriteCount(int nextFavoriteCount) { + return new ProjectSummary(id, slug, title, description, authorId, author, imageUrl, bannerUrl, classification, + downloadCount, Math.max(0, nextFavoriteCount), updatedAt, versions); + } + + @Override + public String toString() { + return title == null || title.isBlank() ? routeKey() : title; + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/model/project/ProjectVersion.java b/launcher/src/main/java/net/modtale/launcher/model/project/ProjectVersion.java new file mode 100644 index 00000000..d36c56c3 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/model/project/ProjectVersion.java @@ -0,0 +1,42 @@ +package net.modtale.launcher.model.project; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record ProjectVersion( + String id, + String versionNumber, + List gameVersions, + String fileUrl, + int downloadCount, + String releaseDate, + String changelog, + List dependencies, + String channel, + List incompatibleProjectIds +) { + public ProjectVersion { + gameVersions = gameVersions == null ? List.of() : List.copyOf(gameVersions); + dependencies = dependencies == null ? List.of() : List.copyOf(dependencies); + incompatibleProjectIds = incompatibleProjectIds == null ? List.of() : List.copyOf(incompatibleProjectIds); + } + + public ProjectVersion( + String id, + String versionNumber, + List gameVersions, + String fileUrl, + int downloadCount, + String releaseDate, + String changelog, + List dependencies, + String channel + ) { + this(id, versionNumber, gameVersions, fileUrl, downloadCount, releaseDate, changelog, dependencies, channel, List.of()); + } + + public boolean supportsGameVersion(String gameVersion) { + return gameVersion == null || gameVersion.isBlank() || gameVersions.contains(gameVersion); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/model/project/ProjectVersionChangelog.java b/launcher/src/main/java/net/modtale/launcher/model/project/ProjectVersionChangelog.java new file mode 100644 index 00000000..27cfc294 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/model/project/ProjectVersionChangelog.java @@ -0,0 +1,11 @@ +package net.modtale.launcher.model.project; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record ProjectVersionChangelog( + String id, + String versionNumber, + String changelog +) { +} diff --git a/launcher/src/main/java/net/modtale/launcher/model/project/VersionDependenciesView.java b/launcher/src/main/java/net/modtale/launcher/model/project/VersionDependenciesView.java new file mode 100644 index 00000000..93ccc7f2 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/model/project/VersionDependenciesView.java @@ -0,0 +1,11 @@ +package net.modtale.launcher.model.project; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record VersionDependenciesView(List dependencies) { + public VersionDependenciesView { + dependencies = dependencies == null ? List.of() : List.copyOf(dependencies); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/model/sync/LauncherSettingsSnapshot.java b/launcher/src/main/java/net/modtale/launcher/model/sync/LauncherSettingsSnapshot.java new file mode 100644 index 00000000..110efd94 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/model/sync/LauncherSettingsSnapshot.java @@ -0,0 +1,366 @@ +package net.modtale.launcher.model.sync; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.List; +import java.util.StringJoiner; +import net.modtale.launcher.model.install.InstalledProject; +import net.modtale.launcher.model.install.InstalledProjectReference; +import net.modtale.launcher.settings.LauncherSettings; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class LauncherSettingsSnapshot { + + private int schemaVersion = 1; + private String settingsHash = ""; + private String updatedAt = ""; + private Preferences preferences = new Preferences(); + private List installedProjects = new ArrayList<>(); + + public static LauncherSettingsSnapshot fromSettings(LauncherSettings settings) { + LauncherSettingsSnapshot snapshot = new LauncherSettingsSnapshot(); + snapshot.setPreferences(Preferences.fromSettings(settings)); + snapshot.setInstalledProjects(settings == null ? List.of() : settings.getInstalledProjects().stream() + .filter(LauncherSettingsSnapshot::isSyncedProject) + .map(InstalledProjectSnapshot::fromInstalledProject) + .toList()); + snapshot.refreshHash(); + return snapshot; + } + + public void applyPreferencesTo(LauncherSettings settings) { + if (settings == null) { + return; + } + Preferences source = preferences == null ? new Preferences() : preferences; + settings.setHytaleModsPath(source.hytaleModsPath); + settings.setHytaleGamePath(source.hytaleGamePath); + settings.setHytaleUserDataPath(source.hytaleUserDataPath); + settings.setHytaleJavaPath(source.hytaleJavaPath); + settings.setHytaleBranch(source.hytaleBranch); + settings.setHytaleBuild(source.hytaleBuild); + settings.setGameVersion(source.gameVersion); + settings.setIncludeDependencies(source.includeDependencies); + settings.setIncludeOptionalDependencies(source.includeOptionalDependencies); + settings.setAutoCheckUpdates(source.autoCheckUpdates); + settings.setLauncherAutoUpdates(source.launcherAutoUpdates); + } + + public boolean hasSyncedContent() { + return !installedProjects().isEmpty() + || (settingsHash != null && !settingsHash.isBlank()) + || (updatedAt != null && !updatedAt.isBlank()); + } + + public String effectiveHash() { + String hash = settingsHash == null ? "" : settingsHash.trim(); + if (!hash.isBlank()) { + return hash; + } + return computeHash(); + } + + public String computeHash() { + return hashPayload(canonicalPayload()); + } + + public String installedProjectsHash() { + return hashPayload(canonicalInstalledProjectsPayload()); + } + + public LauncherSettingsSnapshot preferencesOnly() { + LauncherSettingsSnapshot copy = new LauncherSettingsSnapshot(); + copy.setSchemaVersion(schemaVersion); + copy.setSettingsHash(computeHash()); + copy.setUpdatedAt(updatedAt); + copy.setPreferences(preferences); + copy.setInstalledProjects(List.of()); + return copy; + } + + private static String hashPayload(String payload) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashed = digest.digest(payload.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hashed); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException("SHA-256 is not available.", ex); + } + } + + public void refreshHash() { + settingsHash = computeHash(); + } + + private String canonicalPayload() { + Preferences prefs = preferences == null ? new Preferences() : preferences; + StringJoiner payload = new StringJoiner("\n"); + payload.add("schema=" + schemaVersion); + payload.add("modsPath=" + value(prefs.hytaleModsPath)); + payload.add("gamePath=" + value(prefs.hytaleGamePath)); + payload.add("userDataPath=" + value(prefs.hytaleUserDataPath)); + payload.add("javaPath=" + value(prefs.hytaleJavaPath)); + payload.add("branch=" + value(prefs.hytaleBranch)); + payload.add("build=" + Math.max(0, prefs.hytaleBuild)); + payload.add("gameVersion=" + value(prefs.gameVersion)); + payload.add("includeDependencies=" + prefs.includeDependencies); + payload.add("includeOptionalDependencies=" + prefs.includeOptionalDependencies); + payload.add("autoCheckUpdates=" + prefs.autoCheckUpdates); + payload.add("launcherAutoUpdates=" + prefs.launcherAutoUpdates); + installedProjects().stream() + .filter(project -> !value(project.projectId).isBlank()) + .sorted(Comparator + .comparing((InstalledProjectSnapshot project) -> value(project.projectId)) + .thenComparing(project -> value(project.installedVersionId)) + .thenComparing(project -> value(project.installedVersion))) + .forEach(project -> payload.add(project.canonicalPayload())); + return payload.toString(); + } + + private String canonicalInstalledProjectsPayload() { + StringJoiner payload = new StringJoiner("\n"); + installedProjects().stream() + .filter(project -> !value(project.projectId).isBlank()) + .sorted(Comparator + .comparing((InstalledProjectSnapshot project) -> value(project.projectId)) + .thenComparing(project -> value(project.installedVersionId)) + .thenComparing(project -> value(project.installedVersion))) + .forEach(project -> payload.add(project.canonicalPayload())); + return payload.toString(); + } + + public int getSchemaVersion() { + return schemaVersion; + } + + public void setSchemaVersion(int schemaVersion) { + this.schemaVersion = Math.max(1, schemaVersion); + } + + public String getSettingsHash() { + return settingsHash; + } + + public void setSettingsHash(String settingsHash) { + this.settingsHash = settingsHash == null ? "" : settingsHash.trim(); + } + + public String getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(String updatedAt) { + this.updatedAt = updatedAt == null ? "" : updatedAt.trim(); + } + + public Preferences getPreferences() { + return preferences; + } + + public void setPreferences(Preferences preferences) { + this.preferences = preferences == null ? new Preferences() : preferences; + } + + public List getInstalledProjects() { + return installedProjects; + } + + public void setInstalledProjects(List installedProjects) { + this.installedProjects = installedProjects == null ? new ArrayList<>() : new ArrayList<>(installedProjects); + } + + public List installedProjects() { + return installedProjects == null ? List.of() : List.copyOf(installedProjects); + } + + private static String value(String value) { + return value == null ? "" : value.trim(); + } + + private static boolean isSyncedProject(InstalledProject project) { + if (project == null) { + return false; + } + String source = value(project.source()); + return source.isBlank() || InstalledProject.SOURCE_MODTALE.equalsIgnoreCase(source); + } + + private static String listValue(List values) { + if (values == null || values.isEmpty()) { + return ""; + } + return values.stream() + .filter(value -> value != null && !value.isBlank()) + .map(String::trim) + .distinct() + .sorted() + .reduce((left, right) -> left + "," + right) + .orElse(""); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Preferences { + private String hytaleModsPath = ""; + private String hytaleGamePath = ""; + private String hytaleUserDataPath = ""; + private String hytaleJavaPath = ""; + private String hytaleBranch = "release"; + private int hytaleBuild; + private String gameVersion = ""; + private boolean includeDependencies = true; + private boolean includeOptionalDependencies; + private boolean autoCheckUpdates = true; + private boolean launcherAutoUpdates; + + public static Preferences fromSettings(LauncherSettings settings) { + Preferences preferences = new Preferences(); + if (settings == null) { + return preferences; + } + preferences.setHytaleModsPath(settings.getHytaleModsPath()); + preferences.setHytaleGamePath(settings.getHytaleGamePath()); + preferences.setHytaleUserDataPath(settings.getHytaleUserDataPath()); + preferences.setHytaleJavaPath(settings.getHytaleJavaPath()); + preferences.setHytaleBranch(settings.getHytaleBranch()); + preferences.setHytaleBuild(settings.getHytaleBuild()); + preferences.setGameVersion(settings.getGameVersion()); + preferences.setIncludeDependencies(settings.isIncludeDependencies()); + preferences.setIncludeOptionalDependencies(settings.isIncludeOptionalDependencies()); + preferences.setAutoCheckUpdates(settings.isAutoCheckUpdates()); + preferences.setLauncherAutoUpdates(settings.isLauncherAutoUpdates()); + return preferences; + } + + public String getHytaleModsPath() { return hytaleModsPath; } + public void setHytaleModsPath(String hytaleModsPath) { this.hytaleModsPath = value(hytaleModsPath); } + public String getHytaleGamePath() { return hytaleGamePath; } + public void setHytaleGamePath(String hytaleGamePath) { this.hytaleGamePath = value(hytaleGamePath); } + public String getHytaleUserDataPath() { return hytaleUserDataPath; } + public void setHytaleUserDataPath(String hytaleUserDataPath) { this.hytaleUserDataPath = value(hytaleUserDataPath); } + public String getHytaleJavaPath() { return hytaleJavaPath; } + public void setHytaleJavaPath(String hytaleJavaPath) { this.hytaleJavaPath = value(hytaleJavaPath); } + public String getHytaleBranch() { return hytaleBranch; } + public void setHytaleBranch(String hytaleBranch) { this.hytaleBranch = value(hytaleBranch); } + public int getHytaleBuild() { return hytaleBuild; } + public void setHytaleBuild(int hytaleBuild) { this.hytaleBuild = Math.max(0, hytaleBuild); } + public String getGameVersion() { return gameVersion; } + public void setGameVersion(String gameVersion) { this.gameVersion = value(gameVersion); } + public boolean isIncludeDependencies() { return includeDependencies; } + public void setIncludeDependencies(boolean includeDependencies) { this.includeDependencies = includeDependencies; } + public boolean isIncludeOptionalDependencies() { return includeOptionalDependencies; } + public void setIncludeOptionalDependencies(boolean includeOptionalDependencies) { this.includeOptionalDependencies = includeOptionalDependencies; } + public boolean isAutoCheckUpdates() { return autoCheckUpdates; } + public void setAutoCheckUpdates(boolean autoCheckUpdates) { this.autoCheckUpdates = autoCheckUpdates; } + public boolean isLauncherAutoUpdates() { return launcherAutoUpdates; } + public void setLauncherAutoUpdates(boolean launcherAutoUpdates) { this.launcherAutoUpdates = launcherAutoUpdates; } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static class InstalledProjectSnapshot { + private String projectId = ""; + private String slug = ""; + private String title = ""; + private String classification = ""; + private String installedVersion = ""; + private String installedVersionId = ""; + private String gameVersion = ""; + private String source = InstalledProject.SOURCE_MODTALE; + private String installType = InstalledProject.INSTALL_DIRECT; + private boolean modpackUnlocked; + private List dependencyProjectIds = new ArrayList<>(); + private List externalDependencies = new ArrayList<>(); + private List bundledProjects = new ArrayList<>(); + + public static InstalledProjectSnapshot fromInstalledProject(InstalledProject project) { + InstalledProjectSnapshot snapshot = new InstalledProjectSnapshot(); + if (project == null) { + return snapshot; + } + snapshot.setProjectId(project.projectId()); + snapshot.setSlug(project.slug()); + snapshot.setTitle(project.title()); + snapshot.setClassification(project.classification()); + snapshot.setInstalledVersion(project.installedVersion()); + snapshot.setInstalledVersionId(project.installedVersionId()); + snapshot.setGameVersion(project.gameVersion()); + snapshot.setSource(project.source()); + snapshot.setInstallType(project.installType()); + snapshot.setModpackUnlocked(project.modpackUnlocked()); + snapshot.setDependencyProjectIds(project.dependencyProjectIds()); + snapshot.setExternalDependencies(project.externalDependencies()); + snapshot.setBundledProjects(project.bundledProjects()); + return snapshot; + } + + private String canonicalPayload() { + return String.join("|", + "project=" + value(projectId), + "slug=" + value(slug), + "title=" + value(title), + "classification=" + value(classification), + "version=" + value(installedVersion), + "versionId=" + value(installedVersionId), + "gameVersion=" + value(gameVersion), + "source=" + value(source), + "installType=" + value(installType), + "unlocked=" + modpackUnlocked, + "deps=" + listValue(dependencyProjectIds), + "external=" + listValue(externalDependencies), + "bundled=" + bundledValue(bundledProjects)); + } + + public String getProjectId() { return projectId; } + public void setProjectId(String projectId) { this.projectId = value(projectId); } + public String getSlug() { return slug; } + public void setSlug(String slug) { this.slug = value(slug); } + public String getTitle() { return title; } + public void setTitle(String title) { this.title = value(title); } + public String getClassification() { return classification; } + public void setClassification(String classification) { this.classification = value(classification); } + public String getInstalledVersion() { return installedVersion; } + public void setInstalledVersion(String installedVersion) { this.installedVersion = value(installedVersion); } + public String getInstalledVersionId() { return installedVersionId; } + public void setInstalledVersionId(String installedVersionId) { this.installedVersionId = value(installedVersionId); } + public String getGameVersion() { return gameVersion; } + public void setGameVersion(String gameVersion) { this.gameVersion = value(gameVersion); } + public String getSource() { return source; } + public void setSource(String source) { this.source = value(source).isBlank() ? InstalledProject.SOURCE_MODTALE : value(source); } + public String getInstallType() { return installType; } + public void setInstallType(String installType) { this.installType = value(installType).isBlank() ? InstalledProject.INSTALL_DIRECT : value(installType); } + public boolean isModpackUnlocked() { return modpackUnlocked; } + public void setModpackUnlocked(boolean modpackUnlocked) { this.modpackUnlocked = modpackUnlocked; } + public List getDependencyProjectIds() { return dependencyProjectIds; } + public void setDependencyProjectIds(List dependencyProjectIds) { + this.dependencyProjectIds = dependencyProjectIds == null ? new ArrayList<>() : new ArrayList<>(dependencyProjectIds); + } + public List getExternalDependencies() { return externalDependencies; } + public void setExternalDependencies(List externalDependencies) { + this.externalDependencies = externalDependencies == null ? new ArrayList<>() : new ArrayList<>(externalDependencies); + } + public List getBundledProjects() { return bundledProjects; } + public void setBundledProjects(List bundledProjects) { + this.bundledProjects = bundledProjects == null ? new ArrayList<>() : new ArrayList<>(bundledProjects); + } + } + + private static String bundledValue(List projects) { + if (projects == null || projects.isEmpty()) { + return ""; + } + return projects.stream() + .map(project -> String.join(":", + value(project.projectId()), + value(project.slug()), + value(project.versionNumber()), + value(project.source()), + value(project.externalId()))) + .sorted() + .reduce((left, right) -> left + "," + right) + .orElse(""); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/model/user/CreatorProfile.java b/launcher/src/main/java/net/modtale/launcher/model/user/CreatorProfile.java new file mode 100644 index 00000000..59d30a3f --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/model/user/CreatorProfile.java @@ -0,0 +1,76 @@ +package net.modtale.launcher.model.user; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record CreatorProfile( + @JsonAlias("_id") String id, + String username, + String avatarUrl, + String bannerUrl, + String bio, + String createdAt, + String tier, + List roles, + String accountType, + List badges, + List followerIds, + List followingIds, + List connectedAccounts, + List organizationMembers, + List organizationRoles +) { + public CreatorProfile { + roles = roles == null ? List.of() : List.copyOf(roles); + badges = badges == null ? List.of() : List.copyOf(badges); + followerIds = followerIds == null ? List.of() : List.copyOf(followerIds); + followingIds = followingIds == null ? List.of() : List.copyOf(followingIds); + connectedAccounts = connectedAccounts == null ? List.of() : List.copyOf(connectedAccounts); + organizationMembers = organizationMembers == null ? List.of() : List.copyOf(organizationMembers); + organizationRoles = organizationRoles == null ? List.of() : List.copyOf(organizationRoles); + } + + public boolean organization() { + return "ORGANIZATION".equalsIgnoreCase(accountType); + } + + @Override + public String toString() { + return username == null || username.isBlank() ? id : username; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record ConnectedAccount( + String provider, + String providerId, + String username, + String profileUrl, + Boolean visible + ) { + public boolean isVisible() { + return Boolean.TRUE.equals(visible); + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record OrganizationMember( + String userId, + String roleId + ) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record OrganizationRole( + String id, + String name, + String color, + List permissions, + Boolean owner + ) { + public OrganizationRole { + permissions = permissions == null ? List.of() : List.copyOf(permissions); + } + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/model/user/CurrentUser.java b/launcher/src/main/java/net/modtale/launcher/model/user/CurrentUser.java new file mode 100644 index 00000000..260f540b --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/model/user/CurrentUser.java @@ -0,0 +1,84 @@ +package net.modtale.launcher.model.user; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record CurrentUser( + String id, + String username, + String avatarUrl, + String email, + Boolean emailVerified, + String tier, + List likedProjectIds, + List followingIds, + NotificationPreferences notificationPreferences +) { + public CurrentUser { + likedProjectIds = likedProjectIds == null ? List.of() : List.copyOf(likedProjectIds); + followingIds = followingIds == null ? List.of() : List.copyOf(followingIds); + notificationPreferences = notificationPreferences == null + ? NotificationPreferences.defaults() + : notificationPreferences; + } + + public boolean likesProject(String projectId) { + return projectId != null && likedProjectIds.contains(projectId); + } + + public boolean followsUser(String userId) { + return userId != null && followingIds.contains(userId); + } + + @Override + public String toString() { + return username == null || username.isBlank() ? id : username; + } + + public record NotificationPreferences( + String projectUpdates, + String creatorUploads, + String newComments, + String newFollowers, + String dependencyUpdates + ) { + public NotificationPreferences { + projectUpdates = level(projectUpdates); + creatorUploads = level(creatorUploads); + newComments = level(newComments); + newFollowers = level(newFollowers); + dependencyUpdates = level(dependencyUpdates); + } + + public static NotificationPreferences defaults() { + String enabled = NotificationLevel.ON.apiValue(); + return new NotificationPreferences(enabled, enabled, enabled, enabled, enabled); + } + + private static String level(String value) { + return NotificationLevel.fromApiValue(value).apiValue(); + } + } + + public enum NotificationLevel { + ON, + OFF; + + public String apiValue() { + return name(); + } + + public static NotificationLevel fromApiValue(String value) { + if (value == null || value.isBlank()) { + return ON; + } + for (NotificationLevel level : values()) { + if (level.name().equalsIgnoreCase(value.trim())) { + return level; + } + } + return ON; + } + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/model/user/UserSummary.java b/launcher/src/main/java/net/modtale/launcher/model/user/UserSummary.java new file mode 100644 index 00000000..60a648ba --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/model/user/UserSummary.java @@ -0,0 +1,28 @@ +package net.modtale.launcher.model.user; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record UserSummary( + String id, + String username, + String avatarUrl, + String bannerUrl, + String bio, + String createdAt, + String tier, + List roles, + String accountType, + List badges +) { + public UserSummary { + roles = roles == null ? List.of() : List.copyOf(roles); + badges = badges == null ? List.of() : List.copyOf(badges); + } + + @Override + public String toString() { + return username == null || username.isBlank() ? id : username; + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/model/worldlist/CreateWorldModListRequest.java b/launcher/src/main/java/net/modtale/launcher/model/worldlist/CreateWorldModListRequest.java new file mode 100644 index 00000000..fef2bb99 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/model/worldlist/CreateWorldModListRequest.java @@ -0,0 +1,47 @@ +package net.modtale.launcher.model.worldlist; + +import java.util.List; + +public record CreateWorldModListRequest( + String title, + String worldName, + String gameVersion, + List mods +) { + public CreateWorldModListRequest { + title = value(title); + worldName = value(worldName); + gameVersion = value(gameVersion); + mods = mods == null ? List.of() : List.copyOf(mods); + } + + public record Item( + String modId, + String projectId, + String slug, + String title, + String versionNumber, + String classification, + String source, + String externalId, + String externalUrl, + String icon + ) { + public Item { + modId = value(modId); + projectId = value(projectId); + slug = value(slug); + title = value(title); + versionNumber = value(versionNumber); + classification = value(classification); + source = value(source); + externalId = value(externalId); + externalUrl = value(externalUrl); + icon = value(icon); + } + } + + private static String value(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/model/worldlist/WorldModList.java b/launcher/src/main/java/net/modtale/launcher/model/worldlist/WorldModList.java new file mode 100644 index 00000000..3a828f8d --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/model/worldlist/WorldModList.java @@ -0,0 +1,41 @@ +package net.modtale.launcher.model.worldlist; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.time.Instant; +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record WorldModList( + String id, + String title, + String worldName, + String gameVersion, + String ownerUsername, + Instant createdAt, + Instant lastViewedAt, + Instant expiresAt, + int viewCount, + int downloadCount, + int modCount, + int downloadableCount, + String shareUrl, + String downloadUrl, + String launcherInstallUrl, + List mods +) { + public WorldModList { + id = value(id); + title = value(title); + worldName = value(worldName); + gameVersion = value(gameVersion); + ownerUsername = value(ownerUsername); + shareUrl = value(shareUrl); + downloadUrl = value(downloadUrl); + launcherInstallUrl = value(launcherInstallUrl); + mods = mods == null ? List.of() : List.copyOf(mods); + } + + private static String value(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/model/worldlist/WorldModListInstallResult.java b/launcher/src/main/java/net/modtale/launcher/model/worldlist/WorldModListInstallResult.java new file mode 100644 index 00000000..b650a7d4 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/model/worldlist/WorldModListInstallResult.java @@ -0,0 +1,13 @@ +package net.modtale.launcher.model.worldlist; + +import java.nio.file.Path; +import java.util.List; + +public record WorldModListInstallResult( + WorldModList list, + List installedFiles +) { + public WorldModListInstallResult { + installedFiles = installedFiles == null ? List.of() : List.copyOf(installedFiles); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/model/worldlist/WorldModListItem.java b/launcher/src/main/java/net/modtale/launcher/model/worldlist/WorldModListItem.java new file mode 100644 index 00000000..4321b30f --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/model/worldlist/WorldModListItem.java @@ -0,0 +1,39 @@ +package net.modtale.launcher.model.worldlist; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record WorldModListItem( + String id, + String modId, + String projectId, + String slug, + String title, + String versionNumber, + String classification, + String source, + String externalId, + String externalUrl, + String icon, + boolean downloadable, + String unavailableReason +) { + public WorldModListItem { + id = value(id); + modId = value(modId); + projectId = value(projectId); + slug = value(slug); + title = value(title); + versionNumber = value(versionNumber); + classification = value(classification); + source = value(source); + externalId = value(externalId); + externalUrl = value(externalUrl); + icon = value(icon); + unavailableReason = value(unavailableReason); + } + + private static String value(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/protocol/LauncherProtocolRequest.java b/launcher/src/main/java/net/modtale/launcher/protocol/LauncherProtocolRequest.java new file mode 100644 index 00000000..b51be16d --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/protocol/LauncherProtocolRequest.java @@ -0,0 +1,111 @@ +package net.modtale.launcher.protocol; + +import java.net.URI; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import javafx.application.Application; + +public record LauncherProtocolRequest(String installListId) { + + private static final LauncherProtocolRequest EMPTY = new LauncherProtocolRequest(""); + + public LauncherProtocolRequest { + installListId = value(installListId); + } + + public boolean hasInstallList() { + return !installListId.isBlank(); + } + + public static LauncherProtocolRequest from(Application.Parameters parameters) { + if (parameters == null) { + return EMPTY; + } + + String namedListId = firstValue(parameters.getNamed(), "listId", "installList", "install-list", "list"); + if (!namedListId.isBlank()) { + return new LauncherProtocolRequest(namedListId); + } + + LauncherProtocolRequest rawRequest = firstProtocolUrl(parameters.getRaw()); + if (rawRequest.hasInstallList()) { + return rawRequest; + } + + return firstProtocolUrl(parameters.getUnnamed()); + } + + private static LauncherProtocolRequest firstProtocolUrl(List values) { + if (values == null) { + return EMPTY; + } + for (String value : values) { + LauncherProtocolRequest request = fromProtocolUrl(value); + if (request.hasInstallList()) { + return request; + } + } + return EMPTY; + } + + private static LauncherProtocolRequest fromProtocolUrl(String value) { + if (value == null || !value.trim().startsWith("modtale:")) { + return EMPTY; + } + try { + URI uri = URI.create(value.trim()); + if (!"modtale".equalsIgnoreCase(uri.getScheme())) { + return EMPTY; + } + String action = value(uri.getHost()); + if (!"install-list".equalsIgnoreCase(action)) { + return EMPTY; + } + Map params = queryParams(uri.getRawQuery()); + return new LauncherProtocolRequest(params.get("listId")); + } catch (IllegalArgumentException ignored) { + return EMPTY; + } + } + + private static Map queryParams(String rawQuery) { + Map params = new LinkedHashMap<>(); + if (rawQuery == null || rawQuery.isBlank()) { + return params; + } + for (String part : rawQuery.split("&")) { + if (part.isBlank()) { + continue; + } + int separator = part.indexOf('='); + String key = separator >= 0 ? part.substring(0, separator) : part; + String rawValue = separator >= 0 ? part.substring(separator + 1) : ""; + params.put(decode(key), decode(rawValue)); + } + return params; + } + + private static String firstValue(Map values, String... keys) { + if (values == null || values.isEmpty()) { + return ""; + } + for (String key : keys) { + String value = values.get(key); + if (value != null && !value.isBlank()) { + return value.trim(); + } + } + return ""; + } + + private static String decode(String value) { + return URLDecoder.decode(value == null ? "" : value, StandardCharsets.UTF_8); + } + + private static String value(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/settings/HytalePathDetector.java b/launcher/src/main/java/net/modtale/launcher/settings/HytalePathDetector.java new file mode 100644 index 00000000..d3a53c2c --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/settings/HytalePathDetector.java @@ -0,0 +1,169 @@ +package net.modtale.launcher.settings; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.stream.Stream; + +public final class HytalePathDetector { + + private HytalePathDetector() { + } + + public static Path defaultModsDirectory() { + return detectExistingModsDirectory().orElseGet(() -> defaultUserDataDirectory().resolve("Mods")); + } + + public static Path defaultSavesDirectory() { + return detectExistingSavesDirectory().orElseGet(() -> defaultUserDataDirectory().resolve("Saves")); + } + + public static Path defaultUserDataDirectory() { + return detectExistingUserDataDirectory().orElseGet(() -> userDataCandidates().getFirst()); + } + + public static Path defaultGameDirectory() { + return detectExistingGameDirectory().orElseGet(() -> gameCandidates().getFirst()); + } + + public static Path defaultJavaExecutable() { + return detectExistingJavaExecutable().orElseGet(HytalePathDetector::currentJavaExecutable); + } + + public static Optional detectExistingJavaExecutable() { + return javaExecutableCandidates().stream().filter(Files::isRegularFile).findFirst(); + } + + public static Optional detectExistingGameDirectory() { + return gameCandidates().stream().filter(HytalePathDetector::containsClientExecutable).findFirst(); + } + + public static List candidates() { + return userDataCandidates().stream() + .flatMap(path -> java.util.stream.Stream.of(path.resolve("Mods"), path.resolve("mods"), path.resolve(Path.of("userdata", "mods")))) + .distinct() + .toList(); + } + + public static List userDataCandidates() { + return hytaleDataDirectoryCandidates().stream() + .flatMap(path -> Stream.of(path.resolve("UserData"), path.resolve("userdata"), path)) + .distinct() + .toList(); + } + + public static List gameCandidates() { + List paths = new ArrayList<>(); + hytaleDataDirectoryCandidates().forEach(root -> { + paths.add(root.resolve(Path.of("install", "release", "package", "game", "latest"))); + paths.add(root.resolve(Path.of("install", "pre-release", "package", "game", "latest"))); + paths.add(root.resolve(Path.of("install", "prerelease", "package", "game", "latest"))); + paths.add(root.resolve(Path.of("install", "release", "package", "game"))); + paths.add(root.resolve(Path.of("install", "pre-release", "package", "game"))); + paths.add(root.resolve(Path.of("install", "prerelease", "package", "game"))); + paths.add(root); + }); + + String home = System.getProperty("user.home", "."); + paths.add(Path.of(home, "Hytale", "Game")); + paths.add(Path.of(home, "Hytale")); + paths.add(Path.of(home, ".modtale", "launcher", "hytale")); + return paths.stream().distinct().toList(); + } + + public static List javaExecutableCandidates() { + String executable = javaExecutableName(); + List paths = new ArrayList<>(); + hytaleDataDirectoryCandidates().forEach(root -> { + paths.add(root.resolve(Path.of("install", "release", "package", "jre", "latest", "bin", executable))); + paths.add(root.resolve(Path.of("install", "pre-release", "package", "jre", "latest", "bin", executable))); + paths.add(root.resolve(Path.of("install", "prerelease", "package", "jre", "latest", "bin", executable))); + paths.add(root.resolve(Path.of("jre", "latest", "bin", executable))); + }); + paths.add(currentJavaExecutable()); + return paths.stream().distinct().toList(); + } + + public static boolean isCurrentJavaExecutable(Path path) { + if (path == null) { + return false; + } + return path.toAbsolutePath().normalize().equals(currentJavaExecutable().toAbsolutePath().normalize()); + } + + public static List hytaleDataDirectoryCandidates() { + String home = System.getProperty("user.home", "."); + String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT); + List paths = new ArrayList<>(); + + if (os.contains("win")) { + addEnvPath(paths, "APPDATA", "Hytale"); + paths.add(Path.of(home, "AppData", "Roaming", "Hytale")); + addEnvPath(paths, "LOCALAPPDATA", "Hytale"); + addEnvPath(paths, "ProgramFiles", "Hypixel Studios", "Hytale Launcher"); + paths.add(Path.of("C:", "Program Files", "Hypixel Studios", "Hytale Launcher")); + } else if (os.contains("mac")) { + paths.add(Path.of(home, "Library", "Application Support", "Hytale")); + paths.add(Path.of("/", "Applications", "Hytale Launcher.app", "Contents", "MacOS")); + } else { + String xdgDataHome = System.getenv("XDG_DATA_HOME"); + if (xdgDataHome != null && !xdgDataHome.isBlank()) { + paths.add(Path.of(xdgDataHome, "Hytale")); + } + paths.add(Path.of(home, ".var", "app", "com.hypixel.HytaleLauncher", "data", "Hytale")); + paths.add(Path.of(home, ".local", "share", "Hytale")); + paths.add(Path.of(home, ".config", "Hytale")); + paths.add(Path.of(home, ".hytale")); + } + + paths.add(Path.of(home, "Hytale")); + return paths.stream().distinct().toList(); + } + + private static Path currentJavaExecutable() { + return Path.of(System.getProperty("java.home", "."), "bin", javaExecutableName()); + } + + private static String javaExecutableName() { + String executable = System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win") + ? "java.exe" + : "java"; + return executable; + } + + private static void addEnvPath(List paths, String variable, String first, String... more) { + String value = System.getenv(variable); + if (value != null && !value.isBlank()) { + paths.add(Path.of(value, first).resolve(Path.of("", more))); + } + } + + public static Optional detectExistingModsDirectory() { + return candidates().stream().filter(Files::isDirectory).findFirst(); + } + + public static Optional detectExistingSavesDirectory() { + return userDataCandidates().stream() + .flatMap(path -> java.util.stream.Stream.of(path.resolve("Saves"), path.resolve("saves"))) + .filter(Files::isDirectory) + .findFirst(); + } + + public static Optional detectExistingUserDataDirectory() { + return userDataCandidates().stream().filter(Files::isDirectory).findFirst(); + } + + public static boolean containsClientExecutable(Path path) { + String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT); + if (os.contains("mac")) { + return Files.isRegularFile(path.resolve(Path.of("Client", "Hytale.app", "Contents", "MacOS", "HytaleClient"))); + } + if (os.contains("win")) { + return Files.isRegularFile(path.resolve(Path.of("Client", "HytaleClient.exe"))); + } + return Files.isRegularFile(path.resolve(Path.of("Client", "HytaleClient"))); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/settings/InstalledProjectRegistry.java b/launcher/src/main/java/net/modtale/launcher/settings/InstalledProjectRegistry.java new file mode 100644 index 00000000..461cd166 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/settings/InstalledProjectRegistry.java @@ -0,0 +1,111 @@ +package net.modtale.launcher.settings; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.modtale.launcher.api.ModtaleApiException; +import net.modtale.launcher.model.install.InstalledProject; + +final class InstalledProjectRegistry { + + private static final String REGISTRY_FILE = "installed-projects.json"; + + private final Path registryPath; + private final ObjectMapper mapper; + + InstalledProjectRegistry(Path launcherDirectory, ObjectMapper mapper) { + Path directory = launcherDirectory == null ? Path.of(".") : launcherDirectory; + this.registryPath = directory.resolve(REGISTRY_FILE); + this.mapper = mapper; + } + + List merge(List projects) { + Map merged = new LinkedHashMap<>(); + for (InstalledProject project : validProjects(projects)) { + merged.put(project.projectId(), project); + } + for (InstalledProject project : load()) { + if (hasAnyRecordedFile(project)) { + merged.putIfAbsent(project.projectId(), project); + } + } + return List.copyOf(merged.values()); + } + + void save(List projects) { + List records = validProjects(projects); + try { + Files.createDirectories(registryPath.getParent()); + mapper.writeValue(registryPath.toFile(), new RegistryFile(records)); + } catch (IOException ex) { + throw new ModtaleApiException("Could not save installed project registry to " + registryPath, ex); + } + } + + void remove(String projectId) { + if (projectId == null || projectId.isBlank()) { + return; + } + List remaining = load().stream() + .filter(project -> !projectId.trim().equals(project.projectId())) + .toList(); + save(remaining); + } + + private List load() { + if (!Files.isRegularFile(registryPath)) { + return List.of(); + } + try { + if (Files.size(registryPath) == 0 || Files.readString(registryPath).isBlank()) { + return List.of(); + } + RegistryFile registry = mapper.readValue(registryPath.toFile(), RegistryFile.class); + return validProjects(registry.installedProjects()); + } catch (IOException ex) { + try { + return validProjects(mapper.readValue(registryPath.toFile(), new TypeReference>() { + })); + } catch (IOException ignored) { + throw new ModtaleApiException("Could not load installed project registry from " + registryPath, ex); + } + } + } + + private static List validProjects(List projects) { + if (projects == null || projects.isEmpty()) { + return List.of(); + } + List valid = new ArrayList<>(); + for (InstalledProject project : projects) { + if (project != null && project.projectId() != null && !project.projectId().isBlank()) { + valid.add(project); + } + } + return valid; + } + + private static boolean hasAnyRecordedFile(InstalledProject project) { + if (project.files().isEmpty()) { + return true; + } + return project.files().stream() + .filter(file -> file != null && !file.isBlank()) + .map(Path::of) + .anyMatch(Files::exists); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private record RegistryFile(List installedProjects) { + private RegistryFile { + installedProjects = installedProjects == null ? List.of() : List.copyOf(installedProjects); + } + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/settings/LauncherConfig.java b/launcher/src/main/java/net/modtale/launcher/settings/LauncherConfig.java new file mode 100644 index 00000000..c0afbc6f --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/settings/LauncherConfig.java @@ -0,0 +1,133 @@ +package net.modtale.launcher.settings; + +import java.net.URI; +import java.util.Optional; +import net.modtale.launcher.api.ModtaleApiClient; + +public final class LauncherConfig { + + public static final String API_BASE_URL_PROPERTY = "modtale.apiBaseUrl"; + public static final String API_BASE_URL_ENV = "MODTALE_API_BASE_URL"; + public static final String SITE_BASE_URL_PROPERTY = "modtale.siteBaseUrl"; + public static final String SITE_BASE_URL_ENV = "MODTALE_SITE_BASE_URL"; + public static final String LAUNCHER_UPDATES_REPOSITORY_PROPERTY = "modtale.launcherUpdatesRepository"; + public static final String LAUNCHER_UPDATES_REPOSITORY_ENV = "MODTALE_LAUNCHER_UPDATES_REPOSITORY"; + public static final String DISCORD_CLIENT_ID_PROPERTY = "modtale.discordClientId"; + public static final String DISCORD_CLIENT_ID_ENV = "MODTALE_DISCORD_CLIENT_ID"; + public static final String DISCORD_CLIENT_ID_FALLBACK_ENV = "DISCORD_CLIENT_ID"; + public static final String DEFAULT_LAUNCHER_UPDATES_REPOSITORY = "Modtale/modtale"; + + private LauncherConfig() { + } + + public static String siteBaseUrl() { + return normalizeBaseUrl(firstConfiguredValue(SITE_BASE_URL_PROPERTY, SITE_BASE_URL_ENV), + ModtaleApiClient.DEFAULT_SITE_BASE_URL, + "Modtale website URL"); + } + + public static String apiBaseUrl() { + return normalizeBaseUrl(firstConfiguredValue(API_BASE_URL_PROPERTY, API_BASE_URL_ENV), + ModtaleApiClient.DEFAULT_API_BASE_URL, + "Modtale API URL"); + } + + public static String launcherUpdatesRepository() { + String value = firstConfiguredValue(LAUNCHER_UPDATES_REPOSITORY_PROPERTY, LAUNCHER_UPDATES_REPOSITORY_ENV); + return normalizeLauncherUpdatesRepository(value); + } + + public static Optional discordClientId() { + String value = firstConfiguredValue(DISCORD_CLIENT_ID_PROPERTY, DISCORD_CLIENT_ID_ENV); + if (value == null) { + value = environmentValue(DISCORD_CLIENT_ID_FALLBACK_ENV); + } + return normalizeDiscordClientId(value); + } + + static String normalizeSiteBaseUrl(String rawValue) { + return normalizeBaseUrl(rawValue, ModtaleApiClient.DEFAULT_SITE_BASE_URL, "Modtale website URL"); + } + + static String normalizeApiBaseUrl(String rawValue) { + return normalizeBaseUrl(rawValue, ModtaleApiClient.DEFAULT_API_BASE_URL, "Modtale API URL"); + } + + public static String normalizeLauncherUpdatesRepository(String rawValue) { + String value = rawValue == null || rawValue.isBlank() + ? DEFAULT_LAUNCHER_UPDATES_REPOSITORY + : rawValue.trim(); + if (!value.matches("[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+")) { + throw new IllegalArgumentException("Launcher update repository must use owner/repo format."); + } + return value; + } + + public static Optional normalizeDiscordClientId(String rawValue) { + String value = rawValue == null ? "" : rawValue.trim(); + if (value.isBlank() || "dev".equalsIgnoreCase(value)) { + return Optional.empty(); + } + return value.matches("\\d{8,32}") ? Optional.of(value) : Optional.empty(); + } + + private static String normalizeBaseUrl(String rawValue, String defaultValue, String label) { + String value = rawValue == null || rawValue.isBlank() + ? defaultValue + : rawValue.trim(); + if (!value.contains("://")) { + value = defaultScheme(value) + "://" + value; + } + + URI uri = URI.create(value); + String scheme = uri.getScheme(); + if (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) { + throw new IllegalArgumentException("The " + label + " must start with http:// or https://."); + } + if (uri.getHost() == null || uri.getHost().isBlank()) { + throw new IllegalArgumentException("Enter a valid " + label + "."); + } + return value.replaceAll("/+$", ""); + } + + private static String defaultScheme(String value) { + String host = value; + int pathIndex = host.indexOf('/'); + if (pathIndex >= 0) { + host = host.substring(0, pathIndex); + } + if (host.startsWith("[") && host.contains("]")) { + host = host.substring(1, host.indexOf(']')); + } else { + int portIndex = host.indexOf(':'); + if (portIndex >= 0) { + host = host.substring(0, portIndex); + } + } + + String normalizedHost = host.toLowerCase(java.util.Locale.ROOT); + if ("localhost".equals(normalizedHost) + || "127.0.0.1".equals(normalizedHost) + || "::1".equals(normalizedHost)) { + return "http"; + } + return "https"; + } + + private static String firstConfiguredValue(String systemPropertyName, String environmentVariableName) { + String systemProperty = System.getProperty(systemPropertyName); + if (systemProperty != null && !systemProperty.isBlank()) { + return systemProperty; + } + + String environmentValue = environmentValue(environmentVariableName); + if (environmentValue != null && !environmentValue.isBlank()) { + return environmentValue; + } + return null; + } + + private static String environmentValue(String environmentVariableName) { + return System.getenv(environmentVariableName); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/settings/LauncherSettings.java b/launcher/src/main/java/net/modtale/launcher/settings/LauncherSettings.java new file mode 100644 index 00000000..0af80858 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/settings/LauncherSettings.java @@ -0,0 +1,534 @@ +package net.modtale.launcher.settings; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import net.modtale.launcher.hytale.HytaleApiClient; +import net.modtale.launcher.hytale.HytaleAuthSession; +import net.modtale.launcher.hytale.HytaleVersion; +import net.modtale.launcher.model.install.InstalledProject; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class LauncherSettings { + + private String lastUsername = ""; + private String hytaleModsPath = HytalePathDetector.defaultModsDirectory().toString(); + private String hytaleGamePath = HytalePathDetector.defaultGameDirectory().toString(); + private String hytaleUserDataPath = HytalePathDetector.defaultUserDataDirectory().toString(); + private String hytaleJavaPath = HytalePathDetector.defaultJavaExecutable().toString(); + private String hytaleBranch = "release"; + private int hytaleBuild; + private long hytalePlaytimeSeconds; + private HytaleAuthSession hytaleAuthSession; + private List hytaleAuthSessions = new ArrayList<>(); + private String activeHytaleAccountId = ""; + private String gameVersion = ""; + private boolean includeDependencies = true; + private boolean includeOptionalDependencies; + private boolean autoCheckUpdates = true; + private boolean launcherAutoUpdates; + private List hytalePatchlineCaches = new ArrayList<>(); + private List hytaleVersionCaches = new ArrayList<>(); + private List installedProjects = new ArrayList<>(); + + public String getLastUsername() { + return lastUsername; + } + + public void setLastUsername(String lastUsername) { + this.lastUsername = lastUsername == null ? "" : lastUsername.trim(); + } + + public String getHytaleModsPath() { + return hytaleModsPath; + } + + public void setHytaleModsPath(String hytaleModsPath) { + this.hytaleModsPath = hytaleModsPath == null ? "" : hytaleModsPath.trim(); + } + + public Path hytaleModsDirectory() { + return Path.of(hytaleModsPath); + } + + public String getHytaleGamePath() { + return hytaleGamePath; + } + + public void setHytaleGamePath(String hytaleGamePath) { + this.hytaleGamePath = hytaleGamePath == null ? "" : hytaleGamePath.trim(); + } + + public Path hytaleGameDirectory() { + return Path.of(hytaleGamePath); + } + + public String getHytaleUserDataPath() { + return hytaleUserDataPath; + } + + public void setHytaleUserDataPath(String hytaleUserDataPath) { + this.hytaleUserDataPath = hytaleUserDataPath == null ? "" : hytaleUserDataPath.trim(); + } + + public Path hytaleUserDataDirectory() { + return Path.of(hytaleUserDataPath); + } + + public String getHytaleJavaPath() { + return hytaleJavaPath; + } + + public void setHytaleJavaPath(String hytaleJavaPath) { + this.hytaleJavaPath = hytaleJavaPath == null || hytaleJavaPath.isBlank() + ? HytalePathDetector.defaultJavaExecutable().toString() + : hytaleJavaPath.trim(); + } + + public Path hytaleJavaExecutable() { + return Path.of(hytaleJavaPath); + } + + public String getHytaleBranch() { + return hytaleBranch; + } + + public void setHytaleBranch(String hytaleBranch) { + this.hytaleBranch = HytaleApiClient.normalizeBranch(hytaleBranch); + } + + public int getHytaleBuild() { + return hytaleBuild; + } + + public void setHytaleBuild(int hytaleBuild) { + this.hytaleBuild = Math.max(0, hytaleBuild); + } + + public long getHytalePlaytimeSeconds() { + return Math.max(0, hytalePlaytimeSeconds); + } + + public void setHytalePlaytimeSeconds(long hytalePlaytimeSeconds) { + this.hytalePlaytimeSeconds = Math.max(0, hytalePlaytimeSeconds); + } + + public void addHytalePlaytimeSeconds(long seconds) { + if (seconds <= 0) { + return; + } + this.hytalePlaytimeSeconds = Math.max(0, this.hytalePlaytimeSeconds + seconds); + } + + public List getHytalePatchlineCaches() { + return hytalePatchlineCaches; + } + + public void setHytalePatchlineCaches(List hytalePatchlineCaches) { + this.hytalePatchlineCaches = hytalePatchlineCaches == null ? new ArrayList<>() : new ArrayList<>(hytalePatchlineCaches); + } + + public List getHytaleVersionCaches() { + return hytaleVersionCaches; + } + + public void setHytaleVersionCaches(List hytaleVersionCaches) { + this.hytaleVersionCaches = hytaleVersionCaches == null ? new ArrayList<>() : new ArrayList<>(hytaleVersionCaches); + } + + public List cachedHytalePatchlines(String accountId, String platform) { + String accountKey = cacheValue(accountId); + String platformKey = cacheValue(platform); + return hytalePatchlineCaches.stream() + .filter(entry -> accountKey.equals(cacheValue(entry.getAccountId())) + && platformKey.equals(cacheValue(entry.getPlatform()))) + .findFirst() + .map(HytalePatchlineCacheEntry::getPatchlines) + .orElse(List.of()); + } + + public List pendingHytalePatchlines(String accountId, String platform) { + String accountKey = cacheValue(accountId); + String platformKey = cacheValue(platform); + return hytalePatchlineCaches.stream() + .filter(entry -> accountKey.equals(cacheValue(entry.getAccountId())) + && platformKey.equals(cacheValue(entry.getPlatform()))) + .findFirst() + .map(HytalePatchlineCacheEntry::getPendingPatchlines) + .orElse(List.of()); + } + + public void cacheHytalePatchlines(String accountId, String platform, List patchlines) { + String accountKey = cacheValue(accountId); + String platformKey = cacheValue(platform); + HytalePatchlineCacheEntry entry = hytalePatchlineCacheEntry(accountKey, platformKey); + entry.setAccountId(accountKey); + entry.setPlatform(platformKey); + entry.setFetchedAt(System.currentTimeMillis()); + entry.setPatchlines(patchlines); + } + + public void cachePendingHytalePatchlines(String accountId, String platform, List pendingPatchlines) { + String accountKey = cacheValue(accountId); + String platformKey = cacheValue(platform); + HytalePatchlineCacheEntry entry = hytalePatchlineCacheEntry(accountKey, platformKey); + entry.setAccountId(accountKey); + entry.setPlatform(platformKey); + entry.setPendingPatchlines(pendingPatchlines); + } + + public List cachedHytaleVersions(String accountId, String platform, String patchline) { + String accountKey = cacheValue(accountId); + String platformKey = cacheValue(platform); + String patchlineKey = HytaleApiClient.normalizeBranch(patchline); + return hytaleVersionCaches.stream() + .filter(entry -> accountKey.equals(cacheValue(entry.getAccountId())) + && platformKey.equals(cacheValue(entry.getPlatform())) + && patchlineKey.equals(HytaleApiClient.normalizeBranch(entry.getPatchline()))) + .findFirst() + .map(HytaleVersionCacheEntry::getVersions) + .orElse(List.of()); + } + + public void cacheHytaleVersions(String accountId, String platform, String patchline, List versions) { + String accountKey = cacheValue(accountId); + String platformKey = cacheValue(platform); + String patchlineKey = HytaleApiClient.normalizeBranch(patchline); + hytaleVersionCaches.removeIf(entry -> accountKey.equals(cacheValue(entry.getAccountId())) + && platformKey.equals(cacheValue(entry.getPlatform())) + && patchlineKey.equals(HytaleApiClient.normalizeBranch(entry.getPatchline()))); + HytaleVersionCacheEntry entry = new HytaleVersionCacheEntry(); + entry.setAccountId(accountKey); + entry.setPlatform(platformKey); + entry.setPatchline(patchlineKey); + entry.setFetchedAt(System.currentTimeMillis()); + entry.setVersions(versions); + hytaleVersionCaches.add(entry); + } + + public HytaleAuthSession getHytaleAuthSession() { + normalizeHytaleAuthSessions(); + if (!activeHytaleAccountId.isBlank()) { + for (HytaleAuthSession session : hytaleAuthSessions) { + if (activeHytaleAccountId.equals(hytaleAccountId(session))) { + return session; + } + } + } + if (!hytaleAuthSessions.isEmpty()) { + HytaleAuthSession session = hytaleAuthSessions.getFirst(); + activeHytaleAccountId = hytaleAccountId(session); + hytaleAuthSession = session; + return session; + } + return hytaleAuthSession; + } + + public void setHytaleAuthSession(HytaleAuthSession hytaleAuthSession) { + this.hytaleAuthSession = hytaleAuthSession; + if (hytaleAuthSession == null) { + activeHytaleAccountId = ""; + return; + } + upsertHytaleAuthSession(hytaleAuthSession); + } + + public List getHytaleAuthSessions() { + normalizeHytaleAuthSessions(); + return List.copyOf(hytaleAuthSessions); + } + + public void setHytaleAuthSessions(List hytaleAuthSessions) { + this.hytaleAuthSessions = hytaleAuthSessions == null ? new ArrayList<>() : new ArrayList<>(hytaleAuthSessions); + normalizeHytaleAuthSessions(); + } + + public String getActiveHytaleAccountId() { + normalizeHytaleAuthSessions(); + return activeHytaleAccountId; + } + + public void setActiveHytaleAccountId(String activeHytaleAccountId) { + this.activeHytaleAccountId = activeHytaleAccountId == null ? "" : activeHytaleAccountId.trim(); + normalizeHytaleAuthSessions(); + } + + public void upsertHytaleAuthSession(HytaleAuthSession session) { + if (session == null) { + return; + } + String accountId = hytaleAccountId(session); + hytaleAuthSessions.removeIf(existing -> hytaleAccountId(existing).equals(accountId)); + hytaleAuthSessions.add(session); + activeHytaleAccountId = accountId; + hytaleAuthSession = session; + } + + public void selectHytaleAccount(String accountId) { + if (accountId == null || accountId.isBlank()) { + return; + } + normalizeHytaleAuthSessions(); + for (HytaleAuthSession session : hytaleAuthSessions) { + if (accountId.equals(hytaleAccountId(session))) { + activeHytaleAccountId = accountId; + hytaleAuthSession = session; + return; + } + } + } + + public void removeActiveHytaleAuthSession() { + normalizeHytaleAuthSessions(); + String accountId = activeHytaleAccountId; + if (accountId.isBlank() && hytaleAuthSession != null) { + accountId = hytaleAccountId(hytaleAuthSession); + } + removeHytaleAuthSession(accountId); + } + + public void removeHytaleAuthSession(String accountId) { + if (accountId == null || accountId.isBlank()) { + return; + } + normalizeHytaleAuthSessions(); + String selectedAccountId = accountId.trim(); + String previousActiveAccountId = activeHytaleAccountId; + hytaleAuthSessions.removeIf(existing -> hytaleAccountId(existing).equals(selectedAccountId)); + hytaleAuthSession = null; + activeHytaleAccountId = ""; + if (!hytaleAuthSessions.isEmpty()) { + HytaleAuthSession next = hytaleAuthSessions.stream() + .filter(session -> hytaleAccountId(session).equals(previousActiveAccountId)) + .findFirst() + .orElse(hytaleAuthSessions.getFirst()); + hytaleAuthSession = next; + activeHytaleAccountId = hytaleAccountId(next); + } + } + + public void normalizeHytaleAuthSessions() { + if (hytaleAuthSessions == null) { + hytaleAuthSessions = new ArrayList<>(); + } + if (hytaleAuthSession != null) { + String legacyId = hytaleAccountId(hytaleAuthSession); + boolean known = hytaleAuthSessions.stream() + .anyMatch(existing -> hytaleAccountId(existing).equals(legacyId)); + if (!known) { + hytaleAuthSessions.add(hytaleAuthSession); + } + } + hytaleAuthSessions.removeIf(session -> session == null || hytaleAccountId(session).isBlank()); + if (hytaleAuthSessions.isEmpty()) { + activeHytaleAccountId = ""; + hytaleAuthSession = null; + return; + } + if (activeHytaleAccountId == null || activeHytaleAccountId.isBlank() + || hytaleAuthSessions.stream().noneMatch(session -> activeHytaleAccountId.equals(hytaleAccountId(session)))) { + activeHytaleAccountId = hytaleAccountId(hytaleAuthSessions.getFirst()); + } + hytaleAuthSession = hytaleAuthSessions.stream() + .filter(session -> activeHytaleAccountId.equals(hytaleAccountId(session))) + .findFirst() + .orElse(hytaleAuthSessions.getFirst()); + } + + public static String hytaleAccountId(HytaleAuthSession session) { + if (session == null) { + return ""; + } + if (session.getAccountOwnerId() != null && !session.getAccountOwnerId().isBlank()) { + return session.getAccountOwnerId().trim(); + } + if (session.getUuid() != null && !session.getUuid().isBlank()) { + return session.getUuid().trim(); + } + return session.getUsername() == null ? "" : session.getUsername().trim(); + } + + public String getGameVersion() { + return gameVersion; + } + + public void setGameVersion(String gameVersion) { + this.gameVersion = gameVersion == null ? "" : gameVersion.trim(); + } + + public boolean isIncludeDependencies() { + return includeDependencies; + } + + public void setIncludeDependencies(boolean includeDependencies) { + this.includeDependencies = includeDependencies; + } + + public boolean isIncludeOptionalDependencies() { + return includeOptionalDependencies; + } + + public void setIncludeOptionalDependencies(boolean includeOptionalDependencies) { + this.includeOptionalDependencies = includeOptionalDependencies; + } + + public boolean isAutoCheckUpdates() { + return autoCheckUpdates; + } + + public void setAutoCheckUpdates(boolean autoCheckUpdates) { + this.autoCheckUpdates = autoCheckUpdates; + } + + public boolean isLauncherAutoUpdates() { + return launcherAutoUpdates; + } + + public void setLauncherAutoUpdates(boolean launcherAutoUpdates) { + this.launcherAutoUpdates = launcherAutoUpdates; + } + + public List getInstalledProjects() { + return installedProjects; + } + + public void setInstalledProjects(List installedProjects) { + this.installedProjects = installedProjects == null + ? new ArrayList<>() + : new ArrayList<>(installedProjects.stream() + .filter(project -> project != null && project.projectId() != null && !project.projectId().isBlank()) + .toList()); + } + + public void upsertInstalledProject(InstalledProject project) { + if (project == null || project.projectId() == null || project.projectId().isBlank()) { + return; + } + installedProjects.removeIf(existing -> existing.projectId().equals(project.projectId())); + installedProjects.add(project); + } + + public void removeInstalledProject(String projectId) { + if (projectId == null || projectId.isBlank()) { + return; + } + installedProjects.removeIf(existing -> existing.projectId().equals(projectId.trim())); + } + + private static String cacheValue(String value) { + return value == null ? "" : value.trim(); + } + + private HytalePatchlineCacheEntry hytalePatchlineCacheEntry(String accountId, String platform) { + String accountKey = cacheValue(accountId); + String platformKey = cacheValue(platform); + for (HytalePatchlineCacheEntry entry : hytalePatchlineCaches) { + if (accountKey.equals(cacheValue(entry.getAccountId())) + && platformKey.equals(cacheValue(entry.getPlatform()))) { + return entry; + } + } + HytalePatchlineCacheEntry entry = new HytalePatchlineCacheEntry(); + hytalePatchlineCaches.add(entry); + return entry; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class HytalePatchlineCacheEntry { + private String accountId = ""; + private String platform = ""; + private long fetchedAt; + private List patchlines = new ArrayList<>(); + private List pendingPatchlines = new ArrayList<>(); + + public String getAccountId() { + return accountId; + } + + public void setAccountId(String accountId) { + this.accountId = cacheValue(accountId); + } + + public String getPlatform() { + return platform; + } + + public void setPlatform(String platform) { + this.platform = cacheValue(platform); + } + + public long getFetchedAt() { + return fetchedAt; + } + + public void setFetchedAt(long fetchedAt) { + this.fetchedAt = Math.max(0, fetchedAt); + } + + public List getPatchlines() { + return patchlines; + } + + public void setPatchlines(List patchlines) { + this.patchlines = patchlines == null ? new ArrayList<>() : new ArrayList<>(patchlines); + } + + public List getPendingPatchlines() { + return pendingPatchlines; + } + + public void setPendingPatchlines(List pendingPatchlines) { + this.pendingPatchlines = pendingPatchlines == null ? new ArrayList<>() : new ArrayList<>(pendingPatchlines); + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class HytaleVersionCacheEntry { + private String accountId = ""; + private String platform = ""; + private String patchline = "release"; + private long fetchedAt; + private List versions = new ArrayList<>(); + + public String getAccountId() { + return accountId; + } + + public void setAccountId(String accountId) { + this.accountId = cacheValue(accountId); + } + + public String getPlatform() { + return platform; + } + + public void setPlatform(String platform) { + this.platform = cacheValue(platform); + } + + public String getPatchline() { + return patchline; + } + + public void setPatchline(String patchline) { + this.patchline = HytaleApiClient.normalizeBranch(patchline); + } + + public long getFetchedAt() { + return fetchedAt; + } + + public void setFetchedAt(long fetchedAt) { + this.fetchedAt = Math.max(0, fetchedAt); + } + + public List getVersions() { + return versions; + } + + public void setVersions(List versions) { + this.versions = versions == null ? new ArrayList<>() : new ArrayList<>(versions); + } + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/settings/SettingsStore.java b/launcher/src/main/java/net/modtale/launcher/settings/SettingsStore.java new file mode 100644 index 00000000..2b581626 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/settings/SettingsStore.java @@ -0,0 +1,123 @@ +package net.modtale.launcher.settings; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import net.modtale.launcher.api.ModtaleApiException; + +public class SettingsStore { + + private final Path settingsPath; + private final ObjectMapper mapper; + private final InstalledProjectRegistry installedProjectRegistry; + + public SettingsStore() { + this(defaultSettingsPath()); + } + + public SettingsStore(Path settingsPath) { + this.settingsPath = settingsPath; + this.mapper = new ObjectMapper() + .registerModule(new JavaTimeModule()) + .enable(SerializationFeature.INDENT_OUTPUT) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + this.installedProjectRegistry = new InstalledProjectRegistry(settingsPath.getParent(), mapper); + } + + public LauncherSettings load() { + if (!Files.exists(settingsPath)) { + return saveDefaultSettings(); + } + try { + if (isBlankSettingsFile()) { + return saveDefaultSettings(); + } + LauncherSettings settings = mapper.readValue(settingsPath.toFile(), LauncherSettings.class); + normalize(settings); + settings.setInstalledProjects(installedProjectRegistry.merge(settings.getInstalledProjects())); + return settings; + } catch (IOException ex) { + throw new ModtaleApiException("Could not load launcher settings from " + settingsPath, ex); + } + } + + private LauncherSettings saveDefaultSettings() { + LauncherSettings settings = new LauncherSettings(); + save(settings); + return settings; + } + + private boolean isBlankSettingsFile() throws IOException { + return Files.size(settingsPath) == 0 || Files.readString(settingsPath).isBlank(); + } + + public void save(LauncherSettings settings) { + normalize(settings); + settings.setInstalledProjects(installedProjectRegistry.merge(settings.getInstalledProjects())); + try { + Files.createDirectories(settingsPath.getParent()); + mapper.writeValue(settingsPath.toFile(), settings); + installedProjectRegistry.save(settings.getInstalledProjects()); + } catch (IOException ex) { + throw new ModtaleApiException("Could not save launcher settings to " + settingsPath, ex); + } + } + + public void removeInstalledProject(String projectId) { + installedProjectRegistry.remove(projectId); + } + + public Path settingsPath() { + return settingsPath; + } + + public static Path defaultSettingsPath() { + return defaultLauncherDirectory().resolve("settings.json"); + } + + public static Path defaultSessionPath() { + return defaultLauncherDirectory().resolve("session-cookies.json"); + } + + private static Path defaultLauncherDirectory() { + return Path.of(System.getProperty("user.home", "."), ".modtale", "launcher"); + } + + private static void normalize(LauncherSettings settings) { + if (settings.getHytaleModsPath() == null || settings.getHytaleModsPath().isBlank()) { + settings.setHytaleModsPath(HytalePathDetector.defaultModsDirectory().toString()); + } + if (settings.getHytaleGamePath() == null || settings.getHytaleGamePath().isBlank()) { + settings.setHytaleGamePath(HytalePathDetector.defaultGameDirectory().toString()); + } else if (!HytalePathDetector.containsClientExecutable(settings.hytaleGameDirectory())) { + HytalePathDetector.detectExistingGameDirectory() + .ifPresent(path -> settings.setHytaleGamePath(path.toString())); + } + if (settings.getHytaleUserDataPath() == null || settings.getHytaleUserDataPath().isBlank()) { + settings.setHytaleUserDataPath(HytalePathDetector.defaultUserDataDirectory().toString()); + } + if (settings.getHytaleJavaPath() == null || settings.getHytaleJavaPath().isBlank()) { + settings.setHytaleJavaPath(HytalePathDetector.defaultJavaExecutable().toString()); + } else if (HytalePathDetector.isCurrentJavaExecutable(settings.hytaleJavaExecutable())) { + HytalePathDetector.detectExistingJavaExecutable() + .filter(path -> !HytalePathDetector.isCurrentJavaExecutable(path)) + .ifPresent(path -> settings.setHytaleJavaPath(path.toString())); + } + if (settings.getHytaleBranch() == null || settings.getHytaleBranch().isBlank()) { + settings.setHytaleBranch("release"); + } + if (settings.getInstalledProjects() == null) { + settings.setInstalledProjects(java.util.List.of()); + } + if (settings.getHytalePatchlineCaches() == null) { + settings.setHytalePatchlineCaches(java.util.List.of()); + } + if (settings.getHytaleVersionCaches() == null) { + settings.setHytaleVersionCaches(java.util.List.of()); + } + settings.normalizeHytaleAuthSessions(); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/sync/LauncherPreferenceSyncDialog.java b/launcher/src/main/java/net/modtale/launcher/sync/LauncherPreferenceSyncDialog.java new file mode 100644 index 00000000..ea0c3def --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/sync/LauncherPreferenceSyncDialog.java @@ -0,0 +1,132 @@ +package net.modtale.launcher.sync; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.function.Supplier; +import javafx.geometry.Pos; +import javafx.scene.control.Label; +import javafx.scene.layout.HBox; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import net.modtale.launcher.ui.common.StatusModal; + +final class LauncherPreferenceSyncDialog { + + private static final DateTimeFormatter SAVED_AT_FORMAT = + DateTimeFormatter.ofPattern("MMM d, yyyy 'at' h:mm a"); + + private final int remoteProjects; + private final int localProjects; + private final String updatedAt; + + private LauncherPreferenceSyncDialog( + int remoteProjects, + int localProjects, + String updatedAt + ) { + this.remoteProjects = Math.max(0, remoteProjects); + this.localProjects = Math.max(0, localProjects); + this.updatedAt = updatedAt == null ? "" : updatedAt.trim(); + } + + static boolean showAndWait( + Supplier host, + int remoteProjects, + int localProjects, + String updatedAt + ) { + LauncherPreferenceSyncDialog dialog = new LauncherPreferenceSyncDialog( + remoteProjects, + localProjects, + updatedAt + ); + StatusModal.Result result = StatusModal.builder(host) + .type(StatusModal.Type.INFO) + .title("Different preferences found") + .message("Your Modtale account has launcher preferences that differ from this device.") + .secondaryLabel("Use this device") + .actionLabel("Load from Modtale") + .content(dialog.summaryCard()) + .showAndWait(); + return result == StatusModal.Result.PRIMARY; + } + + private VBox summaryCard() { + VBox summary = new VBox(8); + summary.getStyleClass().add("preference-sync-summary"); + summary.setAlignment(Pos.CENTER); + summary.getChildren().add(summaryLine("Modtale account", + remoteProjects + " installed project" + plural(remoteProjects))); + summary.getChildren().add(summaryLine("This device", + localProjects + " installed project" + plural(localProjects))); + summary.getChildren().add(summaryLine("Last saved", savedAtLabel())); + return summary; + } + + private HBox summaryLine(String labelText, String valueText) { + HBox row = new HBox(10); + row.setAlignment(Pos.CENTER); + Label label = new Label(labelText); + label.getStyleClass().add("preference-sync-summary-label"); + label.setAlignment(Pos.CENTER); + Label value = new Label(valueText); + value.getStyleClass().add("preference-sync-summary-value"); + value.setAlignment(Pos.CENTER); + row.getChildren().addAll(label, value); + return row; + } + + private String savedAtLabel() { + if (updatedAt.isBlank()) { + return "Previously saved"; + } + for (DateParser parser : new DateParser[]{ + this::parseLocalDateTime, + this::parseOffsetDateTime, + this::parseInstant + }) { + String formatted = parser.parse(updatedAt); + if (!formatted.isBlank()) { + return formatted; + } + } + return updatedAt; + } + + private String parseLocalDateTime(String value) { + try { + return SAVED_AT_FORMAT.format(LocalDateTime.parse(value)); + } catch (DateTimeParseException ignored) { + return ""; + } + } + + private String parseOffsetDateTime(String value) { + try { + return SAVED_AT_FORMAT.format(OffsetDateTime.parse(value).toLocalDateTime()); + } catch (DateTimeParseException ignored) { + return ""; + } + } + + private String parseInstant(String value) { + try { + return SAVED_AT_FORMAT.format(LocalDateTime.ofInstant(Instant.parse(value), ZoneId.systemDefault())); + } catch (DateTimeParseException ignored) { + return ""; + } + } + + private static String plural(int count) { + return count == 1 ? "" : "s"; + } + + @FunctionalInterface + private interface DateParser { + String parse(String value); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/sync/LauncherSettingsSyncService.java b/launcher/src/main/java/net/modtale/launcher/sync/LauncherSettingsSyncService.java new file mode 100644 index 00000000..5f5fc8c2 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/sync/LauncherSettingsSyncService.java @@ -0,0 +1,341 @@ +package net.modtale.launcher.sync; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BooleanSupplier; +import java.util.function.Supplier; +import javafx.scene.layout.StackPane; +import net.modtale.launcher.api.ModtaleApiClient; +import net.modtale.launcher.api.ModtaleApiException; +import net.modtale.launcher.install.ModInstaller; +import net.modtale.launcher.install.VersionSelector; +import net.modtale.launcher.model.install.InstallOptions; +import net.modtale.launcher.model.install.InstallResult; +import net.modtale.launcher.model.install.InstalledProject; +import net.modtale.launcher.model.project.ProjectDetail; +import net.modtale.launcher.model.project.ProjectVersion; +import net.modtale.launcher.model.sync.LauncherSettingsSnapshot; +import net.modtale.launcher.settings.LauncherSettings; +import net.modtale.launcher.settings.SettingsStore; +import net.modtale.launcher.ui.feedback.LauncherFeedback; +import net.modtale.launcher.ui.settings.LauncherSettingsController; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +public final class LauncherSettingsSyncService { + + private static final Logger LOG = LogManager.getLogger(LauncherSettingsSyncService.class); + + private final ModtaleApiClient apiClient; + private final SettingsStore settingsStore; + private final LauncherSettingsController settingsController; + private final ModInstaller installer; + private final LauncherFeedback feedback; + private final BooleanSupplier signedIn; + private final Supplier overlayHost; + private final AtomicBoolean checking = new AtomicBoolean(); + private final AtomicBoolean uploading = new AtomicBoolean(); + + private volatile String lastKnownRemoteHash = ""; + private volatile String lastKnownRemoteInstalledProjectsHash = ""; + + public LauncherSettingsSyncService( + ModtaleApiClient apiClient, + SettingsStore settingsStore, + LauncherSettingsController settingsController, + ModInstaller installer, + LauncherFeedback feedback, + BooleanSupplier signedIn, + Supplier overlayHost + ) { + this.apiClient = apiClient; + this.settingsStore = settingsStore; + this.settingsController = settingsController; + this.installer = installer; + this.feedback = feedback; + this.signedIn = signedIn == null ? () -> false : signedIn; + this.overlayHost = overlayHost == null ? () -> null : overlayHost; + } + + public void checkOnSignIn() { + if (!signedIn.getAsBoolean() || !checking.compareAndSet(false, true)) { + return; + } + feedback.runAsync("Checking launcher preferences...", + apiClient::getLauncherSettings, + remote -> { + checking.set(false); + handleRemoteSnapshot(remote); + }, + error -> checking.set(false)); + } + + public void syncAfterLocalChange() { + if (!signedIn.getAsBoolean() || checking.get()) { + return; + } + LauncherSettingsSnapshot local = LauncherSettingsSnapshot.fromSettings(settingsController.settings()); + String localHash = local.computeHash(); + if (localHash.equals(lastKnownRemoteHash)) { + return; + } + if (canUploadPreferencesOnly(local)) { + uploadPreferences(local, false); + } else { + uploadSnapshot(local, false); + } + } + + private void handleRemoteSnapshot(LauncherSettingsSnapshot remote) { + LauncherSettingsSnapshot local = LauncherSettingsSnapshot.fromSettings(settingsController.settings()); + if (remote == null || !remote.hasSyncedContent()) { + uploadSnapshot(local, false); + return; + } + + String localHash = local.computeHash(); + if (hashMatches(remote, localHash)) { + lastKnownRemoteHash = localHash; + lastKnownRemoteInstalledProjectsHash = local.installedProjectsHash(); + return; + } + + lastKnownRemoteHash = remote.effectiveHash(); + lastKnownRemoteInstalledProjectsHash = remote.installedProjectsHash(); + if (promptLoadRemote(remote, local)) { + restoreSnapshot(remote); + } else { + uploadSnapshot(local, true); + } + } + + private boolean promptLoadRemote(LauncherSettingsSnapshot remote, LauncherSettingsSnapshot local) { + return LauncherPreferenceSyncDialog.showAndWait( + overlayHost, + remote.installedProjects().size(), + local.installedProjects().size(), + remote.getUpdatedAt() + ); + } + + private void restoreSnapshot(LauncherSettingsSnapshot snapshot) { + feedback.runAsync("Loading launcher preferences from Modtale...", + () -> restore(snapshot), + result -> { + LauncherSettingsSnapshot local = LauncherSettingsSnapshot.fromSettings(settingsController.settings()); + lastKnownRemoteHash = local.computeHash(); + lastKnownRemoteInstalledProjectsHash = local.installedProjectsHash(); + settingsController.reloadFromStore(); + feedback.log("Loaded launcher preferences from Modtale."); + feedback.showToast("Preferences loaded", result.message()); + }); + } + + private RestoreResult restore(LauncherSettingsSnapshot snapshot) { + LauncherSettings settings = settingsController.settings(); + List previousInstalls = new ArrayList<>(settings.getInstalledProjects()); + Set remoteProjectIds = remoteProjectIds(snapshot); + List preservedLocalInstalls = previousInstalls.stream() + .filter(project -> !remoteProjectIds.contains(project.projectId())) + .toList(); + deleteRecordedFiles(previousInstalls.stream() + .filter(project -> remoteProjectIds.contains(project.projectId())) + .toList()); + remoteProjectIds.forEach(settingsStore::removeInstalledProject); + + snapshot.applyPreferencesTo(settings); + settings.setInstalledProjects(preservedLocalInstalls); + settingsStore.save(settings); + + int installed = 0; + List warnings = new ArrayList<>(); + for (LauncherSettingsSnapshot.InstalledProjectSnapshot projectSnapshot : snapshot.installedProjects()) { + if (projectSnapshot.getProjectId() == null || projectSnapshot.getProjectId().isBlank()) { + continue; + } + try { + ProjectDetail project = apiClient.getProject(projectSnapshot.getProjectId()); + ProjectVersion version = resolveVersion(project, projectSnapshot, settings); + InstallResult result = installer.install(project, version, installOptions(settings, projectSnapshot)); + settings.upsertInstalledProject(result.installedProject().withModpackUnlocked(projectSnapshot.isModpackUnlocked())); + settingsStore.save(settings); + installed++; + warnings.addAll(result.warnings()); + } catch (RuntimeException ex) { + LOG.warn("Could not restore installed project {}", projectSnapshot.getProjectId(), ex); + warnings.add(projectSnapshot.getProjectId() + ": " + ex.getMessage()); + } + } + + if (!warnings.isEmpty()) { + feedback.log("Launcher preference restore warnings: " + String.join(" ", warnings)); + } + return new RestoreResult("Restored " + installed + " installed project" + plural(installed) + + preservedMessage(preservedLocalInstalls.size()) + " and saved preferences."); + } + + private Set remoteProjectIds(LauncherSettingsSnapshot snapshot) { + Set ids = new LinkedHashSet<>(); + for (LauncherSettingsSnapshot.InstalledProjectSnapshot installed : snapshot.installedProjects()) { + if (installed.getProjectId() != null && !installed.getProjectId().isBlank()) { + ids.add(installed.getProjectId().trim()); + } + } + return ids; + } + + private String preservedMessage(int preserved) { + if (preserved <= 0) { + return ""; + } + return " and kept " + preserved + " local install" + plural(preserved); + } + + private ProjectVersion resolveVersion( + ProjectDetail project, + LauncherSettingsSnapshot.InstalledProjectSnapshot installed, + LauncherSettings settings + ) { + if (project == null) { + throw new ModtaleApiException("Project is no longer available."); + } + if (project.versions().isEmpty()) { + project = project.withVersions(apiClient.getProjectVersions(project.routeKey())); + } + if (installed.getInstalledVersionId() != null && !installed.getInstalledVersionId().isBlank()) { + for (ProjectVersion version : project.versions()) { + if (installed.getInstalledVersionId().equals(version.id())) { + return version; + } + } + } + if (installed.getInstalledVersion() != null && !installed.getInstalledVersion().isBlank()) { + for (ProjectVersion version : project.versions()) { + if (installed.getInstalledVersion().equals(version.versionNumber())) { + return version; + } + } + } + String projectTitle = project.title(); + return VersionSelector.latestCompatible(project, effectiveGameVersion(settings, installed)) + .orElseThrow(() -> new ModtaleApiException("No compatible version was found for " + projectTitle)); + } + + private InstallOptions installOptions( + LauncherSettings settings, + LauncherSettingsSnapshot.InstalledProjectSnapshot installed + ) { + if (installed.getBundledProjects() != null && !installed.getBundledProjects().isEmpty()) { + return new InstallOptions( + settings.hytaleModsDirectory(), + effectiveGameVersion(settings, installed), + true, + true, + installed.getBundledProjects().stream() + .map(net.modtale.launcher.model.install.InstalledProjectReference::toDependency) + .toList() + ); + } + return new InstallOptions( + settings.hytaleModsDirectory(), + effectiveGameVersion(settings, installed), + settings.isIncludeDependencies(), + settings.isIncludeOptionalDependencies() + ); + } + + private String effectiveGameVersion( + LauncherSettings settings, + LauncherSettingsSnapshot.InstalledProjectSnapshot installed + ) { + if (installed.getGameVersion() != null && !installed.getGameVersion().isBlank()) { + return installed.getGameVersion(); + } + return settings.getGameVersion(); + } + + private void deleteRecordedFiles(List installedProjects) { + for (InstalledProject installed : installedProjects) { + if (installed == null || installed.files() == null) { + continue; + } + for (String file : installed.files()) { + if (file == null || file.isBlank()) { + continue; + } + try { + Files.deleteIfExists(Path.of(file)); + } catch (IOException ex) { + LOG.warn("Could not delete stale installed file while restoring snapshot: {}", file, ex); + // A stale file should not block restoring the account snapshot. + } + } + } + } + + private void uploadSnapshot(LauncherSettingsSnapshot snapshot, boolean announce) { + uploadSnapshot(snapshot, announce, false); + } + + private void uploadPreferences(LauncherSettingsSnapshot snapshot, boolean announce) { + uploadSnapshot(snapshot, announce, true); + } + + private void uploadSnapshot(LauncherSettingsSnapshot snapshot, boolean announce, boolean preferencesOnly) { + if (!signedIn.getAsBoolean()) { + return; + } + snapshot.refreshHash(); + String snapshotHash = snapshot.computeHash(); + if (snapshotHash.equals(lastKnownRemoteHash)) { + return; + } + if (!uploading.compareAndSet(false, true)) { + return; + } + feedback.runAsync("Saving launcher preferences to Modtale...", + () -> preferencesOnly + ? apiClient.updateLauncherSettingsPreferences(snapshot) + : apiClient.updateLauncherSettings(snapshot), + saved -> { + uploading.set(false); + lastKnownRemoteHash = saved == null || hashMatches(saved, snapshotHash) + ? snapshotHash + : saved.effectiveHash(); + lastKnownRemoteInstalledProjectsHash = saved == null || hashMatches(saved, snapshotHash) + ? snapshot.installedProjectsHash() + : saved.installedProjectsHash(); + if (announce) { + feedback.log("Saved this device's launcher preferences to Modtale."); + feedback.showToast("Preferences saved", "This device is now the account snapshot."); + } + }, + error -> uploading.set(false)); + } + + private boolean canUploadPreferencesOnly(LauncherSettingsSnapshot snapshot) { + String installedProjectsHash = snapshot.installedProjectsHash(); + return !lastKnownRemoteInstalledProjectsHash.isBlank() + && installedProjectsHash.equals(lastKnownRemoteInstalledProjectsHash); + } + + private boolean hashMatches(LauncherSettingsSnapshot remote, String localHash) { + if (localHash == null || localHash.isBlank()) { + return false; + } + return localHash.equals(remote.effectiveHash()) || localHash.equals(remote.computeHash()); + } + + private static String plural(int count) { + return count == 1 ? "" : "s"; + } + + private record RestoreResult(String message) { + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/account/LauncherAccountController.java b/launcher/src/main/java/net/modtale/launcher/ui/account/LauncherAccountController.java new file mode 100644 index 00000000..5ea35e73 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/account/LauncherAccountController.java @@ -0,0 +1,301 @@ +package net.modtale.launcher.ui.account; + +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; +import javafx.application.Platform; +import javafx.scene.control.Label; +import net.modtale.launcher.api.ModtaleApiClient; +import net.modtale.launcher.api.ModtaleApiException; +import net.modtale.launcher.model.auth.SignInResponse; +import net.modtale.launcher.model.user.CurrentUser; +import net.modtale.launcher.sync.LauncherSettingsSyncService; +import net.modtale.launcher.ui.feedback.LauncherFeedback; +import net.modtale.launcher.ui.settings.LauncherSettingsController; + +public final class LauncherAccountController { + + private final ModtaleApiClient apiClient; + private final LauncherSettingsController settingsController; + private final Label accountStatus = new Label("Signed out"); + + private LauncherFeedback feedback; + private Runnable onAutoCheckUpdates = () -> { + }; + private Runnable onSignedIn = () -> { + }; + private Runnable onSignedOut = () -> { + }; + private final List> currentUserListeners = new CopyOnWriteArrayList<>(); + private volatile CurrentUser currentUser; + private LauncherSettingsSyncService settingsSyncService; + + public LauncherAccountController( + ModtaleApiClient apiClient, + LauncherSettingsController settingsController + ) { + this.apiClient = apiClient; + this.settingsController = settingsController; + } + + public void attachFeedback(LauncherFeedback feedback) { + this.feedback = feedback; + } + + public void setOnAutoCheckUpdates(Runnable onAutoCheckUpdates) { + this.onAutoCheckUpdates = onAutoCheckUpdates == null ? () -> { + } : onAutoCheckUpdates; + } + + public void setOnSignedIn(Runnable onSignedIn) { + this.onSignedIn = onSignedIn == null ? () -> { + } : onSignedIn; + } + + public void setOnSignedOut(Runnable onSignedOut) { + this.onSignedOut = onSignedOut == null ? () -> { + } : onSignedOut; + } + + public void setSettingsSyncService(LauncherSettingsSyncService settingsSyncService) { + this.settingsSyncService = settingsSyncService; + } + + public Label statusLabel() { + return accountStatus; + } + + public void addCurrentUserListener(Consumer listener) { + if (listener == null) { + return; + } + currentUserListeners.add(listener); + Platform.runLater(() -> listener.accept(currentUser)); + } + + public boolean isSignedIn() { + return currentUser != null; + } + + public CurrentUser currentUser() { + return currentUser; + } + + public String displayName() { + return currentUser == null ? "Signed out" : currentUser.toString(); + } + + public boolean isProjectLiked(String projectId) { + return currentUser != null && currentUser.likesProject(projectId); + } + + public void syncLocalSettings() { + if (settingsSyncService != null) { + settingsSyncService.syncAfterLocalChange(); + } + } + + public String idleStatus() { + return currentUser == null ? "Signed out" : ""; + } + + public CurrentUser ensureSignedIn() { + if (currentUser != null) { + return currentUser; + } + throw new ModtaleApiException("Sign in with Modtale before continuing."); + } + + public void setCurrentUser(CurrentUser user) { + currentUser = user; + Platform.runLater(this::refreshStatus); + if (user != null) { + Platform.runLater(onSignedIn); + } + } + + public void restoreSession() { + if (!apiClient.hasStoredSession()) { + Platform.runLater(onSignedOut); + return; + } + + feedback.runAsync("Checking Modtale session...", + apiClient::currentUser, + user -> completeSignIn(user, true), + error -> { + currentUser = null; + apiClient.clearStoredSession(); + refreshStatus(); + onSignedOut.run(); + feedback.log("Please sign in with Modtale to continue."); + }); + } + + public void signIn() { + signInWithBrowser(); + } + + public void signIn(String username, char[] password, Consumer onResult) { + feedback.runAsync("Signing in with Modtale...", + () -> signInLocally(username, password), + result -> { + if (result.mfaRequired()) { + if (onResult != null) { + onResult.accept(result); + } + feedback.log("Two-factor authentication is required for this account."); + return; + } + completeSignIn(result.user(), true); + feedback.log("Signed in as " + result.user() + ". Downloads and updates will use your Modtale session."); + feedback.showToast("Signed in", "You're connected as " + result.user() + "."); + if (onResult != null) { + onResult.accept(result); + } + }, + error -> { + currentUser = null; + refreshStatus(); + onSignedOut.run(); + if (onResult != null) { + onResult.accept(SignInResult.failed(error.getMessage())); + } + }); + } + + public void validateMfa(String preAuthToken, String code, Consumer onResult) { + feedback.runAsync("Verifying two-factor code...", + () -> completeMfaSignIn(preAuthToken, code), + result -> { + completeSignIn(result.user(), true); + feedback.log("Signed in as " + result.user() + ". Downloads and updates will use your Modtale session."); + feedback.showToast("Signed in", "You're connected as " + result.user() + "."); + if (onResult != null) { + onResult.accept(result); + } + }, + error -> { + if (onResult != null) { + onResult.accept(SignInResult.failed(error.getMessage())); + } + }); + } + + public void signInWithBrowser() { + feedback.runAsync("Opening Modtale sign-in in your browser...", + () -> new LauncherAuthFlow(apiClient).authenticate(), + user -> { + completeSignIn(user, true); + feedback.log("Signed in as " + user + ". Downloads and updates will use your Modtale session."); + feedback.showToast("Signed in", "You're connected as " + user + "."); + }, + error -> { + currentUser = null; + refreshStatus(); + onSignedOut.run(); + }); + } + + public void signInWithOAuthProvider(String provider, String label, Consumer onResult) { + String providerLabel = label == null || label.isBlank() ? "OAuth" : label; + feedback.runAsync("Opening " + providerLabel + " sign-in...", + () -> new LauncherAuthFlow(apiClient).authenticateWithOAuthProvider(provider), + user -> { + completeSignIn(user, true); + feedback.log("Signed in as " + user + " with " + providerLabel + "."); + feedback.showToast("Signed in", "You're connected as " + user + "."); + if (onResult != null) { + onResult.accept(SignInResult.signedIn(user)); + } + }, + error -> { + currentUser = null; + refreshStatus(); + onSignedOut.run(); + if (onResult != null) { + onResult.accept(SignInResult.failed(error.getMessage())); + } + }); + } + + public void signOut() { + feedback.runAsync("Signing out...", () -> { + apiClient.logout(); + return null; + }, ignored -> { + currentUser = null; + refreshStatus(); + feedback.log("Signed out."); + onSignedOut.run(); + }, error -> { + currentUser = null; + apiClient.clearStoredSession(); + refreshStatus(); + onSignedOut.run(); + feedback.log("Local Modtale session cleared."); + }); + } + + private void completeSignIn(CurrentUser user, boolean autoCheckUpdates) { + currentUser = user; + refreshStatus(); + onSignedIn.run(); + if (settingsSyncService != null) { + settingsSyncService.checkOnSignIn(); + } + if (autoCheckUpdates + && settingsController.settings().isAutoCheckUpdates() + && !settingsController.settings().getInstalledProjects().isEmpty()) { + onAutoCheckUpdates.run(); + } + } + + private SignInResult signInLocally(String username, char[] password) { + try { + SignInResponse response = apiClient.signIn(username, password); + if (response != null && response.mfaRequired()) { + return SignInResult.mfa(response.preAuthToken()); + } + return SignInResult.signedIn(apiClient.currentUser()); + } finally { + if (password != null) { + Arrays.fill(password, '\0'); + } + } + } + + private SignInResult completeMfaSignIn(String preAuthToken, String code) { + apiClient.validateMfa(preAuthToken, code); + return SignInResult.signedIn(apiClient.currentUser()); + } + + private void refreshStatus() { + accountStatus.setText(currentUser == null ? "Signed out" : "Signed in as " + currentUser); + currentUserListeners.forEach(listener -> listener.accept(currentUser)); + } + + public record SignInResult(CurrentUser user, boolean mfaRequired, String preAuthToken, String errorMessage) { + + public static SignInResult signedIn(CurrentUser user) { + return new SignInResult(user, false, null, null); + } + + public static SignInResult mfa(String preAuthToken) { + return new SignInResult(null, true, preAuthToken, null); + } + + public static SignInResult failed(String errorMessage) { + return new SignInResult(null, false, null, errorMessage); + } + + public boolean success() { + return user != null; + } + + public boolean failed() { + return errorMessage != null && !errorMessage.isBlank(); + } + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/account/LauncherAccountMenu.java b/launcher/src/main/java/net/modtale/launcher/ui/account/LauncherAccountMenu.java new file mode 100644 index 00000000..e647cad2 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/account/LauncherAccountMenu.java @@ -0,0 +1,325 @@ +package net.modtale.launcher.ui.account; + +import static net.modtale.launcher.ui.common.LauncherUi.pseudo; + +import java.util.function.Consumer; +import java.util.function.Supplier; +import javafx.application.Platform; +import javafx.event.EventTarget; +import javafx.geometry.Bounds; +import javafx.geometry.Insets; +import javafx.geometry.Point2D; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.image.ImageView; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import javafx.scene.shape.Rectangle; +import javafx.stage.Screen; +import net.modtale.launcher.model.user.CurrentUser; +import net.modtale.launcher.ui.common.CachedImageLoader; +import net.modtale.launcher.ui.common.LauncherIcons; +import net.modtale.launcher.ui.common.LauncherOverlaySupport; +import net.modtale.launcher.ui.common.LauncherView; + +public final class LauncherAccountMenu { + + private static final double PROFILE_MENU_ESTIMATED_WIDTH = 256; + private static final double PROFILE_MENU_SCREEN_MARGIN = 8; + private static final double PROFILE_MENU_TOP_OFFSET = 8; + private static final double PROFILE_AVATAR_SIZE = 38; + + private final LauncherAccountController accountController; + private final CachedImageLoader accountImageLoader; + private final Supplier sceneLayer; + private final Consumer showView; + private final Supplier currentView; + private final Runnable showFollowing; + private final Runnable beforeShow; + + private Button menuButton; + private VBox dropdownPanel; + private Label eyebrowLabel; + private Label accountNameLabel; + private VBox actionGroup; + private Region separator; + private VBox dangerGroup; + + public LauncherAccountMenu( + LauncherAccountController accountController, + CachedImageLoader accountImageLoader, + Supplier sceneLayer, + Consumer showView, + Supplier currentView, + Runnable showFollowing, + Runnable beforeShow + ) { + this.accountController = accountController; + this.accountImageLoader = accountImageLoader; + this.sceneLayer = sceneLayer; + this.showView = showView; + this.currentView = currentView; + this.showFollowing = showFollowing; + this.beforeShow = beforeShow; + } + + public Button button() { + if (menuButton == null) { + menuButton = buildButton(); + } + return menuButton; + } + + public VBox panel() { + button(); + return dropdownPanel; + } + + public void hide() { + if (dropdownPanel != null) { + dropdownPanel.setVisible(false); + } + updateSelected(); + } + + public void updateSelected() { + if (menuButton == null) { + return; + } + LauncherView view = currentView.get(); + boolean utilityView = view == LauncherView.UPDATES + || view == LauncherView.NOTIFICATIONS + || view == LauncherView.SETTINGS; + boolean menuShowing = dropdownPanel != null && dropdownPanel.isVisible(); + pseudo(menuButton, "selected", menuShowing || utilityView); + } + + public void hideOnOutsidePress(EventTarget target) { + if (dropdownPanel == null || !dropdownPanel.isVisible()) { + return; + } + if (!LauncherOverlaySupport.eventTargetInside(target, dropdownPanel) + && !LauncherOverlaySupport.eventTargetInside(target, menuButton)) { + hide(); + } + } + + private Button buildButton() { + Label avatarInitial = new Label(initialFor(accountController.displayName())); + ImageView avatarImage = new ImageView(); + avatarImage.getStyleClass().add("avatar-image"); + avatarImage.setFitWidth(PROFILE_AVATAR_SIZE); + avatarImage.setFitHeight(PROFILE_AVATAR_SIZE); + avatarImage.setSmooth(true); + avatarImage.setMouseTransparent(true); + avatarImage.setClip(roundAvatarClip(PROFILE_AVATAR_SIZE)); + avatarImage.setVisible(false); + avatarImage.imageProperty().addListener((observable, oldImage, newImage) -> + avatarImage.setVisible(newImage != null)); + + StackPane avatar = new StackPane(avatarInitial, avatarImage); + avatar.getStyleClass().add("avatar"); + + Button button = new Button(null, avatar); + button.getStyleClass().add("profile-menu-button"); + button.setMnemonicParsing(false); + + dropdownPanel = buildPanel(); + accountController.addCurrentUserListener(user -> { + String displayName = accountController.displayName(); + avatarInitial.setText(initialFor(displayName)); + if (accountNameLabel != null) { + accountNameLabel.setText(displayName); + } + refreshMenuActions(user); + refreshAccountAvatar(user, avatarImage); + }); + button.setOnAction(event -> toggle()); + return button; + } + + private VBox buildPanel() { + eyebrowLabel = new Label("MODTALE ACCOUNT"); + eyebrowLabel.getStyleClass().add("profile-menu-eyebrow"); + accountNameLabel = new Label(accountController.displayName()); + accountNameLabel.getStyleClass().add("profile-menu-name"); + VBox summary = new VBox(4, eyebrowLabel, accountNameLabel); + summary.getStyleClass().add("profile-menu-summary"); + summary.setMaxWidth(Double.MAX_VALUE); + VBox.setMargin(summary, new Insets(0, 8, 8, 8)); + + VBox menuPanel = new VBox(); + menuPanel.getStyleClass().add("profile-dropdown-panel"); + menuPanel.setMinWidth(PROFILE_MENU_ESTIMATED_WIDTH); + menuPanel.setPrefWidth(PROFILE_MENU_ESTIMATED_WIDTH); + menuPanel.setMaxWidth(PROFILE_MENU_ESTIMATED_WIDTH); + menuPanel.setVisible(false); + menuPanel.setManaged(false); + + actionGroup = new VBox(2); + actionGroup.getStyleClass().add("profile-dropdown-action-group"); + VBox.setMargin(actionGroup, new Insets(0, 8, 0, 8)); + + separator = new Region(); + separator.getStyleClass().add("profile-dropdown-separator"); + VBox.setMargin(separator, new Insets(8, 16, 8, 16)); + + dangerGroup = new VBox(); + dangerGroup.getStyleClass().add("profile-dropdown-action-group"); + VBox.setMargin(dangerGroup, new Insets(0, 8, 0, 8)); + + menuPanel.getChildren().addAll(summary, actionGroup, separator, dangerGroup); + refreshMenuActions(accountController.currentUser()); + return menuPanel; + } + + private void refreshMenuActions(CurrentUser user) { + if (eyebrowLabel != null) { + eyebrowLabel.setText("MODTALE ACCOUNT"); + } + if (accountNameLabel != null) { + accountNameLabel.setText(accountController.displayName()); + } + if (actionGroup == null || dangerGroup == null || separator == null) { + return; + } + boolean signedIn = user != null; + actionGroup.getChildren().setAll( + dropdownItem("Library", LauncherIcons.Glyph.LAYERS, () -> showView.accept(LauncherView.LIBRARY), false), + signedIn + ? dropdownItem("Updates", LauncherIcons.Glyph.DOWNLOAD, () -> showView.accept(LauncherView.UPDATES), false) + : dropdownItem("Sign In", LauncherIcons.Glyph.USER, accountController::signIn, false), + signedIn + ? dropdownItem("Notifications", LauncherIcons.Glyph.BELL, () -> showView.accept(LauncherView.NOTIFICATIONS), false) + : dropdownItem("Settings", LauncherIcons.Glyph.SLIDERS, () -> showView.accept(LauncherView.SETTINGS), false) + ); + if (signedIn) { + actionGroup.getChildren().add(dropdownItem("Following", LauncherIcons.Glyph.USER, showFollowing, false)); + actionGroup.getChildren().add(dropdownItem("Settings", LauncherIcons.Glyph.SLIDERS, () -> showView.accept(LauncherView.SETTINGS), false)); + dangerGroup.getChildren().setAll(dropdownItem( + "Sign Out", + LauncherIcons.Glyph.LOG_OUT, + accountController::signOut, + true + )); + } else { + dangerGroup.getChildren().clear(); + } + separator.setVisible(signedIn); + separator.setManaged(signedIn); + dangerGroup.setVisible(signedIn); + dangerGroup.setManaged(signedIn); + } + + private Button dropdownItem( + String label, + LauncherIcons.Glyph icon, + Runnable action, + boolean danger + ) { + Button item = new Button(label); + item.getStyleClass().add("profile-dropdown-item"); + if (danger) { + item.getStyleClass().add("danger"); + } + item.setGraphic(LauncherIcons.icon(icon, 16)); + item.setAlignment(Pos.CENTER_LEFT); + item.setMaxWidth(Double.MAX_VALUE); + item.setOnAction(event -> { + hide(); + if (action != null) { + action.run(); + } + }); + return item; + } + + private void toggle() { + if (dropdownPanel.isVisible()) { + hide(); + return; + } + show(); + } + + private void show() { + StackPane layer = sceneLayer.get(); + if (layer == null || dropdownPanel == null || menuButton == null) { + return; + } + if (beforeShow != null) { + beforeShow.run(); + } + dropdownPanel.applyCss(); + dropdownPanel.autosize(); + position(); + dropdownPanel.setVisible(true); + dropdownPanel.toFront(); + updateSelected(); + Platform.runLater(this::position); + } + + private void position() { + StackPane layer = sceneLayer.get(); + if (layer == null || dropdownPanel == null || menuButton == null) { + return; + } + Bounds anchorBounds = menuButton.localToScene(menuButton.getBoundsInLocal()); + if (anchorBounds == null) { + return; + } + double centerX = anchorBounds.getMinX() + (anchorBounds.getWidth() / 2.0); + double centerY = anchorBounds.getMinY() + (anchorBounds.getHeight() / 2.0); + Point2D avatarBottomRight = layer.sceneToLocal( + centerX + (PROFILE_AVATAR_SIZE / 2.0), + centerY + (PROFILE_AVATAR_SIZE / 2.0) + ); + double width = dropdownPanel.getLayoutBounds().getWidth() > 0 + ? dropdownPanel.getLayoutBounds().getWidth() + : PROFILE_MENU_ESTIMATED_WIDTH; + double maxX = Math.max(PROFILE_MENU_SCREEN_MARGIN, + layer.getWidth() - width - PROFILE_MENU_SCREEN_MARGIN); + double x = LauncherOverlaySupport.clamp( + avatarBottomRight.getX() - width, + PROFILE_MENU_SCREEN_MARGIN, + maxX + ); + double y = avatarBottomRight.getY() + PROFILE_MENU_TOP_OFFSET; + dropdownPanel.relocate(x, y); + } + + private void refreshAccountAvatar(CurrentUser user, ImageView avatarImage) { + String avatarUrl = user == null ? null : user.avatarUrl(); + if (avatarUrl == null || avatarUrl.isBlank()) { + accountImageLoader.clear(avatarImage); + return; + } + double requestedSize = requestedAvatarImageSize(); + accountImageLoader.loadInto(avatarImage, avatarUrl, requestedSize, requestedSize); + } + + private static Rectangle roundAvatarClip(double size) { + Rectangle clip = new Rectangle(size, size); + clip.setArcWidth(size); + clip.setArcHeight(size); + return clip; + } + + private static double requestedAvatarImageSize() { + double scale = Screen.getScreens().stream() + .mapToDouble(screen -> Math.max(screen.getOutputScaleX(), screen.getOutputScaleY())) + .max() + .orElse(1); + return Math.ceil(PROFILE_AVATAR_SIZE * Math.max(1, Math.min(3, scale))); + } + + private static String initialFor(String name) { + if (name == null || name.isBlank() || "Signed out".equals(name)) { + return "M"; + } + return name.trim().substring(0, 1).toUpperCase(); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/account/LauncherAuthFlow.java b/launcher/src/main/java/net/modtale/launcher/ui/account/LauncherAuthFlow.java new file mode 100644 index 00000000..65186d39 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/account/LauncherAuthFlow.java @@ -0,0 +1,227 @@ +package net.modtale.launcher.ui.account; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import java.awt.Desktop; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.time.Duration; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import net.modtale.launcher.api.ModtaleApiClient; +import net.modtale.launcher.logging.LogSanitizer; +import net.modtale.launcher.model.user.CurrentUser; +import net.modtale.launcher.settings.LauncherConfig; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +public final class LauncherAuthFlow { + + private static final Logger LOG = LogManager.getLogger(LauncherAuthFlow.class); + private static final Duration AUTH_TIMEOUT = Duration.ofMinutes(5); + private static final String APP_NAME = "Modtale Launcher"; + private static final SecureRandom RANDOM = new SecureRandom(); + + private final ModtaleApiClient apiClient; + private final String siteBaseUrl; + + public LauncherAuthFlow(ModtaleApiClient apiClient) { + this.apiClient = apiClient; + this.siteBaseUrl = LauncherConfig.siteBaseUrl(); + } + + public CurrentUser authenticate() { + return authenticate(this::buildAuthUri); + } + + public CurrentUser authenticateWithOAuthProvider(String provider) { + return authenticate((callbackUri, state) -> buildOAuthAuthUri(provider, callbackUri, state)); + } + + private CurrentUser authenticate(AuthUriFactory authUriFactory) { + HttpServer callbackServer = null; + CompletableFuture codeFuture = new CompletableFuture<>(); + try { + String state = randomState(); + callbackServer = HttpServer.create(new InetSocketAddress(InetAddress.getByName("127.0.0.1"), 0), 0); + callbackServer.createContext("/callback", exchange -> handleCallback(exchange, codeFuture, state)); + callbackServer.setExecutor(null); + callbackServer.start(); + + URI callbackUri = URI.create("http://127.0.0.1:" + callbackServer.getAddress().getPort() + "/callback"); + openBrowser(authUriFactory.build(callbackUri, state)); + + String code = codeFuture.get(AUTH_TIMEOUT.toSeconds(), TimeUnit.SECONDS); + apiClient.exchangeLauncherCode(code); + return apiClient.currentUser(); + } catch (TimeoutException ex) { + LOG.warn("Launcher sign-in timed out.", ex); + throw new RuntimeException("Launcher sign-in timed out. Please try again.", ex); + } catch (CancellationException ex) { + LOG.warn("Launcher sign-in was cancelled.", ex); + throw ex; + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + LOG.warn("Launcher sign-in was interrupted.", ex); + throw new RuntimeException("Launcher sign-in was interrupted.", ex); + } catch (Exception ex) { + LOG.warn("Launcher sign-in failed.", ex); + if (ex instanceof RuntimeException runtimeException) { + throw runtimeException; + } + throw new RuntimeException(ex.getMessage(), ex); + } finally { + if (callbackServer != null) { + callbackServer.stop(0); + } + } + } + + private URI buildAuthUri(URI callbackUri, String state) { + String base = siteBaseUrl.replaceAll("/+$", ""); + String query = "redirect_uri=" + encode(callbackUri.toString()) + + "&state=" + encode(state) + + "&app_name=" + encode(APP_NAME); + return URI.create(base + "/launcher/auth?" + query); + } + + private URI buildOAuthAuthUri(String provider, URI callbackUri, String state) { + String normalizedProvider = provider == null ? "" : provider.trim().toLowerCase(java.util.Locale.ROOT); + if (!normalizedProvider.matches("[a-z0-9_-]+")) { + throw new RuntimeException("That OAuth provider is not valid."); + } + String base = apiClient.apiBaseUri().toString().replaceAll("/+$", ""); + String query = "redirect_uri=" + encode(callbackUri.toString()) + + "&state=" + encode(state) + + "&app_name=" + encode(APP_NAME); + return URI.create(base + "/auth/launcher/oauth/" + encodePath(normalizedProvider) + "?" + query); + } + + private void handleCallback(HttpExchange exchange, CompletableFuture codeFuture, String expectedState) throws IOException { + Map params = parseQuery(exchange.getRequestURI().getRawQuery()); + String responseTitle = "Launcher sign-in complete"; + String responseMessage = "You can return to Modtale Launcher."; + + String returnedState = params.get("state"); + String code = params.get("code"); + String error = params.get("error"); + if (error != null && !error.isBlank()) { + LOG.warn("Launcher sign-in callback returned an error: " + error); + codeFuture.completeExceptionally(new RuntimeException("Launcher sign-in failed: " + error)); + responseTitle = "Launcher sign-in failed"; + responseMessage = "Return to Modtale Launcher and try again."; + } else if (!expectedState.equals(returnedState)) { + LOG.warn("Launcher sign-in callback returned an unexpected state."); + codeFuture.completeExceptionally(new RuntimeException("Launcher sign-in returned an unexpected state.")); + responseTitle = "Launcher sign-in failed"; + responseMessage = "Return to Modtale Launcher and try again."; + } else if (code == null || code.isBlank()) { + LOG.warn("Launcher sign-in callback did not include an authorization code."); + codeFuture.completeExceptionally(new RuntimeException("Launcher sign-in did not return an authorization code.")); + responseTitle = "Launcher sign-in failed"; + responseMessage = "Return to Modtale Launcher and try again."; + } else { + codeFuture.complete(code); + } + + byte[] body = callbackPage(responseTitle, responseMessage).getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "text/html; charset=utf-8"); + exchange.sendResponseHeaders(200, body.length); + try (OutputStream output = exchange.getResponseBody()) { + output.write(body); + } + } + + private static void openBrowser(URI uri) { + if (!Desktop.isDesktopSupported() || !Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { + throw new RuntimeException("Desktop browser integration is not available. Could not open Modtale sign-in."); + } + try { + LOG.info("Opening sign-in URL " + LogSanitizer.uri(uri)); + Desktop.getDesktop().browse(uri); + } catch (IOException ex) { + LOG.warn("Could not open sign-in URL " + LogSanitizer.uri(uri), ex); + throw new RuntimeException("Could not open Modtale sign-in in your browser.", ex); + } + } + + private static String callbackPage(String title, String message) { + return """ + + + + + + %s + + + +

%s

%s

+ + """.formatted(escapeHtml(title), escapeHtml(title), escapeHtml(message)); + } + + private static Map parseQuery(String rawQuery) { + Map params = new LinkedHashMap<>(); + if (rawQuery == null || rawQuery.isBlank()) { + return params; + } + for (String pair : rawQuery.split("&")) { + int separator = pair.indexOf('='); + String key = separator >= 0 ? pair.substring(0, separator) : pair; + String value = separator >= 0 ? pair.substring(separator + 1) : ""; + params.put(decode(key), decode(value)); + } + return params; + } + + private static String randomState() { + byte[] bytes = new byte[32]; + RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private static String encode(String value) { + return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8); + } + + private static String encodePath(String value) { + return encode(value).replace("+", "%20"); + } + + private static String decode(String value) { + return URLDecoder.decode(value == null ? "" : value, StandardCharsets.UTF_8); + } + + private static String escapeHtml(String value) { + return value == null ? "" : value + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } + + @FunctionalInterface + private interface AuthUriFactory { + URI build(URI callbackUri, String state); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/account/LauncherAuthGate.java b/launcher/src/main/java/net/modtale/launcher/ui/account/LauncherAuthGate.java new file mode 100644 index 00000000..99583fcf --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/account/LauncherAuthGate.java @@ -0,0 +1,317 @@ +package net.modtale.launcher.ui.account; + +import static net.modtale.launcher.ui.common.LauncherUi.primaryButton; +import static net.modtale.launcher.ui.common.LauncherUi.secondaryButton; +import static net.modtale.launcher.ui.common.LauncherUi.setVisibleManaged; +import static net.modtale.launcher.ui.common.LauncherUi.styleInput; + +import java.util.List; +import java.util.Objects; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.PasswordField; +import javafx.scene.control.TextField; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import net.modtale.launcher.ui.common.LauncherIcons; + +public final class LauncherAuthGate { + + private static final List OAUTH_PROVIDERS = List.of( + new OAuthProvider("GitHub", "github", LauncherIcons.BrandGlyph.GITHUB), + new OAuthProvider("Discord", "discord", LauncherIcons.BrandGlyph.DISCORD), + new OAuthProvider("Google", "google", LauncherIcons.BrandGlyph.GOOGLE) + ); + + private final LauncherAccountController accountController; + + private Node view; + private Label statusLabel; + private Button signInButton; + private Button browserSignInButton; + private TextField usernameField; + private PasswordField passwordField; + private TextField mfaCodeField; + private VBox credentialsPane; + private VBox mfaPane; + private String preAuthToken; + + public LauncherAuthGate(LauncherAccountController accountController) { + this.accountController = accountController; + } + + public Node view() { + if (view == null) { + view = buildView(); + } + return view; + } + + public void show(String status, boolean allowSignIn) { + if (statusLabel != null) { + statusLabel.setText(status == null || status.isBlank() ? "Sign in with Modtale to continue." : status); + } + if (credentialsPane != null) { + credentialsPane.setDisable(!allowSignIn); + } + if (mfaPane != null) { + mfaPane.setDisable(!allowSignIn); + } + if (signInButton != null) { + signInButton.setDisable(!allowSignIn); + } + if (browserSignInButton != null) { + browserSignInButton.setDisable(!allowSignIn); + } + if (usernameField != null) { + usernameField.setDisable(!allowSignIn); + } + if (passwordField != null) { + passwordField.setDisable(!allowSignIn); + } + if (mfaCodeField != null) { + mfaCodeField.setDisable(!allowSignIn); + } + } + + private Node buildView() { + VBox gate = new VBox(18); + gate.getStyleClass().add("auth-gate"); + gate.setAlignment(Pos.CENTER); + + ImageView logo = new ImageView(new Image(Objects.requireNonNull(getClass() + .getResource("/net/modtale/launcher/ui/nativefx/assets/logo_light.png")).toExternalForm(), true)); + logo.setFitHeight(42); + logo.setPreserveRatio(true); + + Label title = new Label("Sign in with Modtale"); + title.getStyleClass().add("auth-title"); + + statusLabel = new Label("Checking for an existing Modtale session..."); + statusLabel.getStyleClass().add("auth-status"); + statusLabel.setWrapText(true); + statusLabel.setMaxWidth(360); + statusLabel.setAlignment(Pos.CENTER); + statusLabel.setTextAlignment(javafx.scene.text.TextAlignment.CENTER); + + credentialsPane = credentialsPane(); + mfaPane = mfaPane(); + setVisibleManaged(mfaPane, false); + + VBox card = new VBox(16, logo, title, credentialsPane, mfaPane, statusLabel); + card.getStyleClass().add("auth-card"); + card.setAlignment(Pos.CENTER); + gate.getChildren().add(card); + return gate; + } + + private VBox credentialsPane() { + usernameField = new TextField(); + usernameField.setPromptText("Email or username"); + + passwordField = new PasswordField(); + passwordField.setPromptText("Password"); + styleInput(usernameField, passwordField); + usernameField.setMaxWidth(Double.MAX_VALUE); + passwordField.setMaxWidth(Double.MAX_VALUE); + usernameField.setOnAction(event -> passwordField.requestFocus()); + passwordField.setOnAction(event -> submitCredentials()); + + signInButton = primaryButton("Sign In"); + signInButton.setGraphic(LauncherIcons.icon(LauncherIcons.Glyph.USER, 16)); + signInButton.setMaxWidth(Double.MAX_VALUE); + signInButton.setOnAction(event -> submitCredentials()); + + browserSignInButton = secondaryButton("Use browser sign-in"); + browserSignInButton.setGraphic(LauncherIcons.icon(LauncherIcons.Glyph.GLOBE, 16)); + browserSignInButton.setMaxWidth(Double.MAX_VALUE); + browserSignInButton.setOnAction(event -> { + show("Opening Modtale sign-in in your browser...", false); + accountController.signInWithBrowser(); + }); + + VBox fields = new VBox(10, usernameField, passwordField); + fields.setMaxWidth(Double.MAX_VALUE); + + VBox pane = new VBox(12, oauthPane(), separator(), fields, signInButton, browserSignInButton); + pane.getStyleClass().add("auth-form"); + pane.setAlignment(Pos.CENTER); + pane.setMaxWidth(360); + return pane; + } + + private Node oauthPane() { + VBox providers = new VBox(10); + providers.getStyleClass().add("auth-oauth-list"); + for (int index = 0; index < OAUTH_PROVIDERS.size(); index += 2) { + HBox row = new HBox(10); + row.getStyleClass().add("auth-oauth-row"); + row.getChildren().add(oauthButton(OAUTH_PROVIDERS.get(index))); + if (index + 1 < OAUTH_PROVIDERS.size()) { + row.getChildren().add(oauthButton(OAUTH_PROVIDERS.get(index + 1))); + } + providers.getChildren().add(row); + } + return providers; + } + + private Button oauthButton(OAuthProvider provider) { + Button button = secondaryButton(provider.label()); + button.getStyleClass().add("auth-oauth-button"); + button.setGraphic(LauncherIcons.brandIcon(provider.icon(), 16)); + button.setMaxWidth(Double.MAX_VALUE); + button.setOnAction(event -> submitOAuth(provider)); + HBox.setHgrow(button, Priority.ALWAYS); + return button; + } + + private Node separator() { + Label label = new Label("or use email"); + label.getStyleClass().add("auth-separator-label"); + label.getStyleClass().add("auth-separator"); + label.setAlignment(Pos.CENTER); + label.setMaxWidth(Double.MAX_VALUE); + return label; + } + + private VBox mfaPane() { + Label title = new Label("Enter your two-factor code"); + title.getStyleClass().add("auth-step-title"); + + mfaCodeField = new TextField(); + mfaCodeField.setPromptText("000000"); + mfaCodeField.getStyleClass().addAll("input", "auth-mfa-input"); + mfaCodeField.setMaxWidth(Double.MAX_VALUE); + mfaCodeField.textProperty().addListener((observable, previous, current) -> { + String digits = current == null ? "" : current.replaceAll("\\D", ""); + if (digits.length() > 6) { + digits = digits.substring(0, 6); + } + if (!digits.equals(current)) { + mfaCodeField.setText(digits); + } + }); + mfaCodeField.setOnAction(event -> submitMfa()); + + Button verify = primaryButton("Verify"); + verify.setGraphic(LauncherIcons.icon(LauncherIcons.Glyph.CHECK, 16)); + verify.setMaxWidth(Double.MAX_VALUE); + verify.setOnAction(event -> submitMfa()); + + Button back = secondaryButton("Back"); + back.setGraphic(LauncherIcons.icon(LauncherIcons.Glyph.CHEVRON_LEFT, 16)); + back.setMaxWidth(Double.MAX_VALUE); + back.setOnAction(event -> showCredentials("Sign in with Modtale to continue.", true)); + + HBox actions = new HBox(10, back, verify); + actions.setAlignment(Pos.CENTER); + HBox.setHgrow(back, Priority.ALWAYS); + HBox.setHgrow(verify, Priority.ALWAYS); + + VBox pane = new VBox(12, title, mfaCodeField, actions); + pane.getStyleClass().add("auth-form"); + pane.setAlignment(Pos.CENTER); + pane.setMaxWidth(360); + VBox.setMargin(title, new Insets(0, 0, 2, 0)); + return pane; + } + + private void submitCredentials() { + String username = usernameField == null ? "" : usernameField.getText().trim(); + String password = passwordField == null ? "" : passwordField.getText(); + if (username.isBlank()) { + show("Enter your email or username.", true); + usernameField.requestFocus(); + return; + } + if (password.isBlank()) { + show("Enter your password.", true); + passwordField.requestFocus(); + return; + } + + show("Signing in with Modtale...", false); + accountController.signIn(username, password.toCharArray(), result -> { + if (result.mfaRequired()) { + preAuthToken = result.preAuthToken(); + if (passwordField != null) { + passwordField.clear(); + } + showMfa(); + return; + } + if (result.failed()) { + showCredentials(result.errorMessage(), true); + } else if (result.success() && passwordField != null) { + passwordField.clear(); + } + }); + } + + private void submitOAuth(OAuthProvider provider) { + show("Opening " + provider.label() + " sign-in...", false); + accountController.signInWithOAuthProvider(provider.id(), provider.label(), result -> { + if (result.failed()) { + showCredentials(result.errorMessage(), true); + } + }); + } + + private void submitMfa() { + String code = mfaCodeField == null ? "" : mfaCodeField.getText().trim(); + if (preAuthToken == null || preAuthToken.isBlank()) { + showCredentials("Your two-factor login session expired. Sign in again.", true); + return; + } + if (!code.matches("\\d{6}")) { + show("Enter the 6-digit code from your authenticator app.", true); + mfaCodeField.requestFocus(); + return; + } + + show("Verifying two-factor code...", false); + accountController.validateMfa(preAuthToken, code, result -> { + if (result.failed()) { + show(result.errorMessage(), true); + if (mfaCodeField != null) { + mfaCodeField.selectAll(); + mfaCodeField.requestFocus(); + } + } else { + preAuthToken = null; + if (mfaCodeField != null) { + mfaCodeField.clear(); + } + } + }); + } + + private void showMfa() { + setVisibleManaged(credentialsPane, false); + setVisibleManaged(mfaPane, true); + if (mfaCodeField != null) { + mfaCodeField.clear(); + mfaCodeField.requestFocus(); + } + show("Enter the 6-digit code from your authenticator app.", true); + } + + private void showCredentials(String status, boolean allowSignIn) { + preAuthToken = null; + setVisibleManaged(mfaPane, false); + setVisibleManaged(credentialsPane, true); + show(status, allowSignIn); + if (usernameField != null) { + usernameField.requestFocus(); + } + } + + private record OAuthProvider(String label, String id, LauncherIcons.BrandGlyph icon) { + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/account/LauncherHytaleAuthGate.java b/launcher/src/main/java/net/modtale/launcher/ui/account/LauncherHytaleAuthGate.java new file mode 100644 index 00000000..78bc0bff --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/account/LauncherHytaleAuthGate.java @@ -0,0 +1,111 @@ +package net.modtale.launcher.ui.account; + +import static net.modtale.launcher.ui.common.LauncherUi.primaryButton; + +import java.util.Objects; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.layout.VBox; +import net.modtale.launcher.hytale.HytaleAuthService; +import net.modtale.launcher.ui.common.LauncherIcons; +import net.modtale.launcher.ui.feedback.LauncherFeedback; +import net.modtale.launcher.ui.settings.LauncherSettingsController; + +public final class LauncherHytaleAuthGate { + + private final HytaleAuthService hytaleAuthService; + private final LauncherSettingsController settingsController; + private final LauncherFeedback feedback; + private final Runnable onLinked; + + private Node view; + private Label statusLabel; + private Button signInButton; + + public LauncherHytaleAuthGate( + HytaleAuthService hytaleAuthService, + LauncherSettingsController settingsController, + LauncherFeedback feedback, + Runnable onLinked + ) { + this.hytaleAuthService = hytaleAuthService; + this.settingsController = settingsController; + this.feedback = feedback; + this.onLinked = onLinked == null ? () -> { + } : onLinked; + } + + public Node view() { + if (view == null) { + view = buildView(); + } + return view; + } + + public void show(String status, boolean allowSignIn) { + if (statusLabel != null) { + statusLabel.setText(status == null || status.isBlank() + ? "Sign in with Hytale to use Modtale Launcher." + : status); + } + if (signInButton != null) { + signInButton.setDisable(!allowSignIn); + } + } + + private Node buildView() { + VBox gate = new VBox(18); + gate.getStyleClass().add("auth-gate"); + gate.setAlignment(Pos.CENTER); + + ImageView logo = new ImageView(new Image(Objects.requireNonNull(getClass() + .getResource("/net/modtale/launcher/ui/nativefx/assets/logo_light.png")).toExternalForm(), true)); + logo.setFitHeight(42); + logo.setPreserveRatio(true); + + Label title = new Label("Link a Hytale account"); + title.getStyleClass().add("auth-title"); + + statusLabel = new Label("Sign in with Hytale to use Modtale Launcher."); + statusLabel.getStyleClass().add("auth-status"); + statusLabel.setWrapText(true); + statusLabel.setMaxWidth(360); + statusLabel.setAlignment(Pos.CENTER); + statusLabel.setTextAlignment(javafx.scene.text.TextAlignment.CENTER); + + signInButton = primaryButton("Sign In With Hytale"); + signInButton.setGraphic(LauncherIcons.icon(LauncherIcons.Glyph.USER, 16)); + signInButton.setMaxWidth(Double.MAX_VALUE); + signInButton.setOnAction(event -> signInHytale()); + + Label note = new Label("Modtale sign-in is optional for local play, but Hytale launch uses official Hytale authentication."); + note.getStyleClass().add("auth-status"); + note.setWrapText(true); + note.setMaxWidth(360); + note.setAlignment(Pos.CENTER); + note.setTextAlignment(javafx.scene.text.TextAlignment.CENTER); + + VBox card = new VBox(16, logo, title, statusLabel, signInButton, note); + card.getStyleClass().add("auth-card"); + card.setAlignment(Pos.CENTER); + gate.getChildren().add(card); + return gate; + } + + private void signInHytale() { + settingsController.saveFromFields(false); + show("Opening Hytale sign-in in your browser...", false); + feedback.runAsync("Opening Hytale sign-in in your browser...", () -> + hytaleAuthService.loginAndSave(settingsController.settings()), session -> { + settingsController.reloadFromStore(); + feedback.log("Signed in with Hytale as " + session + "."); + feedback.showToast("Hytale ready", "Signed in as " + session + "."); + show("Signed in with Hytale.", true); + onLinked.run(); + }, error -> show("Hytale sign-in failed. Try again when Hytale authentication is available.", true)); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/activity/LauncherFollowingController.java b/launcher/src/main/java/net/modtale/launcher/ui/activity/LauncherFollowingController.java new file mode 100644 index 00000000..0c9743e9 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/activity/LauncherFollowingController.java @@ -0,0 +1,278 @@ +package net.modtale.launcher.ui.activity; + +import static net.modtale.launcher.ui.common.LauncherUi.setVisibleManaged; +import static net.modtale.launcher.ui.common.LauncherUi.value; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.List; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.Tooltip; +import javafx.scene.image.ImageView; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import javafx.scene.shape.Rectangle; +import net.modtale.launcher.api.ModtaleApiClient; +import net.modtale.launcher.model.user.CurrentUser; +import net.modtale.launcher.model.user.UserSummary; +import net.modtale.launcher.ui.account.LauncherAccountController; +import net.modtale.launcher.ui.common.CachedImageLoader; +import net.modtale.launcher.ui.common.LauncherExternalLinks; +import net.modtale.launcher.ui.common.LauncherIcons; +import net.modtale.launcher.ui.feedback.LauncherFeedback; + +public final class LauncherFollowingController { + + private static final double AVATAR_SIZE = 40; + + private final ModtaleApiClient apiClient; + private final LauncherAccountController accountController; + private final LauncherFeedback feedback; + private final CachedImageLoader imageLoader; + private final StackPane modalLayer = new StackPane(); + private final VBox userList = new VBox(2); + private final Label summary = new Label("No creators loaded"); + + private boolean modalBuilt; + private boolean loading; + private List users = List.of(); + + public LauncherFollowingController( + ModtaleApiClient apiClient, + LauncherAccountController accountController, + LauncherFeedback feedback, + CachedImageLoader imageLoader + ) { + this.apiClient = apiClient; + this.accountController = accountController; + this.feedback = feedback; + this.imageLoader = imageLoader; + accountController.addCurrentUserListener(user -> { + if (user == null) { + users = List.of(); + loading = false; + hideModal(); + renderUsers(); + } + }); + } + + public Node modal() { + if (!modalBuilt) { + buildModal(); + } + return modalLayer; + } + + public void showModal() { + modal(); + setVisibleManaged(modalLayer, true); + modalLayer.toFront(); + refresh(); + } + + public void hideModal() { + setVisibleManaged(modalLayer, false); + } + + public void refresh() { + CurrentUser user = accountController.currentUser(); + if (user == null || user.id() == null || user.id().isBlank()) { + loading = false; + users = List.of(); + renderUsers(); + return; + } + loading = true; + renderUsers(); + feedback.runAsync("Loading following...", + () -> apiClient.getFollowing(user.id()), + loaded -> { + loading = false; + users = List.copyOf(loaded); + renderUsers(); + feedback.log("Loaded " + users.size() + " followed creators."); + }, + error -> { + loading = false; + renderUsers(); + }); + } + + private void buildModal() { + modalBuilt = true; + modalLayer.getStyleClass().add("following-modal-layer"); + setVisibleManaged(modalLayer, false); + + Region scrim = new Region(); + scrim.getStyleClass().add("following-modal-scrim"); + scrim.setMaxSize(Double.MAX_VALUE, Double.MAX_VALUE); + scrim.setOnMouseClicked(event -> hideModal()); + + Label title = new Label("Following"); + title.getStyleClass().add("following-modal-title"); + title.setGraphic(LauncherIcons.icon(LauncherIcons.Glyph.USER, 18)); + title.setGraphicTextGap(8); + + Region headerSpacer = new Region(); + HBox.setHgrow(headerSpacer, Priority.ALWAYS); + + Button close = new Button(null, LauncherIcons.icon(LauncherIcons.Glyph.X, 16)); + close.getStyleClass().addAll("icon-btn", "following-modal-close"); + close.setMnemonicParsing(false); + close.setAccessibleText("Close following"); + close.setTooltip(new Tooltip("Close")); + close.setOnAction(event -> hideModal()); + + HBox header = new HBox(10, title, headerSpacer, close); + header.getStyleClass().add("following-modal-header"); + header.setAlignment(Pos.CENTER_LEFT); + + summary.getStyleClass().add("following-modal-summary"); + userList.getStyleClass().add("following-modal-list"); + + ScrollPane scrollPane = new ScrollPane(userList); + scrollPane.getStyleClass().add("following-modal-scroll"); + scrollPane.setFitToWidth(true); + scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + scrollPane.setMaxHeight(460); + VBox.setVgrow(scrollPane, Priority.ALWAYS); + + VBox card = new VBox(0, header, summary, scrollPane); + card.getStyleClass().add("following-modal-card"); + card.setMaxWidth(460); + card.setMaxHeight(620); + StackPane.setAlignment(card, Pos.CENTER); + StackPane.setMargin(card, new Insets(24)); + + modalLayer.getChildren().setAll(scrim, card); + renderUsers(); + } + + private void renderUsers() { + if (!modalBuilt) { + return; + } + userList.getChildren().clear(); + if (loading) { + setVisibleManaged(summary, true); + summary.setText("Loading followed creators..."); + Label loadingLabel = new Label("Loading..."); + loadingLabel.getStyleClass().add("following-loading"); + StackPane loadingPane = new StackPane(loadingLabel); + loadingPane.getStyleClass().add("following-loading-pane"); + userList.getChildren().add(loadingPane); + return; + } + if (users.isEmpty()) { + setVisibleManaged(summary, false); + userList.getChildren().add(followingEmptyState()); + return; + } + setVisibleManaged(summary, true); + summary.setText(users.size() + " followed creator" + (users.size() == 1 ? "" : "s")); + for (UserSummary user : users) { + userList.getChildren().add(userRow(user)); + } + } + + private VBox followingEmptyState() { + VBox empty = new VBox(8); + empty.getStyleClass().add("following-empty-state"); + empty.setAlignment(Pos.CENTER); + + Label title = new Label("Not following anyone"); + title.getStyleClass().add("following-empty-title"); + Label subtitle = new Label("Follow creators on Modtale to see them here."); + subtitle.getStyleClass().add("following-empty-subtitle"); + + empty.getChildren().addAll(title, subtitle); + return empty; + } + + private Node userRow(UserSummary user) { + HBox row = new HBox(12); + row.getStyleClass().add("following-row"); + row.setAlignment(Pos.CENTER_LEFT); + + StackPane avatar = avatar(user); + Label name = new Label(value(user.username(), "Unknown creator")); + name.getStyleClass().add("following-name"); + VBox copy = new VBox(3, name); + String role = primaryRole(user.roles()); + if (!role.isBlank()) { + Label roleLabel = new Label(role); + roleLabel.getStyleClass().add("following-role"); + copy.getChildren().add(roleLabel); + } + HBox.setHgrow(copy, Priority.ALWAYS); + + Button open = new Button(null, LauncherIcons.icon(LauncherIcons.Glyph.EXTERNAL_LINK, 15)); + open.getStyleClass().addAll("icon-btn", "following-open-button"); + open.setAccessibleText("Open creator profile"); + open.setTooltip(new Tooltip("Open creator profile")); + open.addEventHandler(MouseEvent.MOUSE_CLICKED, MouseEvent::consume); + open.setOnAction(event -> { + hideModal(); + LauncherExternalLinks.open(creatorPath(user), feedback::showToast); + }); + + row.setOnMouseClicked(event -> { + hideModal(); + LauncherExternalLinks.open(creatorPath(user), feedback::showToast); + }); + row.getChildren().addAll(avatar, copy, open); + return row; + } + + private StackPane avatar(UserSummary user) { + Label initial = new Label(initialFor(user.username())); + ImageView image = new ImageView(); + image.getStyleClass().add("following-avatar-image"); + image.setFitWidth(AVATAR_SIZE); + image.setFitHeight(AVATAR_SIZE); + image.setSmooth(true); + Rectangle clip = new Rectangle(AVATAR_SIZE, AVATAR_SIZE); + clip.setArcWidth(AVATAR_SIZE); + clip.setArcHeight(AVATAR_SIZE); + image.setClip(clip); + imageLoader.loadInto(image, user.avatarUrl(), AVATAR_SIZE, AVATAR_SIZE); + StackPane avatar = new StackPane(initial, image); + avatar.getStyleClass().add("following-avatar"); + return avatar; + } + + private static String creatorPath(UserSummary user) { + String handle = user.username() == null || user.username().isBlank() + ? user.id() + : user.username(); + return "/creator/" + encodePath(handle); + } + + private static String encodePath(String value) { + return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8).replace("+", "%20"); + } + + private static String primaryRole(List roles) { + if (roles == null || roles.isEmpty() || "USER".equals(roles.getFirst())) { + return ""; + } + return roles.getFirst(); + } + + private static String initialFor(String name) { + if (name == null || name.isBlank()) { + return "M"; + } + return name.trim().substring(0, 1).toUpperCase(); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/activity/LauncherNotificationsController.java b/launcher/src/main/java/net/modtale/launcher/ui/activity/LauncherNotificationsController.java new file mode 100644 index 00000000..9b2eb6a4 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/activity/LauncherNotificationsController.java @@ -0,0 +1,423 @@ +package net.modtale.launcher.ui.activity; + +import static net.modtale.launcher.ui.common.LauncherUi.dangerButton; +import static net.modtale.launcher.ui.common.LauncherUi.primaryButton; +import static net.modtale.launcher.ui.common.LauncherUi.pseudo; +import static net.modtale.launcher.ui.common.LauncherUi.secondaryButton; +import static net.modtale.launcher.ui.common.LauncherUi.value; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.Tooltip; +import javafx.scene.image.ImageView; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import javafx.scene.shape.Rectangle; +import net.modtale.launcher.api.ModtaleApiClient; +import net.modtale.launcher.model.notification.LauncherNotification; +import net.modtale.launcher.model.user.CurrentUser; +import net.modtale.launcher.ui.account.LauncherAccountController; +import net.modtale.launcher.ui.common.CachedImageLoader; +import net.modtale.launcher.ui.common.LauncherExternalLinks; +import net.modtale.launcher.ui.common.LauncherIcons; +import net.modtale.launcher.ui.common.LauncherView; +import net.modtale.launcher.ui.feedback.LauncherFeedback; + +public final class LauncherNotificationsController { + + private static final DateTimeFormatter NOTIFICATION_DATE = DateTimeFormatter.ofPattern("MMM d, yyyy"); + private static final double NOTIFICATION_ICON_SIZE = 40; + + private final ModtaleApiClient apiClient; + private final LauncherAccountController accountController; + private final LauncherFeedback feedback; + private final CachedImageLoader imageLoader; + private final VBox notificationList = new VBox(0); + private final Label notificationSummary = new Label("No notifications loaded"); + private final ToggleControl projectUpdates = new ToggleControl( + "Favorite Project Updates", + "Notify me when projects I've favorited release new versions." + ); + private final ToggleControl creatorUploads = new ToggleControl( + "New Creator Uploads", + "Notify me when creators I follow upload new projects." + ); + private final ToggleControl newComments = new ToggleControl( + "New Comments", + "Get notified when someone comments on your project." + ); + private final ToggleControl newFollowers = new ToggleControl( + "New Followers", + "Notify me when someone starts following me." + ); + private final ToggleControl dependencyUpdates = new ToggleControl( + "Dependency Updates", + "Alert me when a project I depend on releases a new version." + ); + + private Node view; + private List notifications = List.of(); + + public LauncherNotificationsController( + ModtaleApiClient apiClient, + LauncherAccountController accountController, + LauncherFeedback feedback, + CachedImageLoader imageLoader + ) { + this.apiClient = apiClient; + this.accountController = accountController; + this.feedback = feedback; + this.imageLoader = imageLoader; + accountController.addCurrentUserListener(this::applyPreferences); + } + + public Node view() { + if (view == null) { + view = buildView(); + applyPreferences(accountController.currentUser()); + renderNotifications(); + } + return view; + } + + public void refresh() { + applyPreferences(accountController.currentUser()); + loadNotifications(); + } + + private Node buildView() { + VBox root = new VBox(24); + root.setUserData(LauncherView.NOTIFICATIONS); + root.getStyleClass().addAll("view", "account-view"); + + VBox preferences = new VBox(0); + preferences.getStyleClass().add("account-card"); + preferences.getChildren().addAll( + projectUpdates.node(), + creatorUploads.node(), + newComments.node(), + newFollowers.node(), + dependencyUpdates.node() + ); + + Button save = primaryButton("Save Changes"); + save.setGraphic(LauncherIcons.icon(LauncherIcons.Glyph.SAVE, 14)); + save.setOnAction(event -> savePreferences(save)); + HBox saveRow = new HBox(save); + saveRow.setAlignment(Pos.CENTER_RIGHT); + + VBox recent = new VBox(0); + recent.getStyleClass().add("account-card"); + Button refresh = iconAction(LauncherIcons.Glyph.REFRESH_CW, "Refresh"); + refresh.setOnAction(event -> loadNotifications()); + Button markAll = secondaryButton("Mark All Read"); + markAll.setGraphic(LauncherIcons.icon(LauncherIcons.Glyph.CHECK, 13)); + markAll.setOnAction(event -> markAllRead(markAll)); + Button clear = secondaryButton("Clear All"); + clear.setGraphic(LauncherIcons.icon(LauncherIcons.Glyph.TRASH, 13)); + clear.setOnAction(event -> clearAll(clear)); + HBox recentActions = new HBox(8, refresh, markAll, clear); + recentActions.setAlignment(Pos.CENTER_RIGHT); + HBox recentHeader = sectionHeader("Notifications", "Recent account activity and requests.", recentActions); + notificationSummary.getStyleClass().add("account-section-meta"); + VBox.setMargin(notificationSummary, new Insets(0, 18, 12, 18)); + recent.getChildren().addAll(recentHeader, notificationSummary, notificationList); + + root.getChildren().addAll(preferences, saveRow, recent); + return root; + } + + private HBox sectionHeader(String title, String subtitle, Node actions) { + Label heading = new Label(title); + heading.getStyleClass().add("account-section-title"); + Label sub = new Label(subtitle); + sub.getStyleClass().add("account-section-subtitle"); + VBox copy = new VBox(3, heading, sub); + HBox header = new HBox(12, copy); + header.getStyleClass().add("account-section-header"); + header.setAlignment(Pos.CENTER_LEFT); + HBox.setHgrow(copy, Priority.ALWAYS); + if (actions != null) { + header.getChildren().add(actions); + } + return header; + } + + private void loadNotifications() { + feedback.runAsync("Loading notifications...", + apiClient::getNotifications, + loaded -> { + notifications = List.copyOf(loaded); + renderNotifications(); + feedback.log("Loaded " + notifications.size() + " notifications."); + }); + } + + private void savePreferences(Button save) { + save.setDisable(true); + CurrentUser.NotificationPreferences preferences = new CurrentUser.NotificationPreferences( + projectUpdates.value(), + creatorUploads.value(), + newComments.value(), + newFollowers.value(), + dependencyUpdates.value() + ); + feedback.runAsync("Saving notification preferences...", () -> { + apiClient.updateNotificationPreferences(preferences); + return apiClient.currentUser(); + }, user -> { + save.setDisable(false); + accountController.setCurrentUser(user); + feedback.showToast("Saved", "Notification preferences were updated."); + }, error -> save.setDisable(false)); + } + + private void clearAll(Button clear) { + clear.setDisable(true); + feedback.runAsync("Clearing notifications...", () -> { + apiClient.clearNotifications(); + return List.of(); + }, ignored -> { + clear.setDisable(false); + notifications = List.of(); + renderNotifications(); + feedback.showToast("Notifications cleared", "Your notification list is empty."); + }, error -> clear.setDisable(false)); + } + + private void markAllRead(Button markAll) { + markAll.setDisable(true); + feedback.runAsync("Marking notifications as read...", () -> { + apiClient.markAllNotificationsRead(); + return apiClient.getNotifications(); + }, loaded -> { + markAll.setDisable(false); + notifications = List.copyOf(loaded); + renderNotifications(); + }, error -> markAll.setDisable(false)); + } + + private void dismiss(LauncherNotification notification) { + feedback.runAsync("Dismissing notification...", () -> { + apiClient.deleteNotification(notification.id()); + return notification.id(); + }, id -> { + notifications = notifications.stream() + .filter(item -> !id.equals(item.id())) + .toList(); + renderNotifications(); + }); + } + + private void toggleRead(LauncherNotification notification) { + boolean nextRead = !notification.read(); + feedback.runAsync(nextRead ? "Marking notification as read..." : "Marking notification as unread...", () -> { + apiClient.markNotificationRead(notification.id(), nextRead); + return apiClient.getNotifications(); + }, loaded -> { + notifications = List.copyOf(loaded); + renderNotifications(); + }); + } + + private void resolveAction(LauncherNotification notification, boolean accept) { + feedback.runAsync((accept ? "Accepting" : "Declining") + " notification request...", () -> { + apiClient.resolveNotificationAction(notification, accept); + apiClient.deleteNotification(notification.id()); + return apiClient.getNotifications(); + }, loaded -> { + notifications = List.copyOf(loaded); + renderNotifications(); + feedback.showToast(accept ? "Accepted" : "Declined", "Notification request updated."); + }); + } + + private void renderNotifications() { + notificationList.getChildren().clear(); + int unread = (int) notifications.stream().filter(item -> !item.read()).count(); + if (notifications.isEmpty()) { + notificationSummary.setText(""); + notificationSummary.setVisible(false); + notificationSummary.setManaged(false); + return; + } + notificationSummary.setText(notifications.size() + " total - " + unread + " unread"); + notificationSummary.setVisible(true); + notificationSummary.setManaged(true); + for (LauncherNotification notification : notifications) { + notificationList.getChildren().add(notificationRow(notification)); + } + } + + private Node notificationRow(LauncherNotification notification) { + HBox row = new HBox(12); + row.getStyleClass().add("notification-row"); + row.setAlignment(Pos.TOP_LEFT); + pseudo(row, "unread", !notification.read()); + + StackPane icon = notificationIcon(notification.iconUrl()); + VBox copy = new VBox(4); + HBox titleLine = new HBox(6); + titleLine.setAlignment(Pos.CENTER_LEFT); + Label title = new Label(value(notification.title(), "Notification")); + title.getStyleClass().add("notification-title"); + title.setMaxWidth(Double.MAX_VALUE); + titleLine.getChildren().add(title); + if (!notification.read()) { + Region unreadDot = new Region(); + unreadDot.getStyleClass().add("notification-unread-dot"); + titleLine.getChildren().add(unreadDot); + } + + Label message = new Label(value(notification.message(), "")); + message.getStyleClass().add("notification-message"); + message.setWrapText(true); + Label date = new Label(formatDate(notification.createdAt())); + date.getStyleClass().add("notification-date"); + copy.getChildren().addAll(titleLine, message); + if (notification.actionable()) { + HBox decisions = new HBox(8); + decisions.getStyleClass().add("notification-decisions"); + Button accept = primaryButton("Accept"); + accept.setGraphic(LauncherIcons.icon(LauncherIcons.Glyph.CHECK, 13)); + accept.addEventFilter(MouseEvent.MOUSE_CLICKED, MouseEvent::consume); + accept.setOnAction(event -> resolveAction(notification, true)); + Button decline = dangerButton("Decline"); + decline.setGraphic(LauncherIcons.icon(LauncherIcons.Glyph.X, 13)); + decline.addEventFilter(MouseEvent.MOUSE_CLICKED, MouseEvent::consume); + decline.setOnAction(event -> resolveAction(notification, false)); + decisions.getChildren().addAll(accept, decline); + copy.getChildren().add(decisions); + } else { + copy.getChildren().add(date); + } + HBox.setHgrow(copy, Priority.ALWAYS); + + VBox actions = new VBox(6); + actions.getStyleClass().add("notification-row-actions"); + actions.setAlignment(Pos.TOP_RIGHT); + Button open = iconAction(LauncherIcons.Glyph.EXTERNAL_LINK, "Open"); + open.addEventFilter(MouseEvent.MOUSE_CLICKED, MouseEvent::consume); + open.setOnAction(event -> LauncherExternalLinks.open(notification.link(), feedback::showToast)); + Button dismiss = iconAction(LauncherIcons.Glyph.X, "Dismiss"); + dismiss.addEventFilter(MouseEvent.MOUSE_CLICKED, MouseEvent::consume); + dismiss.setOnAction(event -> dismiss(notification)); + Button read = iconAction(LauncherIcons.Glyph.CIRCLE, notification.read() ? "Mark unread" : "Mark read"); + read.addEventFilter(MouseEvent.MOUSE_CLICKED, MouseEvent::consume); + pseudo(read, "selected", !notification.read()); + read.setOnAction(event -> toggleRead(notification)); + actions.getChildren().addAll(open, dismiss, read); + + row.getChildren().addAll(icon, copy, actions); + return row; + } + + private StackPane notificationIcon(String iconUrl) { + ImageView image = new ImageView(); + image.getStyleClass().add("notification-image"); + image.setFitWidth(NOTIFICATION_ICON_SIZE); + image.setFitHeight(NOTIFICATION_ICON_SIZE); + image.setSmooth(true); + Rectangle clip = new Rectangle(NOTIFICATION_ICON_SIZE, NOTIFICATION_ICON_SIZE); + clip.setArcWidth(8); + clip.setArcHeight(8); + image.setClip(clip); + imageLoader.loadInto(image, iconUrl, NOTIFICATION_ICON_SIZE, NOTIFICATION_ICON_SIZE); + StackPane icon = new StackPane(LauncherIcons.icon(LauncherIcons.Glyph.BELL, 18), image); + icon.getStyleClass().add("notification-image-shell"); + return icon; + } + + private Button iconAction(LauncherIcons.Glyph glyph, String tooltipText) { + Button button = new Button(null, LauncherIcons.icon(glyph, 14)); + button.getStyleClass().addAll("icon-btn", "notification-icon-button"); + button.setMnemonicParsing(false); + button.setAccessibleText(tooltipText); + button.setTooltip(new Tooltip(tooltipText)); + return button; + } + + private void applyPreferences(CurrentUser user) { + CurrentUser.NotificationPreferences prefs = user == null + ? CurrentUser.NotificationPreferences.defaults() + : user.notificationPreferences(); + projectUpdates.setValue(prefs.projectUpdates()); + creatorUploads.setValue(prefs.creatorUploads()); + newComments.setValue(prefs.newComments()); + newFollowers.setValue(prefs.newFollowers()); + dependencyUpdates.setValue(prefs.dependencyUpdates()); + } + + private static String formatDate(LocalDateTime createdAt) { + return createdAt == null ? "" : NOTIFICATION_DATE.format(createdAt); + } + + private static final class ToggleControl { + private final String label; + private final String description; + private final Button off = new Button("Off"); + private final Button on = new Button("On"); + private CurrentUser.NotificationLevel value = CurrentUser.NotificationLevel.ON; + private Node node; + + private ToggleControl(String label, String description) { + this.label = label; + this.description = description; + } + + Node node() { + if (node == null) { + Label title = new Label(label); + title.getStyleClass().add("notification-toggle-title"); + Label desc = new Label(description); + desc.getStyleClass().add("notification-toggle-description"); + desc.setWrapText(true); + VBox copy = new VBox(4, title, desc); + HBox.setHgrow(copy, Priority.ALWAYS); + + off.getStyleClass().add("notification-toggle-segment"); + on.getStyleClass().add("notification-toggle-segment"); + off.setOnAction(event -> setValue(CurrentUser.NotificationLevel.OFF)); + on.setOnAction(event -> setValue(CurrentUser.NotificationLevel.ON)); + HBox segments = new HBox(0, off, on); + segments.getStyleClass().add("notification-toggle-control"); + segments.setAlignment(Pos.CENTER_RIGHT); + + HBox row = new HBox(18, copy, segments); + row.getStyleClass().add("notification-toggle-row"); + row.setAlignment(Pos.CENTER_LEFT); + node = row; + refresh(); + } + return node; + } + + String value() { + return value.apiValue(); + } + + void setValue(String nextValue) { + setValue(CurrentUser.NotificationLevel.fromApiValue(nextValue)); + } + + void setValue(CurrentUser.NotificationLevel nextValue) { + value = nextValue == null ? CurrentUser.NotificationLevel.ON : nextValue; + refresh(); + } + + private void refresh() { + pseudo(off, "selected", value == CurrentUser.NotificationLevel.OFF); + pseudo(on, "selected", value == CurrentUser.NotificationLevel.ON); + } + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/activity/LauncherNotificationsMenu.java b/launcher/src/main/java/net/modtale/launcher/ui/activity/LauncherNotificationsMenu.java new file mode 100644 index 00000000..a1e457ae --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/activity/LauncherNotificationsMenu.java @@ -0,0 +1,445 @@ +package net.modtale.launcher.ui.activity; + +import static net.modtale.launcher.ui.common.LauncherUi.dangerButton; +import static net.modtale.launcher.ui.common.LauncherUi.emptyState; +import static net.modtale.launcher.ui.common.LauncherUi.primaryButton; +import static net.modtale.launcher.ui.common.LauncherUi.pseudo; +import static net.modtale.launcher.ui.common.LauncherUi.value; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.function.Supplier; +import javafx.application.Platform; +import javafx.event.EventTarget; +import javafx.geometry.Bounds; +import javafx.geometry.Point2D; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.Tooltip; +import javafx.scene.image.ImageView; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import javafx.scene.shape.Rectangle; +import net.modtale.launcher.api.ModtaleApiClient; +import net.modtale.launcher.model.notification.LauncherNotification; +import net.modtale.launcher.ui.account.LauncherAccountController; +import net.modtale.launcher.ui.common.CachedImageLoader; +import net.modtale.launcher.ui.common.LauncherExternalLinks; +import net.modtale.launcher.ui.common.LauncherIcons; +import net.modtale.launcher.ui.common.LauncherOverlaySupport; +import net.modtale.launcher.ui.feedback.LauncherFeedback; + +public final class LauncherNotificationsMenu { + + private static final DateTimeFormatter NOTIFICATION_DATE = DateTimeFormatter.ofPattern("MMM d, yyyy"); + private static final double NOTIFICATION_ICON_SIZE = 40; + private static final double MENU_ESTIMATED_WIDTH = 384; + private static final double MENU_TOP_OFFSET = 8; + private static final double SCREEN_MARGIN = 8; + + private final ModtaleApiClient apiClient; + private final LauncherAccountController accountController; + private final LauncherFeedback feedback; + private final CachedImageLoader imageLoader; + private final VBox notificationList = new VBox(0); + + private Supplier sceneLayer = () -> null; + private Runnable beforeShow; + + private Button menuButton; + private Button clearButton; + private VBox dropdownPanel; + private Region unreadDot; + private List notifications = List.of(); + private boolean loading; + + public LauncherNotificationsMenu( + ModtaleApiClient apiClient, + LauncherAccountController accountController, + LauncherFeedback feedback, + CachedImageLoader imageLoader + ) { + this.apiClient = apiClient; + this.accountController = accountController; + this.feedback = feedback; + this.imageLoader = imageLoader; + accountController.addCurrentUserListener(user -> { + if (user == null) { + notifications = List.of(); + loading = false; + hide(); + renderNotifications(); + } + }); + } + + public void attachOverlay(Supplier sceneLayer, Runnable beforeShow) { + this.sceneLayer = sceneLayer == null ? () -> null : sceneLayer; + this.beforeShow = beforeShow; + } + + public Button button() { + if (menuButton == null) { + menuButton = buildButton(); + } + return menuButton; + } + + public VBox panel() { + button(); + return dropdownPanel; + } + + public void hide() { + if (dropdownPanel != null) { + dropdownPanel.setVisible(false); + } + if (menuButton != null) { + pseudo(menuButton, "selected", false); + } + } + + public void hideOnOutsidePress(EventTarget target) { + if (dropdownPanel == null || !dropdownPanel.isVisible()) { + return; + } + if (!LauncherOverlaySupport.eventTargetInside(target, dropdownPanel) + && !LauncherOverlaySupport.eventTargetInside(target, menuButton)) { + hide(); + } + } + + private Button buildButton() { + unreadDot = new Region(); + unreadDot.getStyleClass().add("notification-menu-unread-dot"); + unreadDot.setVisible(false); + unreadDot.setManaged(false); + + StackPane graphic = new StackPane(LauncherIcons.icon(LauncherIcons.Glyph.BELL, 18), unreadDot); + graphic.getStyleClass().add("notification-menu-button-graphic"); + StackPane.setAlignment(unreadDot, Pos.TOP_RIGHT); + + Button button = new Button(null, graphic); + button.getStyleClass().add("notification-menu-button"); + button.setMnemonicParsing(false); + button.setAccessibleText("Notifications"); + button.setTooltip(new Tooltip("Notifications")); + button.setOnAction(event -> toggle()); + + dropdownPanel = buildPanel(); + renderNotifications(); + return button; + } + + private VBox buildPanel() { + Label title = new Label("Notifications"); + title.getStyleClass().add("notification-menu-title"); + HBox.setHgrow(title, Priority.ALWAYS); + + Button refresh = new Button(null, LauncherIcons.icon(LauncherIcons.Glyph.REFRESH_CW, 13)); + refresh.getStyleClass().addAll("icon-btn", "notification-menu-header-button"); + refresh.setMnemonicParsing(false); + refresh.setAccessibleText("Refresh notifications"); + refresh.setTooltip(new Tooltip("Refresh")); + refresh.setOnAction(event -> refresh(false)); + + clearButton = new Button("Clear All", LauncherIcons.icon(LauncherIcons.Glyph.TRASH, 12)); + clearButton.getStyleClass().add("notification-menu-clear"); + clearButton.setOnAction(event -> clearAll()); + + HBox header = new HBox(8, title, refresh, clearButton); + header.getStyleClass().add("notification-menu-header"); + header.setAlignment(Pos.CENTER_LEFT); + + notificationList.getStyleClass().add("notification-menu-list"); + ScrollPane scroll = new ScrollPane(notificationList); + scroll.getStyleClass().add("notification-menu-scroll"); + scroll.setFitToWidth(true); + scroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + scroll.setMaxHeight(520); + VBox.setVgrow(scroll, Priority.ALWAYS); + + VBox menuPanel = new VBox(0, header, scroll); + menuPanel.getStyleClass().add("notification-dropdown-panel"); + menuPanel.setMinWidth(MENU_ESTIMATED_WIDTH); + menuPanel.setPrefWidth(MENU_ESTIMATED_WIDTH); + menuPanel.setMaxWidth(MENU_ESTIMATED_WIDTH); + menuPanel.setVisible(false); + menuPanel.setManaged(false); + return menuPanel; + } + + private void toggle() { + if (dropdownPanel.isVisible()) { + hide(); + return; + } + show(); + } + + private void show() { + StackPane layer = sceneLayer.get(); + if (layer == null || dropdownPanel == null || menuButton == null) { + return; + } + if (beforeShow != null) { + beforeShow.run(); + } + dropdownPanel.applyCss(); + dropdownPanel.autosize(); + position(); + dropdownPanel.setVisible(true); + dropdownPanel.toFront(); + pseudo(menuButton, "selected", true); + refresh(true); + Platform.runLater(this::position); + } + + private void position() { + StackPane layer = sceneLayer.get(); + if (layer == null || dropdownPanel == null || menuButton == null) { + return; + } + Bounds anchorBounds = menuButton.localToScene(menuButton.getBoundsInLocal()); + if (anchorBounds == null) { + return; + } + Point2D anchorBottomRight = layer.sceneToLocal(anchorBounds.getMaxX(), anchorBounds.getMaxY()); + double width = dropdownPanel.getLayoutBounds().getWidth() > 0 + ? dropdownPanel.getLayoutBounds().getWidth() + : MENU_ESTIMATED_WIDTH; + double maxX = Math.max(SCREEN_MARGIN, layer.getWidth() - width - SCREEN_MARGIN); + double x = LauncherOverlaySupport.clamp(anchorBottomRight.getX() - width, SCREEN_MARGIN, maxX); + double y = anchorBottomRight.getY() + MENU_TOP_OFFSET; + dropdownPanel.relocate(x, y); + } + + private void refresh(boolean markReadOnLoad) { + if (accountController.currentUser() == null) { + notifications = List.of(); + loading = false; + renderNotifications(); + return; + } + loading = true; + renderNotifications(); + feedback.runAsync("Loading notifications...", + apiClient::getNotifications, + loaded -> { + loading = false; + notifications = List.copyOf(loaded); + renderNotifications(); + if (markReadOnLoad && unreadCount() > 0) { + markAllReadFromMenu(); + } + }, + error -> { + loading = false; + renderNotifications(); + }); + } + + private void clearAll() { + if (clearButton != null) { + clearButton.setDisable(true); + } + feedback.runAsync("Clearing notifications...", () -> { + apiClient.clearNotifications(); + return List.of(); + }, ignored -> { + if (clearButton != null) { + clearButton.setDisable(false); + } + notifications = List.of(); + renderNotifications(); + feedback.showToast("Notifications cleared", "Your notification list is empty."); + }, error -> { + if (clearButton != null) { + clearButton.setDisable(false); + } + }); + } + + private void dismiss(LauncherNotification notification) { + feedback.runAsync("Dismissing notification...", () -> { + apiClient.deleteNotification(notification.id()); + return notification.id(); + }, id -> { + notifications = notifications.stream() + .filter(item -> !id.equals(item.id())) + .toList(); + renderNotifications(); + }); + } + + private void toggleRead(LauncherNotification notification) { + boolean nextRead = !notification.read(); + feedback.runAsync(nextRead ? "Marking notification as read..." : "Marking notification as unread...", () -> { + apiClient.markNotificationRead(notification.id(), nextRead); + return apiClient.getNotifications(); + }, loaded -> { + notifications = List.copyOf(loaded); + renderNotifications(); + }); + } + + private void markAllReadFromMenu() { + feedback.runAsync("Marking notifications as read...", () -> { + apiClient.markAllNotificationsRead(); + return apiClient.getNotifications(); + }, loaded -> { + notifications = List.copyOf(loaded); + renderNotifications(); + }); + } + + private void resolveAction(LauncherNotification notification, boolean accept) { + feedback.runAsync((accept ? "Accepting" : "Declining") + " notification request...", () -> { + apiClient.resolveNotificationAction(notification, accept); + apiClient.deleteNotification(notification.id()); + return apiClient.getNotifications(); + }, loaded -> { + notifications = List.copyOf(loaded); + renderNotifications(); + feedback.showToast(accept ? "Accepted" : "Declined", "Notification request updated."); + }); + } + + private void renderNotifications() { + if (notificationList == null) { + return; + } + notificationList.getChildren().clear(); + int unread = unreadCount(); + if (unreadDot != null) { + unreadDot.setVisible(unread > 0); + unreadDot.setManaged(unread > 0); + } + if (clearButton != null) { + clearButton.setVisible(!notifications.isEmpty()); + clearButton.setManaged(!notifications.isEmpty()); + } + if (loading) { + Label loadingLabel = new Label("Loading..."); + loadingLabel.getStyleClass().add("notification-menu-loading"); + StackPane loadingPane = new StackPane(loadingLabel); + loadingPane.getStyleClass().add("notification-menu-loading-pane"); + notificationList.getChildren().add(loadingPane); + return; + } + if (notifications.isEmpty()) { + VBox empty = emptyState("No notifications", "New activity and requests will appear here."); + empty.getStyleClass().add("notification-menu-empty"); + notificationList.getChildren().add(empty); + return; + } + for (LauncherNotification notification : notifications) { + notificationList.getChildren().add(notificationRow(notification)); + } + } + + private Node notificationRow(LauncherNotification notification) { + HBox row = new HBox(12); + row.getStyleClass().addAll("notification-row", "notification-menu-row"); + row.setAlignment(Pos.TOP_LEFT); + pseudo(row, "unread", !notification.read()); + row.setOnMouseClicked(event -> { + hide(); + LauncherExternalLinks.open(notification.link(), feedback::showToast); + }); + + StackPane icon = notificationIcon(notification.iconUrl()); + VBox copy = new VBox(4); + HBox titleLine = new HBox(6); + titleLine.setAlignment(Pos.CENTER_LEFT); + Label title = new Label(value(notification.title(), "Notification")); + title.getStyleClass().add("notification-title"); + title.setMaxWidth(Double.MAX_VALUE); + titleLine.getChildren().add(title); + if (!notification.read()) { + Region rowUnreadDot = new Region(); + rowUnreadDot.getStyleClass().add("notification-unread-dot"); + titleLine.getChildren().add(rowUnreadDot); + } + + Label message = new Label(value(notification.message(), "")); + message.getStyleClass().add("notification-message"); + message.setWrapText(true); + Label date = new Label(formatDate(notification.createdAt())); + date.getStyleClass().add("notification-date"); + copy.getChildren().addAll(titleLine, message); + if (notification.actionable()) { + HBox decisions = new HBox(8); + decisions.getStyleClass().add("notification-decisions"); + Button accept = primaryButton("Accept"); + accept.setGraphic(LauncherIcons.icon(LauncherIcons.Glyph.CHECK, 13)); + accept.addEventFilter(MouseEvent.MOUSE_CLICKED, MouseEvent::consume); + accept.setOnAction(event -> resolveAction(notification, true)); + Button decline = dangerButton("Decline"); + decline.setGraphic(LauncherIcons.icon(LauncherIcons.Glyph.X, 13)); + decline.addEventFilter(MouseEvent.MOUSE_CLICKED, MouseEvent::consume); + decline.setOnAction(event -> resolveAction(notification, false)); + decisions.getChildren().addAll(accept, decline); + copy.getChildren().add(decisions); + } else { + copy.getChildren().add(date); + } + HBox.setHgrow(copy, Priority.ALWAYS); + + VBox actions = new VBox(6); + actions.getStyleClass().add("notification-row-actions"); + actions.setAlignment(Pos.TOP_RIGHT); + Button dismiss = iconAction(LauncherIcons.Glyph.X, "Dismiss"); + dismiss.addEventFilter(MouseEvent.MOUSE_CLICKED, MouseEvent::consume); + dismiss.setOnAction(event -> dismiss(notification)); + Button read = iconAction(LauncherIcons.Glyph.CIRCLE, notification.read() ? "Mark unread" : "Mark read"); + read.addEventFilter(MouseEvent.MOUSE_CLICKED, MouseEvent::consume); + pseudo(read, "selected", !notification.read()); + read.setOnAction(event -> toggleRead(notification)); + actions.getChildren().addAll(dismiss, read); + + row.getChildren().addAll(icon, copy, actions); + return row; + } + + private StackPane notificationIcon(String iconUrl) { + ImageView image = new ImageView(); + image.getStyleClass().add("notification-image"); + image.setFitWidth(NOTIFICATION_ICON_SIZE); + image.setFitHeight(NOTIFICATION_ICON_SIZE); + image.setSmooth(true); + Rectangle clip = new Rectangle(NOTIFICATION_ICON_SIZE, NOTIFICATION_ICON_SIZE); + clip.setArcWidth(8); + clip.setArcHeight(8); + image.setClip(clip); + imageLoader.loadInto(image, iconUrl, NOTIFICATION_ICON_SIZE, NOTIFICATION_ICON_SIZE); + StackPane icon = new StackPane(LauncherIcons.icon(LauncherIcons.Glyph.BELL, 18), image); + icon.getStyleClass().add("notification-image-shell"); + return icon; + } + + private Button iconAction(LauncherIcons.Glyph glyph, String tooltipText) { + Button button = new Button(null, LauncherIcons.icon(glyph, 14)); + button.getStyleClass().addAll("icon-btn", "notification-icon-button"); + button.setMnemonicParsing(false); + button.setAccessibleText(tooltipText); + button.setTooltip(new Tooltip(tooltipText)); + return button; + } + + private int unreadCount() { + return (int) notifications.stream().filter(item -> !item.read()).count(); + } + + private static String formatDate(LocalDateTime createdAt) { + return createdAt == null ? "" : NOTIFICATION_DATE.format(createdAt); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/browse/ProjectBrowseController.java b/launcher/src/main/java/net/modtale/launcher/ui/browse/ProjectBrowseController.java new file mode 100644 index 00000000..6c925f17 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/browse/ProjectBrowseController.java @@ -0,0 +1,1040 @@ +package net.modtale.launcher.ui.browse; + +import static net.modtale.launcher.ui.common.LauncherUi.pseudo; +import static net.modtale.launcher.ui.common.LauncherUi.secondaryButton; +import static net.modtale.launcher.ui.common.LauncherUi.setVisibleManaged; +import static net.modtale.launcher.ui.common.LauncherUi.styleCombo; +import static net.modtale.launcher.ui.common.LauncherUi.styleInput; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.function.BiConsumer; +import java.util.function.BooleanSupplier; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; +import javafx.animation.Interpolator; +import javafx.animation.KeyFrame; +import javafx.animation.KeyValue; +import javafx.animation.PauseTransition; +import javafx.animation.Timeline; +import javafx.application.Platform; +import javafx.beans.value.ChangeListener; +import javafx.collections.FXCollections; +import javafx.event.EventTarget; +import javafx.geometry.Bounds; +import javafx.geometry.Point2D; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.TextField; +import javafx.scene.control.Tooltip; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.FlowPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import javafx.util.Duration; +import javafx.util.StringConverter; +import net.modtale.launcher.api.ModtaleApiClient; +import net.modtale.launcher.api.ProjectSearchQuery; +import net.modtale.launcher.model.project.GameVersionCatalog; +import net.modtale.launcher.model.project.ProjectPage; +import net.modtale.launcher.model.project.ProjectSummary; +import net.modtale.launcher.ui.browse.card.ProjectCardFactory; +import net.modtale.launcher.ui.browse.controls.BrowseOptions; +import net.modtale.launcher.ui.browse.controls.ProjectBrowseCategories; +import net.modtale.launcher.ui.browse.controls.ProjectBrowseDownloadTimeframeSelector; +import net.modtale.launcher.ui.browse.controls.ProjectBrowseFilterOptions; +import net.modtale.launcher.ui.browse.controls.ProjectBrowseSort; +import net.modtale.launcher.ui.browse.controls.ProjectBrowseTags; +import net.modtale.launcher.ui.browse.controls.ProjectBrowseViewStyleSelector; +import net.modtale.launcher.ui.browse.render.ProjectBrowserRenderer; +import net.modtale.launcher.ui.browse.search.ProjectBrowseSearchState; +import net.modtale.launcher.ui.common.LauncherIcons; +import net.modtale.launcher.ui.common.LauncherScrollSupport; +import net.modtale.launcher.ui.common.LauncherView; + +public final class ProjectBrowseController { + + private static final double RESULTS_INDICATOR_WIDTH = 126; + private static final double RESULTS_INDICATOR_SHOW_BUFFER = 10; + private static final Duration PAGINATION_SCROLL_DURATION = Duration.millis(420); + private static final Interpolator PAGINATION_SCROLL_EASE = Interpolator.SPLINE(0.16, 1.0, 0.30, 1.0); + + private final ModtaleApiClient apiClient; + private final Executor executor; + private final Runnable applySettings; + private final Consumer status; + private final Supplier idleStatus; + private final Consumer log; + private final BiConsumer toast; + private final Runnable showDiscover; + private final Supplier currentView; + private final BooleanSupplier modtaleApiAvailable; + private final TextField searchField = new TextField(); + private final ComboBox sortCombo = new ComboBox<>(); + private final ComboBox pageSizeCombo = new ComboBox<>(); + private final StackPane projectResults = new StackPane(); + private final Label resultsIndicator = new Label("0 Results"); + private final FlowPane paginationNav = new FlowPane(24, 12); + private final HBox paginationPageShell = new HBox(4); + private final HBox paginationPageButtons = new HBox(4); + private final HBox paginationJumpShell = new HBox(12); + private final TextField jumpPageField = new TextField(); + private final PauseTransition searchDebounce = new PauseTransition(Duration.millis(450)); + private final ChangeListener layoutResizeListener = (observable, oldValue, newValue) -> + scheduleLayoutRefresh(oldValue, newValue); + private final ProjectBrowserRenderer renderer; + private final ProjectBrowseCategories categories; + private final ProjectBrowseTags tags; + private final ProjectBrowseViewStyleSelector viewStyles; + private final ProjectBrowseFilterOptions filterOptions; + private final ProjectBrowseDownloadTimeframeSelector downloadTimeframes; + private final ProjectBrowseSearchState searchState = new ProjectBrowseSearchState(); + private final VBox sortDropdown = new VBox(); + private final Map sortOptionButtons = new LinkedHashMap<>(); + private final Map sortOptionChecks = new LinkedHashMap<>(); + + private Button tagToggleButton; + private Button filterToggleButton; + private Button sortButton; + private Label sortButtonLabel; + private Timeline paginationScrollTimeline; + private Button previousPageButton; + private Button nextPageButton; + private Button jumpPageButton; + private StackPane browseRoot; + private Node view; + private List currentProjects = List.of(); + private boolean suppressSearch; + private boolean layoutRefreshScheduled; + private int currentPage; + private int totalPageCount; + private long totalResultCount; + private BrowseOptions.BrowseViewOption activeBrowseView = BrowseOptions.BrowseViewOption.defaultOption(); + + public ProjectBrowseController( + ModtaleApiClient apiClient, + Executor executor, + ProjectCardFactory projectCardFactory, + StackPane viewDeck, + Supplier contentBody, + LauncherScrollSupport scrollSupport, + Runnable applySettings, + Consumer status, + Supplier idleStatus, + Consumer log, + BiConsumer toast, + Runnable showDiscover, + Supplier currentView, + BooleanSupplier modtaleApiAvailable, + Function favoriteResolver, + Supplier gameVersion, + Consumer onInstall, + Consumer onOpenPage, + Consumer onOpenCreator, + Consumer onToggleFavorite + ) { + this.apiClient = apiClient; + this.executor = executor; + this.applySettings = applySettings; + this.status = status; + this.idleStatus = idleStatus; + this.log = log; + this.toast = toast; + this.showDiscover = showDiscover; + this.currentView = currentView; + this.modtaleApiAvailable = modtaleApiAvailable == null ? () -> true : modtaleApiAvailable; + this.renderer = new ProjectBrowserRenderer(projectResults, viewDeck, contentBody, projectCardFactory, + favoriteResolver, gameVersion, onInstall, onOpenPage, onOpenCreator, onToggleFavorite); + this.categories = new ProjectBrowseCategories(scrollSupport, this::searchProjects); + this.tags = new ProjectBrowseTags(this::searchProjects, this::refreshBrowseControls); + this.viewStyles = new ProjectBrowseViewStyleSelector(this::searchProjects); + this.filterOptions = new ProjectBrowseFilterOptions( + this::searchProjects, + this::refreshBrowseControls, + tags::clear + ); + this.downloadTimeframes = new ProjectBrowseDownloadTimeframeSelector( + filterOptions::selectDateRange, + filterOptions::selectedDateRange + ); + configureInputs(); + } + + public TextField searchField() { + return searchField; + } + + public Node view() { + if (view == null) { + view = buildView(); + } + return view; + } + + public BrowseOptions.BrowseViewOption activeBrowseView() { + return activeBrowseView; + } + + public String title() { + if (!tags.isEmpty()) { + return tags.title(); + } + BrowseOptions.BrowseViewOption viewOption = BrowseOptions.browseView(activeBrowseView); + if (viewOption.isDefault()) { + String sortTitle = selectedSort().title(); + if (!sortTitle.isBlank()) { + return sortTitle; + } + BrowseOptions.ClassificationOption selectedClassification = categories.selectedClassification(); + return selectedClassification.isDefault() + ? "All Projects" + : "All " + selectedClassification.label(); + } + return viewOption.label(); + } + + public String subtitle() { + BrowseOptions.BrowseViewOption viewOption = BrowseOptions.browseView(activeBrowseView); + if (!viewOption.isDefault()) { + return "Browse " + viewOption.label().toLowerCase(Locale.ROOT) + " with the same filters as the web catalog."; + } + return "Browse the Modtale catalog and install compatible Hytale projects."; + } + + public void selectBrowseView(BrowseOptions.BrowseViewOption browseView) { + BrowseOptions.BrowseViewOption selected = BrowseOptions.browseView(browseView); + activeBrowseView = selected; + withSuppressedSearch(() -> sortCombo.setValue(selected.defaultSort())); + refreshBrowseControls(); + showDiscover.run(); + searchProjects(); + } + + public void selectClassification(BrowseOptions.ClassificationOption classification) { + activeBrowseView = BrowseOptions.BrowseViewOption.defaultOption(); + withSuppressedSearch(() -> sortCombo.setValue(ProjectBrowseSort.defaultSort())); + refreshBrowseControls(); + showDiscover.run(); + categories.selectClassification(classification); + } + + public void selectDefaultBrowsePage() { + withSuppressedSearch(() -> { + activeBrowseView = BrowseOptions.BrowseViewOption.defaultOption(); + searchField.clear(); + sortCombo.setValue(ProjectBrowseSort.defaultSort()); + filterOptions.reset(false); + tags.popover().setVisible(false); + filterOptions.popover().setVisible(false); + }); + searchDebounce.stop(); + refreshBrowseControls(); + showDiscover.run(); + categories.selectClassification(BrowseOptions.ClassificationOption.defaultOption()); + } + + public void refreshControls() { + categories.refresh(); + tags.refresh(); + viewStyles.refresh(); + refreshBrowseControls(); + } + + public void loadGameVersionFilters() { + if (!modtaleApiAvailable.getAsBoolean()) { + return; + } + CompletableFuture.supplyAsync(this::loadGameVersionCatalog, executor) + .whenComplete((catalog, error) -> Platform.runLater(() -> { + if (error != null || catalog == null) { + return; + } + filterOptions.replaceGameVersionCatalog(catalog); + })); + } + + public void searchProjects() { + currentPage = 0; + requestProjects(); + } + + public void resetSearchQuery() { + if (searchField.getText().isEmpty()) { + return; + } + withSuppressedSearch(searchField::clear); + searchDebounce.stop(); + searchProjects(); + } + + private void requestProjects() { + if (suppressSearch) { + return; + } + if (!modtaleApiAvailable.getAsBoolean()) { + status.accept(idleStatus.get()); + log.accept("Sign in with Modtale to browse projects."); + toast.accept("Modtale sign-in required", "Sign in with Modtale to browse projects."); + return; + } + applySettings.run(); + ProjectSearchQuery query = searchQuery(); + long requestId = searchState.start(query); + if (requestId == ProjectBrowseSearchState.DUPLICATE_SEARCH) { + return; + } + + updateResultsIndicator(totalResultCount, true); + status.accept("Searching Modtale projects..."); + log.accept("Searching Modtale projects..."); + CompletableFuture.supplyAsync(() -> apiClient.searchProjects(query), executor) + .whenComplete((page, error) -> Platform.runLater(() -> { + if (!searchState.acceptCompletion(query, requestId)) { + return; + } + status.accept(idleStatus.get()); + if (error != null) { + Throwable cause = error.getCause() == null ? error : error.getCause(); + updateResultsIndicator(totalResultCount, false); + updatePaginationControls(); + log.accept("Error: " + cause.getMessage()); + toast.accept("Search failed", cause.getMessage()); + return; + } + finishSearch(page, query); + })); + } + + public void renderProjects() { + renderer.render(currentProjects, viewStyles.style(), selectedPageSize()); + } + + public void applyFavoriteDelta(String projectId, int delta) { + if (delta == 0) { + return; + } + currentProjects = currentProjects.stream() + .map(item -> item.id().equals(projectId) + ? item.withFavoriteCount(item.favoriteCount() + delta) + : item) + .toList(); + } + + private Node buildView() { + VBox content = new VBox(16); + content.getStyleClass().add("browse-content"); + content.setMaxSize(Double.MAX_VALUE, Double.MAX_VALUE); + + VBox filters = new VBox(10); + filters.getStyleClass().add("browse-filter-shell"); + + HBox browseControls = new HBox(12); + browseControls.getStyleClass().add("browse-filter-row"); + browseControls.setAlignment(Pos.CENTER_LEFT); + + HBox controlRow = new HBox(8); + controlRow.getStyleClass().add("browse-control-group"); + controlRow.setAlignment(Pos.CENTER_RIGHT); + controlRow.setMinWidth(Region.USE_PREF_SIZE); + tagToggleButton = popoverToggle("Tags", LauncherIcons.Glyph.TAG, tags.popover()); + filterToggleButton = popoverToggle("Filters", LauncherIcons.Glyph.FILTER, filterOptions.popover()); + sortButton = sortControl(); + controlRow.getChildren().addAll( + pageSizeCombo, + viewStyles.view(), + tagToggleButton, + filterToggleButton, + downloadTimeframes.view(), + sortButton + ); + resultsIndicator.getStyleClass().add("browse-results-indicator"); + resultsIndicator.setAlignment(Pos.CENTER_LEFT); + resultsIndicator.setMinWidth(RESULTS_INDICATOR_WIDTH); + resultsIndicator.setPrefWidth(RESULTS_INDICATOR_WIDTH); + resultsIndicator.setMaxWidth(RESULTS_INDICATOR_WIDTH); + resultsIndicator.setMouseTransparent(true); + resultsIndicator.setAccessibleText("Browse results status"); + Node categoryPills = categories.view(); + HBox.setHgrow(categoryPills, Priority.NEVER); + Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + browseControls.getChildren().addAll(categoryPills, resultsIndicator, spacer, controlRow); + configureResultsIndicatorVisibility(browseControls, controlRow); + + filters.getChildren().add(browseControls); + + projectResults.getStyleClass().add("project-results"); + projectResults.setMaxWidth(Double.MAX_VALUE); + projectResults.setAlignment(Pos.TOP_LEFT); + VBox.setVgrow(projectResults, Priority.ALWAYS); + configurePagination(); + content.getChildren().addAll(filters, projectResults, paginationNav); + + configureSortDropdown(); + StackPane root = new StackPane(content, tags.popover(), filterOptions.popover(), sortDropdown); + browseRoot = root; + root.setUserData(LauncherView.DISCOVER); + root.getStyleClass().addAll("view", "browse-view"); + root.setMaxSize(Double.MAX_VALUE, Double.MAX_VALUE); + root.addEventFilter(MouseEvent.MOUSE_PRESSED, this::hideFilterDropdownsOnOutsidePress); + return root; + } + + private GameVersionCatalog loadGameVersionCatalog() { + try { + return apiClient.getGameVersionCatalog(); + } catch (RuntimeException ex) { + return GameVersionCatalog.fromVersions(apiClient.getGameVersions()); + } + } + + private void configureInputs() { + sortCombo.setItems(FXCollections.observableArrayList(ProjectBrowseSort.DOWNLOADS, ProjectBrowseSort.FAVORITES)); + sortCombo.setValue(ProjectBrowseSort.defaultSort()); + sortCombo.setConverter(new StringConverter<>() { + @Override + public String toString(ProjectBrowseSort value) { + return value == null ? ProjectBrowseSort.defaultSort().label() : value.label(); + } + + @Override + public ProjectBrowseSort fromString(String value) { + return ProjectBrowseSort.fromLabel(value); + } + }); + sortCombo.setOnAction(event -> { + refreshBrowseControls(); + if (!suppressSearch) { + activeBrowseView = selectedSort().browseView(); + showDiscover.run(); + } + searchProjects(); + }); + sortCombo.valueProperty().addListener((observable, oldValue, newValue) -> refreshSortDropdown()); + pageSizeCombo.setItems(FXCollections.observableArrayList(BrowseOptions.BROWSE_ITEMS_PER_PAGE_OPTIONS)); + pageSizeCombo.setValue(BrowseOptions.DEFAULT_ITEMS_PER_PAGE); + pageSizeCombo.setConverter(new StringConverter<>() { + @Override + public String toString(Integer value) { + return Integer.toString(BrowseOptions.itemsPerPage(value)); + } + + @Override + public Integer fromString(String value) { + if (value == null || value.isBlank()) { + return BrowseOptions.DEFAULT_ITEMS_PER_PAGE; + } + try { + return BrowseOptions.itemsPerPage(Integer.parseInt(value.trim())); + } catch (NumberFormatException ignored) { + return BrowseOptions.DEFAULT_ITEMS_PER_PAGE; + } + } + }); + pageSizeCombo.setOnAction(event -> { + tags.popover().setVisible(false); + filterOptions.popover().setVisible(false); + hideSortDropdown(); + searchProjects(); + }); + pageSizeCombo.setTooltip(new Tooltip("Results per page")); + pageSizeCombo.setAccessibleText("Results per page"); + styleCombo(pageSizeCombo); + pageSizeCombo.setMinWidth(68); + pageSizeCombo.setPrefWidth(68); + pageSizeCombo.setMaxWidth(68); + pageSizeCombo.getStyleClass().add("page-size-select"); + searchDebounce.setOnFinished(event -> searchProjects()); + searchField.textProperty().addListener((observable, oldValue, newValue) -> searchDebounce.playFromStart()); + projectResults.widthProperty().addListener(layoutResizeListener); + projectResults.heightProperty().addListener(layoutResizeListener); + projectResults.sceneProperty().addListener((observable, oldScene, newScene) -> + observeSceneResizes(oldScene, newScene)); + styleInput(searchField); + refreshBrowseControls(); + } + + private void configurePagination() { + paginationNav.getStyleClass().add("pagination-nav"); + paginationNav.setAlignment(Pos.CENTER); + paginationNav.setMaxWidth(Double.MAX_VALUE); + + paginationPageShell.getStyleClass().add("pagination-page-shell"); + paginationPageShell.setAlignment(Pos.CENTER); + previousPageButton = paginationIconButton(LauncherIcons.Glyph.CHEVRON_LEFT, "Previous Page"); + nextPageButton = paginationIconButton(LauncherIcons.Glyph.CHEVRON_RIGHT, "Next Page"); + paginationPageShell.getChildren().setAll(previousPageButton, paginationPageButtons, nextPageButton); + + paginationPageButtons.getStyleClass().add("pagination-page-buttons"); + paginationPageButtons.setAlignment(Pos.CENTER); + + paginationJumpShell.getStyleClass().add("pagination-jump-shell"); + paginationJumpShell.setAlignment(Pos.CENTER); + Label jumpLabel = new Label("JUMP"); + jumpLabel.getStyleClass().add("pagination-jump-label"); + jumpPageField.setPromptText("#"); + jumpPageField.getStyleClass().add("pagination-jump-input"); + jumpPageField.setOnAction(event -> submitJumpPage()); + jumpPageField.textProperty().addListener((observable, oldValue, newValue) -> updateJumpPageState()); + jumpPageButton = paginationIconButton(LauncherIcons.Glyph.CORNER_DOWN_LEFT, "Go"); + jumpPageButton.getStyleClass().add("pagination-jump-button"); + jumpPageButton.setOnAction(event -> submitJumpPage()); + paginationJumpShell.getChildren().setAll(jumpLabel, jumpPageField, jumpPageButton); + + paginationNav.getChildren().setAll(paginationPageShell, paginationJumpShell); + updatePaginationControls(); + } + + private Button paginationIconButton(LauncherIcons.Glyph glyph, String accessibleText) { + Button button = new Button(); + button.getStyleClass().addAll("pagination-button", "pagination-icon-button"); + button.setGraphic(LauncherIcons.icon(glyph, 16)); + button.setAccessibleText(accessibleText); + button.setMinSize(36, 36); + button.setPrefSize(36, 36); + button.setMaxSize(36, 36); + return button; + } + + private Button sortControl() { + Button button = new Button(); + button.getStyleClass().add("sort-button"); + sortButtonLabel = new Label(selectedSort().label()); + sortButtonLabel.getStyleClass().add("sort-button-label"); + sortButtonLabel.setAlignment(Pos.CENTER); + Node chevron = LauncherIcons.icon(LauncherIcons.Glyph.CHEVRON_DOWN, 14); + chevron.getStyleClass().add("sort-button-chevron"); + HBox content = new HBox(6, sortButtonLabel, chevron); + content.getStyleClass().add("sort-button-content"); + content.setAlignment(Pos.CENTER); + button.setGraphic(content); + button.setOnAction(event -> toggleSortDropdown()); + refreshSortDropdown(); + return button; + } + + private void configureSortDropdown() { + sortDropdown.getStyleClass().add("sort-dropdown-panel"); + sortDropdown.setMinWidth(192); + sortDropdown.setPrefWidth(192); + sortDropdown.setMaxWidth(192); + sortDropdown.setVisible(false); + sortDropdown.setManaged(false); + sortDropdown.getChildren().setAll( + sortDropdownItem(ProjectBrowseSort.DOWNLOADS), + sortDropdownItem(ProjectBrowseSort.FAVORITES) + ); + refreshSortDropdown(); + } + + private Button sortDropdownItem(ProjectBrowseSort sort) { + Button item = new Button(); + item.getStyleClass().add("sort-dropdown-item"); + Label label = new Label(sort.label()); + label.getStyleClass().add("sort-dropdown-item-label"); + Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + Node check = LauncherIcons.icon(LauncherIcons.Glyph.CHECK, 15); + check.getStyleClass().add("sort-dropdown-check"); + HBox content = new HBox(8, label, spacer, check); + content.setAlignment(Pos.CENTER_LEFT); + item.setGraphic(content); + item.setMaxWidth(Double.MAX_VALUE); + item.setOnAction(event -> selectSort(sort)); + sortOptionButtons.put(sort, item); + sortOptionChecks.put(sort, check); + return item; + } + + private void toggleSortDropdown() { + if (sortDropdown.isVisible()) { + hideSortDropdown(); + return; + } + tags.popover().setVisible(false); + filterOptions.popover().setVisible(false); + sortDropdown.setVisible(true); + sortDropdown.toFront(); + positionFilterDropdown(sortButton, sortDropdown, true); + Platform.runLater(() -> positionFilterDropdown(sortButton, sortDropdown, true)); + refreshSortDropdown(); + } + + private void hideSortDropdown() { + sortDropdown.setVisible(false); + refreshSortDropdown(); + } + + private void selectSort(ProjectBrowseSort sort) { + hideSortDropdown(); + sortCombo.setValue(sort); + refreshSortDropdown(); + } + + private void refreshSortDropdown() { + ProjectBrowseSort selected = selectedSort(); + if (sortButtonLabel != null) { + sortButtonLabel.setText(selected.label()); + } + if (sortButton != null) { + pseudo(sortButton, "selected", sortDropdown.isVisible()); + } + sortOptionButtons.forEach((sort, button) -> pseudo(button, "selected", sort == selected)); + sortOptionChecks.forEach((sort, check) -> check.setVisible(sort == selected)); + } + + private Button popoverToggle(String label, LauncherIcons.Glyph icon, VBox popover) { + Button button = secondaryButton(label); + button.getStyleClass().add("toolbar-pill"); + button.setGraphic(LauncherIcons.icon(icon, 15)); + double width = "Filters".equals(label) ? 92 : 80; + button.setMinWidth(width); + button.setOnAction(event -> { + boolean nextVisible = !popover.isVisible(); + tags.popover().setVisible(false); + filterOptions.popover().setVisible(false); + hideSortDropdown(); + popover.setVisible(nextVisible); + if (nextVisible) { + positionFilterDropdown(button, popover, "Filters".equals(label)); + popover.toFront(); + Platform.runLater(() -> positionFilterDropdown(button, popover, "Filters".equals(label))); + } + updateBrowseControlBadges(); + }); + popover.visibleProperty().addListener((observable, oldValue, visible) -> { + if (visible) { + positionFilterDropdown(button, popover, "Filters".equals(label)); + popover.toFront(); + } + updateBrowseControlBadges(); + scheduleLayoutRefresh(null, null); + }); + return button; + } + + private void positionFilterDropdown(Button anchor, VBox popover, boolean rightAligned) { + if (browseRoot == null || anchor == null || popover == null || anchor.getScene() == null) { + return; + } + popover.applyCss(); + popover.autosize(); + Bounds anchorBounds = anchor.localToScene(anchor.getBoundsInLocal()); + if (anchorBounds == null) { + return; + } + Point2D anchorMin = browseRoot.sceneToLocal(anchorBounds.getMinX(), anchorBounds.getMinY()); + Point2D anchorMax = browseRoot.sceneToLocal(anchorBounds.getMaxX(), anchorBounds.getMaxY()); + double width = popover.getLayoutBounds().getWidth() > 0 ? popover.getLayoutBounds().getWidth() : popover.prefWidth(-1); + double maxX = Math.max(8, browseRoot.getWidth() - width - 8); + double x = rightAligned ? anchorMax.getX() - width : anchorMin.getX(); + double y = anchorMax.getY() + 8; + popover.relocate(clamp(x, 8, maxX), y); + } + + private void hideFilterDropdownsOnOutsidePress(MouseEvent event) { + boolean tagsVisible = tags.popover().isVisible(); + boolean filtersVisible = filterOptions.popover().isVisible(); + boolean sortVisible = sortDropdown.isVisible(); + if (!tagsVisible && !filtersVisible && !sortVisible) { + return; + } + EventTarget target = event.getTarget(); + if (tagsVisible + && !eventTargetInside(target, tags.popover()) + && !eventTargetInside(target, tagToggleButton)) { + tags.popover().setVisible(false); + } + if (filtersVisible + && !eventTargetInside(target, filterOptions.popover()) + && !eventTargetInside(target, filterToggleButton)) { + filterOptions.popover().setVisible(false); + } + if (sortVisible + && !eventTargetInside(target, sortDropdown) + && !eventTargetInside(target, sortButton)) { + hideSortDropdown(); + } + } + + private static boolean eventTargetInside(EventTarget target, Node root) { + if (!(target instanceof Node node) || root == null) { + return false; + } + for (Node current = node; current != null; current = current.getParent()) { + if (current == root) { + return true; + } + } + return false; + } + + private static double clamp(double value, double min, double max) { + if (max < min) { + return min; + } + return Math.max(min, Math.min(value, max)); + } + + private void updateBrowseControlBadges() { + if (tagToggleButton != null) { + tagToggleButton.setText(tags.isEmpty() ? "Tags" : "Tags " + tags.selectedCount()); + pseudo(tagToggleButton, "selected", !tags.isEmpty() || tags.popover().isVisible()); + } + if (filterToggleButton != null) { + int count = filterOptions.activeFilterCount(); + filterToggleButton.setText(count == 0 ? "Filters" : "Filters " + count); + pseudo(filterToggleButton, "selected", count > 0 || filterOptions.popover().isVisible()); + } + } + + private void refreshBrowseControls() { + boolean downloadSort = isDownloadSort(); + filterOptions.setDownloadSort(downloadSort); + downloadTimeframes.setVisible(downloadSort); + downloadTimeframes.refresh(); + updateBrowseControlBadges(); + refreshSortDropdown(); + } + + private boolean isDownloadSort() { + return selectedSort() == ProjectBrowseSort.DOWNLOADS; + } + + private ProjectSearchQuery searchQuery() { + BrowseOptions.BrowseViewOption browseViewOption = BrowseOptions.browseView(activeBrowseView); + return new ProjectSearchQuery( + searchField.getText(), + categories.selectedClassification().apiValue(), + filterOptions.selectedGameVersion(), + selectedSort().apiValue(), + currentPage, + selectedPageSize(), + tags.selectedQuery(), + filterOptions.selectedMinimumDownloads(), + filterOptions.selectedMinimumFavorites(), + browseViewOption.category(), + filterOptions.selectedDateRange(), + filterOptions.selectedOpenSource() + ); + } + + private ProjectBrowseSort selectedSort() { + return sortCombo.getValue() == null ? ProjectBrowseSort.defaultSort() : sortCombo.getValue(); + } + + private void withSuppressedSearch(Runnable work) { + boolean previous = suppressSearch; + suppressSearch = true; + try { + work.run(); + } finally { + suppressSearch = previous; + } + } + + private int selectedPageSize() { + return BrowseOptions.itemsPerPage(pageSizeCombo.getValue()); + } + + private void finishSearch(ProjectPage page, ProjectSearchQuery query) { + if (page == null) { + return; + } + int nextTotalPages = Math.max(0, page.totalPages()); + if (nextTotalPages > 0 && query.page() >= nextTotalPages) { + totalPageCount = nextTotalPages; + currentPage = nextTotalPages - 1; + updatePaginationControls(); + requestProjects(); + return; + } + + searchState.recordCompleted(query); + totalPageCount = nextTotalPages; + currentPage = totalPageCount == 0 + ? 0 + : (int) clamp(query.page(), 0, totalPageCount - 1); + currentProjects = page.content(); + totalResultCount = Math.max(0, page.totalElements()); + updateResultsIndicator(totalResultCount, false); + updatePaginationControls(); + renderProjects(); + log.accept("Found " + page.content().size() + " projects."); + ProjectSearchQuery nextQuery = searchQuery(); + if (!nextQuery.equals(query) && searchState.shouldSearchForLayout(nextQuery, currentProjects)) { + requestProjects(); + } + } + + private void renderProjectsForLayoutChange() { + if (currentProjects.isEmpty()) { + return; + } + renderProjects(); + ProjectSearchQuery nextQuery = searchQuery(); + if (searchState.shouldSearchForLayout(nextQuery, currentProjects)) { + requestProjects(); + } + } + + private void updatePaginationControls() { + boolean showPagination = totalPageCount > 1; + setVisibleManaged(paginationNav, showPagination); + if (!showPagination || previousPageButton == null || nextPageButton == null || jumpPageButton == null) { + return; + } + + previousPageButton.setDisable(currentPage <= 0); + previousPageButton.setOnAction(event -> goToPage(currentPage - 1)); + nextPageButton.setDisable(currentPage >= totalPageCount - 1); + nextPageButton.setOnAction(event -> goToPage(currentPage + 1)); + + paginationPageButtons.getChildren().clear(); + for (PageToken token : pageTokens(currentPage, totalPageCount)) { + if (token.ellipsis()) { + Label dots = new Label("..."); + dots.getStyleClass().add("pagination-dots"); + paginationPageButtons.getChildren().add(dots); + continue; + } + + int targetPage = token.page(); + Button button = new Button(Integer.toString(targetPage + 1)); + button.getStyleClass().add("pagination-button"); + button.setMinSize(36, 36); + button.setPrefSize(36, 36); + button.setMaxSize(36, 36); + button.setAccessibleText("Page " + (targetPage + 1)); + button.setOnAction(event -> goToPage(targetPage)); + pseudo(button, "selected", targetPage == currentPage); + paginationPageButtons.getChildren().add(button); + } + updateJumpPageState(); + } + + private void updateJumpPageState() { + if (jumpPageButton == null) { + return; + } + jumpPageButton.setDisable(jumpPageField.getText() == null || jumpPageField.getText().isBlank()); + } + + private void submitJumpPage() { + String rawPage = jumpPageField.getText(); + jumpPageField.clear(); + if (rawPage == null || rawPage.isBlank()) { + return; + } + try { + int targetPage = Integer.parseInt(rawPage.trim()) - 1; + goToPage(targetPage); + } catch (NumberFormatException ignored) { + // Invalid jump values are intentionally treated like the web form: clear and stay put. + } + } + + private void goToPage(int page) { + if (page < 0 || page >= totalPageCount || page == currentPage) { + return; + } + currentPage = page; + requestProjects(); + animateBrowseToTop(); + } + + private void animateBrowseToTop() { + ScrollPane scrollPane = enclosingScrollPane(); + if (scrollPane != null) { + animateBrowseToTop(scrollPane); + } + Platform.runLater(() -> { + ScrollPane nextScrollPane = enclosingScrollPane(); + if (nextScrollPane != null && Math.abs(nextScrollPane.getVvalue() - nextScrollPane.getVmin()) > 0.001) { + animateBrowseToTop(nextScrollPane); + } + }); + } + + private void animateBrowseToTop(ScrollPane scrollPane) { + if (paginationScrollTimeline != null) { + paginationScrollTimeline.stop(); + } + + double start = scrollPane.getVvalue(); + double end = scrollPane.getVmin(); + if (Math.abs(start - end) <= 0.001) { + scrollPane.setVvalue(end); + return; + } + + Timeline timeline = new Timeline( + new KeyFrame(Duration.ZERO, new KeyValue(scrollPane.vvalueProperty(), start)), + new KeyFrame(PAGINATION_SCROLL_DURATION, + new KeyValue(scrollPane.vvalueProperty(), end, PAGINATION_SCROLL_EASE)) + ); + paginationScrollTimeline = timeline; + timeline.setOnFinished(event -> { + scrollPane.setVvalue(end); + if (paginationScrollTimeline == timeline) { + paginationScrollTimeline = null; + } + }); + timeline.play(); + } + + private ScrollPane enclosingScrollPane() { + for (Node current = browseRoot; current != null; current = current.getParent()) { + if (current instanceof ScrollPane scrollPane) { + return scrollPane; + } + } + return null; + } + + private static List pageTokens(int currentPage, int totalPages) { + int total = Math.max(0, totalPages); + int current = Math.max(0, currentPage) + 1; + int delta = 2; + Set range = new LinkedHashSet<>(); + if (total >= 1) { + range.add(1); + } + for (int i = current - delta; i <= current + delta; i++) { + if (i < total && i > 1) { + range.add(i); + } + } + if (total > 1) { + range.add(total); + } + + List sorted = new ArrayList<>(range); + sorted.sort(Integer::compareTo); + List tokens = new ArrayList<>(); + Integer previous = null; + for (Integer page : sorted) { + if (previous != null) { + if (page - previous == 2) { + tokens.add(PageToken.page(previous)); + } else if (page - previous != 1) { + tokens.add(PageToken.gap()); + } + } + tokens.add(PageToken.page(page - 1)); + previous = page; + } + return tokens; + } + + private void configureResultsIndicatorVisibility(HBox browseControls, HBox controlRow) { + ChangeListener sizeListener = (observable, oldValue, newValue) -> + scheduleResultsIndicatorVisibilityUpdate(browseControls, controlRow); + browseControls.widthProperty().addListener(sizeListener); + controlRow.widthProperty().addListener(sizeListener); + categories.contentWidthProperty().addListener(sizeListener); + categories.overflowingProperty().addListener((observable, oldValue, newValue) -> + scheduleResultsIndicatorVisibilityUpdate(browseControls, controlRow)); + browseControls.sceneProperty().addListener((observable, oldScene, newScene) -> + scheduleResultsIndicatorVisibilityUpdate(browseControls, controlRow)); + scheduleResultsIndicatorVisibilityUpdate(browseControls, controlRow); + } + + private void scheduleResultsIndicatorVisibilityUpdate(HBox browseControls, HBox controlRow) { + Platform.runLater(() -> updateResultsIndicatorVisibility(browseControls, controlRow)); + } + + private void updateResultsIndicatorVisibility(HBox browseControls, HBox controlRow) { + double rowWidth = browseControls.getWidth(); + double categoryWidth = categories.contentWidth(); + if (!Double.isFinite(rowWidth) || rowWidth <= 0 + || !Double.isFinite(categoryWidth) || categoryWidth <= 0) { + return; + } + + double controlsWidth = measuredOrPref(controlRow.getWidth(), controlRow.prefWidth(-1)); + double availableCategoryWidthWithIndicator = rowWidth + - controlsWidth + - RESULTS_INDICATOR_WIDTH + - browseControls.getSpacing() * 3; + boolean canShowIndicator = availableCategoryWidthWithIndicator + >= categoryWidth + RESULTS_INDICATOR_SHOW_BUFFER; + setVisibleManaged(resultsIndicator, canShowIndicator); + } + + private double measuredOrPref(double measured, double pref) { + if (Double.isFinite(measured) && measured > 0) { + return measured; + } + return Double.isFinite(pref) && pref > 0 ? pref : 0; + } + + private void updateResultsIndicator(long totalItems, boolean searching) { + long safeTotal = Math.max(0, totalItems); + resultsIndicator.setText(searching + ? "SEARCHING..." + : String.format(Locale.US, "%,d %s", safeTotal, safeTotal == 1 ? "RESULT" : "RESULTS")); + pseudo(resultsIndicator, "searching", searching); + } + + private void observeSceneResizes(Scene oldScene, Scene newScene) { + if (oldScene != null) { + oldScene.widthProperty().removeListener(layoutResizeListener); + oldScene.heightProperty().removeListener(layoutResizeListener); + } + if (newScene != null) { + newScene.widthProperty().addListener(layoutResizeListener); + newScene.heightProperty().addListener(layoutResizeListener); + } + } + + private void scheduleLayoutRefresh(Number oldValue, Number newValue) { + if (oldValue == null || newValue == null + || Math.abs(newValue.doubleValue() - oldValue.doubleValue()) > 0.5) { + scheduleLayoutRefresh(); + } + } + + private void scheduleLayoutRefresh() { + if (layoutRefreshScheduled) { + return; + } + layoutRefreshScheduled = true; + Platform.runLater(() -> { + layoutRefreshScheduled = false; + if (currentView.get() == LauncherView.DISCOVER + && !currentProjects.isEmpty() + && renderer.shouldRenderForLayout(viewStyles.style(), selectedPageSize())) { + renderProjectsForLayoutChange(); + } + }); + } + + private record PageToken(Integer page, boolean ellipsis) { + + static PageToken page(int page) { + return new PageToken(page, false); + } + + static PageToken gap() { + return new PageToken(null, true); + } + } + +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/browse/card/ProjectCardFactory.java b/launcher/src/main/java/net/modtale/launcher/ui/browse/card/ProjectCardFactory.java new file mode 100644 index 00000000..fcf0ef93 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/browse/card/ProjectCardFactory.java @@ -0,0 +1,402 @@ +package net.modtale.launcher.ui.browse.card; + +import static net.modtale.launcher.ui.browse.card.ProjectCardFormatter.classificationLabel; +import static net.modtale.launcher.ui.browse.card.ProjectCardFormatter.number; +import static net.modtale.launcher.ui.browse.card.ProjectCardFormatter.value; +import static net.modtale.launcher.ui.browse.card.ProjectCardMedia.lockHeight; +import static net.modtale.launcher.ui.browse.card.ProjectCardMedia.lockWidth; + +import java.util.Optional; +import java.util.concurrent.Executor; +import java.util.function.Consumer; +import java.util.function.Function; +import javafx.css.PseudoClass; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.CacheHint; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.OverrunStyle; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import javafx.scene.text.Font; +import javafx.scene.text.FontWeight; +import net.modtale.launcher.install.VersionSelector; +import net.modtale.launcher.model.project.ProjectSummary; +import net.modtale.launcher.model.project.ProjectVersion; +import net.modtale.launcher.ui.browse.controls.BrowseOptions; +import net.modtale.launcher.ui.common.LauncherIcons; + +public final class ProjectCardFactory { + + public static final String SCROLL_ACTIVE_PROPERTY = "net.modtale.launcher.scrollActive"; + + private static final double GRID_WIDTH = 400; + private static final double GRID_ICON_SIZE = 104; + private static final double GRID_BODY_PADDING = 22; + private static final double LIST_ICON_SIZE = 128; + private static final double COMPACT_WIDTH = 330; + private static final double CARD_STAT_HEIGHT = 38; + private static final double CARD_STAT_ICON_SIZE = 16; + private static final double COMPACT_STAT_ICON_SIZE = 14; + private static final double TITLE_FONT_SIZE = 20; + private static final double COMPACT_TITLE_FONT_SIZE = 14; + private static final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected"); + + private final ProjectCardMedia media; + + public ProjectCardFactory(Function assetResolver, Executor executor) { + this.media = new ProjectCardMedia(assetResolver, executor); + } + + public void clearImageCache() { + media.clearImageCache(); + } + + public Node create( + ProjectSummary project, + ProjectCardViewStyle viewStyle, + String gameVersion, + boolean favorite, + Consumer onInstall, + Consumer onOpenPage, + Consumer onOpenCreator, + Consumer onToggleFavorite, + double cardWidth, + double cardHeight + ) { + return switch (viewStyle) { + case LIST -> listCard(project, gameVersion, favorite, onInstall, onOpenPage, onOpenCreator, onToggleFavorite); + case COMPACT -> compactCard(project, gameVersion, favorite, onInstall, onOpenPage, onOpenCreator, onToggleFavorite, cardWidth, cardHeight); + case GRID -> gridCard(project, gameVersion, favorite, onInstall, onOpenPage, onOpenCreator, onToggleFavorite, cardWidth, cardHeight); + }; + } + + private Node gridCard( + ProjectSummary project, + String gameVersion, + boolean favorite, + Consumer onInstall, + Consumer onOpenPage, + Consumer onOpenCreator, + Consumer onToggleFavorite, + double cardWidth, + double cardHeight + ) { + double width = cardWidth > 0 ? cardWidth : GRID_WIDTH; + double height = cardHeight > 0 ? cardHeight : Math.round(width); + double bannerHeight = Math.round(width / 3.0); + double bodyHeight = Math.max(0, height - bannerHeight); + boolean tight = width < 390 || bodyHeight < 272; + boolean shelfTight = tight && bodyHeight < 216 && height < width * 0.95; + double bodyPadding = shelfTight ? 14 : GRID_BODY_PADDING; + double iconSize = shelfTight + ? Math.max(64, Math.min(72, Math.round(width * 0.215))) + : Math.max(84, Math.min(GRID_ICON_SIZE, Math.round(width * 0.215))); + double iconOverlap = Math.round(iconSize / 2.0); + double bodySpacing = shelfTight ? 3 : tight ? 5 : 6; + double copySpacing = shelfTight ? 3 : tight ? 6 : 8; + double descriptionHeight = shelfTight ? 24 : tight ? 44 : 52; + Optional latestVersion = latestCompatible(project, gameVersion); + StackPane shell = new StackPane(); + shell.getStyleClass().add("project-card-shell"); + shell.setAlignment(Pos.TOP_LEFT); + lockWidth(shell, width); + lockHeight(shell, height); + ProjectCardInteraction.openOnCardClick(shell, project, onOpenPage); + + VBox card = new VBox(0); + card.getStyleClass().addAll("project-card", "project-card-grid"); + cacheCardSurface(card); + lockWidth(card, width); + lockHeight(card, height); + + StackPane banner = media.banner(project, width, bannerHeight); + Node bannerMedia = banner.getChildren().isEmpty() ? null : banner.getChildren().get(0); + banner.getChildren().add(classificationBadge(project.classification())); + StackPane.setAlignment(banner.getChildren().getLast(), Pos.TOP_RIGHT); + StackPane.setMargin(banner.getChildren().getLast(), new Insets(8)); + + VBox body = new VBox(bodySpacing); + body.getStyleClass().add("project-card-body"); + if (shelfTight) { + body.getStyleClass().add("project-card-body-shelf-tight"); + } + lockHeight(body, bodyHeight); + VBox.setVgrow(body, Priority.ALWAYS); + StackPane icon = media.projectIcon(project, iconSize, 4); + VBox.setMargin(icon, new Insets(-(iconOverlap + bodyPadding), 0, 0, 0)); + + VBox copy = new VBox(copySpacing); + copy.getStyleClass().add("project-copy"); + Label title = titleText(value(project.title(), "Untitled Project"), "project-title", TITLE_FONT_SIZE); + title.setTextOverrun(OverrunStyle.ELLIPSIS); + Label description = text(value(project.description(), "No description provided."), "project-description"); + description.setWrapText(true); + description.setAlignment(Pos.TOP_LEFT); + description.setPrefHeight(descriptionHeight); + description.setMinHeight(descriptionHeight); + description.setMaxHeight(descriptionHeight); + VBox.setMargin(description, new Insets(tight ? 1 : 2, 0, 0, 0)); + copy.getChildren().addAll(title, authorLine(project, "By", "byline", onOpenCreator), description); + + Region footerSpacer = new Region(); + VBox.setVgrow(footerSpacer, Priority.ALWAYS); + body.getChildren().addAll(icon, copy, footerSpacer, + installStatsRow(project, latestVersion, favorite, onInstall, onToggleFavorite, tight)); + card.getChildren().addAll(banner, body); + shell.getChildren().addAll(card, media.restingOutline(shell), media.hoverOutline(shell, shell)); + ProjectCardInteraction.addHoverAnimation(shell, icon, bannerMedia); + return shell; + } + + private Node listCard( + ProjectSummary project, + String gameVersion, + boolean favorite, + Consumer onInstall, + Consumer onOpenPage, + Consumer onOpenCreator, + Consumer onToggleFavorite + ) { + HBox card = new HBox(18); + card.getStyleClass().addAll("project-card", "project-card-list"); + cacheCardSurface(card); + card.setAlignment(Pos.TOP_LEFT); + card.setMaxWidth(Double.MAX_VALUE); + ProjectCardInteraction.openOnCardClick(card, project, onOpenPage); + Optional latestVersion = latestCompatible(project, gameVersion); + + StackPane icon = media.projectIcon(project, LIST_ICON_SIZE, 4); + VBox copy = new VBox(8); + copy.setMinWidth(0); + HBox.setHgrow(copy, Priority.ALWAYS); + + HBox titleRow = new HBox(12); + titleRow.setAlignment(Pos.TOP_LEFT); + Label title = titleText(value(project.title(), "Untitled Project"), "project-title", TITLE_FONT_SIZE); + title.setTextOverrun(OverrunStyle.ELLIPSIS); + HBox.setHgrow(title, Priority.ALWAYS); + titleRow.getChildren().addAll(title, classificationBadge(project.classification())); + + Label description = text(value(project.description(), "No description provided."), "project-description"); + description.setWrapText(true); + description.setMaxHeight(40); + copy.getChildren().addAll(titleRow, authorLine(project, "by", "byline", onOpenCreator), description, statsRow(project, favorite, onToggleFavorite)); + + VBox actions = new VBox(8); + actions.setAlignment(Pos.CENTER_RIGHT); + actions.getChildren().addAll(versionLabel(latestVersion), installButton(project, onInstall)); + card.getChildren().addAll(icon, copy, actions); + ProjectCardInteraction.addHoverAnimation(card, icon); + return card; + } + + private Node compactCard( + ProjectSummary project, + String gameVersion, + boolean favorite, + Consumer onInstall, + Consumer onOpenPage, + Consumer onOpenCreator, + Consumer onToggleFavorite, + double cardWidth, + double cardHeight + ) { + HBox card = new HBox(16); + card.getStyleClass().addAll("project-card", "project-card-compact"); + cacheCardSurface(card); + card.setAlignment(Pos.CENTER_LEFT); + lockWidth(card, cardWidth > 0 ? cardWidth : COMPACT_WIDTH); + if (cardHeight > 0) { + lockHeight(card, cardHeight); + } + ProjectCardInteraction.openOnCardClick(card, project, onOpenPage); + Optional latestVersion = latestCompatible(project, gameVersion); + + StackPane icon = media.projectIcon(project, 64, 2); + VBox copy = new VBox(5); + copy.setMinWidth(0); + HBox.setHgrow(copy, Priority.ALWAYS); + Label title = titleText(value(project.title(), "Untitled Project"), "compact-title", COMPACT_TITLE_FONT_SIZE); + title.setTextOverrun(OverrunStyle.ELLIPSIS); + copy.getChildren().addAll(title, authorLine(project, "by", "compact-byline", onOpenCreator)); + + VBox stats = new VBox(4, statLabel(LauncherIcons.Glyph.DOWNLOAD, number(project.downloadCount())), + favoriteStat(project, favorite, onToggleFavorite)); + stats.getStyleClass().add("compact-stats"); + + Button install = installButton(project, onInstall); + install.getStyleClass().add("icon-only-button"); + install.setText(""); + install.setMinWidth(42); + install.setPrefWidth(42); + install.setGraphic(LauncherIcons.icon(LauncherIcons.Glyph.DOWNLOAD, 16)); + + card.getChildren().addAll(icon, copy, stats, install, LauncherIcons.icon(LauncherIcons.Glyph.CHEVRON_RIGHT, 16)); + ProjectCardInteraction.addHoverAnimation(card, icon); + return card; + } + + private Node classificationBadge(String classification) { + HBox badge = new HBox(6); + badge.getStyleClass().add("classification-badge"); + badge.setAlignment(Pos.CENTER); + badge.setMaxSize(Region.USE_PREF_SIZE, Region.USE_PREF_SIZE); + badge.getChildren().addAll( + LauncherIcons.icon(BrowseOptions.classification(classification).icon(), 14), + text(classificationLabel(classification), "classification-label") + ); + return badge; + } + + private static void cacheCardSurface(Region card) { + card.setCache(true); + card.setCacheHint(CacheHint.SPEED); + } + + private Node installStatsRow( + ProjectSummary project, + Optional latestVersion, + boolean favorite, + Consumer onInstall, + Consumer onToggleFavorite, + boolean tight + ) { + HBox row = new HBox(tight ? 12 : 16); + row.getStyleClass().add("project-stats"); + row.setAlignment(Pos.CENTER_LEFT); + row.setMinHeight(CARD_STAT_HEIGHT); + row.setPrefHeight(CARD_STAT_HEIGHT); + + HBox left = new HBox(tight ? 12 : 16, statLabel(LauncherIcons.Glyph.DOWNLOAD, number(project.downloadCount()), true), + favoriteStat(project, favorite, onToggleFavorite, true)); + left.setAlignment(Pos.CENTER_LEFT); + Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + Button install = installButton(project, onInstall); + if (tight) { + install.getStyleClass().add("icon-only-button"); + install.setText(""); + install.setMinWidth(38); + install.setPrefWidth(38); + } + install.setMinHeight(CARD_STAT_HEIGHT); + install.setPrefHeight(CARD_STAT_HEIGHT); + row.getChildren().addAll(left, spacer, install); + return row; + } + + private Node statsRow(ProjectSummary project, boolean favorite, Consumer onToggleFavorite) { + HBox row = new HBox(16, statLabel(LauncherIcons.Glyph.DOWNLOAD, number(project.downloadCount()), true), + favoriteStat(project, favorite, onToggleFavorite, true)); + row.getStyleClass().add("project-stats"); + row.setAlignment(Pos.CENTER_LEFT); + return row; + } + + private HBox statLabel(LauncherIcons.Glyph glyph, String value) { + return statLabel(glyph, value, false); + } + + private HBox statLabel(LauncherIcons.Glyph glyph, String value, boolean fullSize) { + HBox stat = new HBox(5); + stat.getStyleClass().addAll(fullSize ? "project-stat-large" : "project-stat"); + stat.setAlignment(Pos.CENTER_LEFT); + if (fullSize) { + stat.setMinHeight(CARD_STAT_HEIGHT); + stat.setPrefHeight(CARD_STAT_HEIGHT); + } + stat.getChildren().addAll(LauncherIcons.icon(glyph, fullSize ? CARD_STAT_ICON_SIZE : COMPACT_STAT_ICON_SIZE), + text(value, fullSize ? "project-stat-text-large" : "project-stat-text")); + return stat; + } + + private Button favoriteStat(ProjectSummary project, boolean favorite, Consumer onToggleFavorite) { + return favoriteStat(project, favorite, onToggleFavorite, false); + } + + private Button favoriteStat( + ProjectSummary project, + boolean favorite, + Consumer onToggleFavorite, + boolean fullSize + ) { + Button button = new Button(number(project.favoriteCount())); + button.getStyleClass().addAll(fullSize ? "project-stat-large" : "project-stat", "favorite-stat"); + if (fullSize) { + button.setMinHeight(CARD_STAT_HEIGHT); + button.setPrefHeight(CARD_STAT_HEIGHT); + } + button.setGraphic(LauncherIcons.icon(LauncherIcons.Glyph.HEART, + fullSize ? CARD_STAT_ICON_SIZE : COMPACT_STAT_ICON_SIZE)); + button.pseudoClassStateChanged(SELECTED, favorite); + button.setOnAction(event -> { + event.consume(); + onToggleFavorite.accept(project); + }); + return button; + } + + private HBox authorLine( + ProjectSummary project, + String prefix, + String textStyleClass, + Consumer onOpenCreator + ) { + HBox row = new HBox(4); + row.getStyleClass().add("byline-row"); + row.setAlignment(Pos.CENTER_LEFT); + Label by = text(prefix, textStyleClass); + Button author = new Button(value(project.author(), "Unknown")); + author.getStyleClass().addAll("author-link", textStyleClass); + author.setAlignment(Pos.CENTER_LEFT); + author.setTextOverrun(OverrunStyle.ELLIPSIS); + author.setMinWidth(0); + author.setOnAction(event -> { + event.consume(); + onOpenCreator.accept(project); + }); + row.getChildren().addAll(by, author); + return row; + } + + private Label versionLabel(Optional latestVersion) { + Label latest = text(latestVersion.map(version -> "Latest " + version.versionNumber()).orElse(""), "latest-label"); + latest.setVisible(latestVersion.isPresent()); + latest.setManaged(latestVersion.isPresent()); + latest.setTextOverrun(OverrunStyle.ELLIPSIS); + return latest; + } + + private Button installButton(ProjectSummary project, Consumer onInstall) { + Button install = new Button("Install"); + install.getStyleClass().addAll("btn", "primary", "small", "project-install-button"); + install.setGraphic(LauncherIcons.icon(LauncherIcons.Glyph.DOWNLOAD, 16)); + install.setOnAction(event -> { + event.consume(); + onInstall.accept(project); + }); + return install; + } + + private Optional latestCompatible(ProjectSummary project, String gameVersion) { + return VersionSelector.latestCompatible(project.versions(), gameVersion); + } + + private Label text(String value, String styleClass) { + Label label = new Label(value); + label.getStyleClass().add(styleClass); + return label; + } + + private Label titleText(String value, String styleClass, double fontSize) { + Label label = text(value, styleClass); + label.setFont(Font.font("Arial Black", FontWeight.BLACK, fontSize)); + return label; + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/browse/card/ProjectCardFormatter.java b/launcher/src/main/java/net/modtale/launcher/ui/browse/card/ProjectCardFormatter.java new file mode 100644 index 00000000..3e2a1085 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/browse/card/ProjectCardFormatter.java @@ -0,0 +1,80 @@ +package net.modtale.launcher.ui.browse.card; + +import java.text.NumberFormat; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeParseException; +import java.util.Locale; +import net.modtale.launcher.model.project.ProjectClassification; + +public final class ProjectCardFormatter { + + private static final NumberFormat NUMBER_FORMAT = NumberFormat.getIntegerInstance(Locale.US); + + private ProjectCardFormatter() { + } + + public static String value(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value; + } + + public static String number(int value) { + return NUMBER_FORMAT.format(value); + } + + public static String classificationLabel(String classification) { + return ProjectClassification.compactLabelFor(classification); + } + + public static String timeAgo(String rawDate) { + if (rawDate == null || rawDate.isBlank()) { + return "Unknown"; + } + try { + Instant instant = Instant.parse(rawDate); + return timeAgo(instant); + } catch (DateTimeParseException ex) { + try { + return timeAgo(LocalDateTime.parse(rawDate).atZone(ZoneId.systemDefault()).toInstant()); + } catch (DateTimeParseException ignored) { + try { + return timeAgo(LocalDate.parse(rawDate).atStartOfDay(ZoneId.systemDefault()).toInstant()); + } catch (DateTimeParseException ignoredAgain) { + return rawDate; + } + } + } + } + + private static String timeAgo(Instant instant) { + Duration age = Duration.between(instant, Instant.now()); + if (age.isNegative()) { + return "Just now"; + } + long seconds = age.toSeconds(); + double interval = seconds / 31_536_000.0; + if (interval > 1) { + return (long) interval + "y ago"; + } + interval = seconds / 2_592_000.0; + if (interval > 1) { + return (long) interval + "mo ago"; + } + interval = seconds / 86_400.0; + if (interval > 1) { + return (long) interval + "d ago"; + } + interval = seconds / 3_600.0; + if (interval > 1) { + return (long) interval + "h ago"; + } + interval = seconds / 60.0; + if (interval > 1) { + return (long) interval + "m ago"; + } + return "Just now"; + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/browse/card/ProjectCardInteraction.java b/launcher/src/main/java/net/modtale/launcher/ui/browse/card/ProjectCardInteraction.java new file mode 100644 index 00000000..5bc4a9a1 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/browse/card/ProjectCardInteraction.java @@ -0,0 +1,208 @@ +package net.modtale.launcher.ui.browse.card; + +import java.util.function.Consumer; +import javafx.animation.Animation; +import javafx.animation.Interpolator; +import javafx.animation.ScaleTransition; +import javafx.animation.TranslateTransition; +import javafx.scene.CacheHint; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.input.MouseEvent; +import javafx.scene.input.ScrollEvent; +import javafx.scene.layout.Region; +import javafx.util.Duration; +import net.modtale.launcher.model.project.ProjectSummary; + +public final class ProjectCardInteraction { + + private static final String ANIMATION_CACHE_ENABLED_PROPERTY = "net.modtale.launcher.animationCacheEnabled"; + private static final String ANIMATION_CACHE_HINT_PROPERTY = "net.modtale.launcher.animationCacheHint"; + private static final Interpolator HOVER_EASE = Interpolator.SPLINE(0.16, 1.0, 0.30, 1.0); + private static final Duration HOVER_TRANSLATE_DURATION = Duration.millis(800); + private static final Duration HOVER_SCALE_DURATION = Duration.millis(900); + + private ProjectCardInteraction() { + } + + public static void openOnCardClick(Region card, ProjectSummary project, Consumer onOpenPage) { + card.addEventFilter(MouseEvent.MOUSE_CLICKED, event -> { + if (isInteractive(event.getTarget())) { + return; + } + onOpenPage.accept(project); + }); + } + + public static void addHoverAnimation(Region card, Node floatingIcon) { + addHoverAnimation(card, floatingIcon, null); + } + + public static void addHoverAnimation(Region card, Node floatingIcon, Node zoomMedia) { + TranslateTransition cardTransition = hoverTransition(card); + TranslateTransition iconTransition = hoverTransition(floatingIcon); + ScaleTransition mediaTransition = zoomMedia == null ? null : scaleTransition(zoomMedia); + card.addEventFilter(ScrollEvent.SCROLL, event -> { + if (hasHoverEffects(cardTransition, iconTransition, mediaTransition)) { + resetHoverEffects(cardTransition, iconTransition, mediaTransition); + } + }); + card.setOnMouseEntered(event -> { + if (isScrollActive(card)) { + resetHoverEffects(cardTransition, iconTransition, mediaTransition); + return; + } + animateY(cardTransition, -4); + animateY(iconTransition, -7); + animateScale(mediaTransition, 1.05); + }); + card.setOnMouseExited(event -> { + if (isScrollActive(card)) { + resetHoverEffects(cardTransition, iconTransition, mediaTransition); + return; + } + animateY(cardTransition, 0); + animateY(iconTransition, 0); + animateScale(mediaTransition, 1); + }); + } + + private static boolean isInteractive(Object target) { + if (!(target instanceof Node node)) { + return false; + } + while (node != null) { + if (node instanceof Button) { + return true; + } + node = node.getParent(); + } + return false; + } + + private static TranslateTransition hoverTransition(Node node) { + TranslateTransition transition = new TranslateTransition(HOVER_TRANSLATE_DURATION, node); + transition.setInterpolator(HOVER_EASE); + transition.setOnFinished(event -> releaseAnimationCache(node)); + return transition; + } + + private static ScaleTransition scaleTransition(Node node) { + ScaleTransition transition = new ScaleTransition(HOVER_SCALE_DURATION, node); + transition.setInterpolator(HOVER_EASE); + transition.setOnFinished(event -> releaseAnimationCache(node)); + return transition; + } + + private static void animateY(TranslateTransition transition, double y) { + Node node = transition.getNode(); + if (Math.abs(node.getTranslateY() - y) < 0.1) { + return; + } + transition.stop(); + transition.setFromY(node.getTranslateY()); + transition.setToY(y); + prepareAnimationCache(node); + transition.playFromStart(); + } + + private static void animateScale(ScaleTransition transition, double scale) { + if (transition == null) { + return; + } + Node node = transition.getNode(); + if (Math.abs(node.getScaleX() - scale) < 0.01 && Math.abs(node.getScaleY() - scale) < 0.01) { + return; + } + transition.stop(); + transition.setFromX(node.getScaleX()); + transition.setFromY(node.getScaleY()); + transition.setToX(scale); + transition.setToY(scale); + prepareAnimationCache(node); + transition.playFromStart(); + } + + private static void resetHoverOffsets(TranslateTransition... transitions) { + for (TranslateTransition transition : transitions) { + transition.stop(); + Node node = transition.getNode(); + if (Math.abs(node.getTranslateY()) >= 0.1) { + node.setTranslateY(0); + } + releaseAnimationCache(node); + } + } + + private static void resetHoverEffects( + TranslateTransition cardTransition, + TranslateTransition iconTransition, + ScaleTransition mediaTransition + ) { + resetHoverOffsets(cardTransition, iconTransition); + if (mediaTransition != null) { + mediaTransition.stop(); + Node node = mediaTransition.getNode(); + if (Math.abs(node.getScaleX() - 1) >= 0.01) { + node.setScaleX(1); + } + if (Math.abs(node.getScaleY() - 1) >= 0.01) { + node.setScaleY(1); + } + releaseAnimationCache(node); + } + } + + private static boolean hasHoverEffects( + TranslateTransition cardTransition, + TranslateTransition iconTransition, + ScaleTransition mediaTransition + ) { + return isTranslateActive(cardTransition) + || isTranslateActive(iconTransition) + || isScaleActive(mediaTransition); + } + + private static boolean isTranslateActive(TranslateTransition transition) { + return transition.getStatus() == Animation.Status.RUNNING + || Math.abs(transition.getNode().getTranslateY()) >= 0.1; + } + + private static boolean isScaleActive(ScaleTransition transition) { + if (transition == null) { + return false; + } + Node node = transition.getNode(); + return transition.getStatus() == Animation.Status.RUNNING + || Math.abs(node.getScaleX() - 1) >= 0.01 + || Math.abs(node.getScaleY() - 1) >= 0.01; + } + + private static void prepareAnimationCache(Node node) { + if (!node.getProperties().containsKey(ANIMATION_CACHE_ENABLED_PROPERTY)) { + node.getProperties().put(ANIMATION_CACHE_ENABLED_PROPERTY, node.isCache()); + node.getProperties().put(ANIMATION_CACHE_HINT_PROPERTY, node.getCacheHint()); + } + node.setCache(true); + node.setCacheHint(CacheHint.SPEED); + } + + private static void releaseAnimationCache(Node node) { + Object cached = node.getProperties().remove(ANIMATION_CACHE_ENABLED_PROPERTY); + Object hint = node.getProperties().remove(ANIMATION_CACHE_HINT_PROPERTY); + if (!(cached instanceof Boolean wasCached)) { + return; + } + node.setCache(wasCached); + if (hint instanceof CacheHint cacheHint) { + node.setCacheHint(cacheHint); + } else { + node.setCacheHint(CacheHint.DEFAULT); + } + } + + private static boolean isScrollActive(Node node) { + return node.getScene() != null + && Boolean.TRUE.equals(node.getScene().getRoot().getProperties().get(ProjectCardFactory.SCROLL_ACTIVE_PROPERTY)); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/browse/card/ProjectCardMedia.java b/launcher/src/main/java/net/modtale/launcher/ui/browse/card/ProjectCardMedia.java new file mode 100644 index 00000000..2b349db0 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/browse/card/ProjectCardMedia.java @@ -0,0 +1,238 @@ +package net.modtale.launcher.ui.browse.card; + +import static net.modtale.launcher.ui.browse.card.ProjectCardFormatter.value; + +import java.util.Locale; +import java.util.concurrent.Executor; +import java.util.function.Function; +import javafx.geometry.Pos; +import javafx.scene.control.Label; +import javafx.scene.image.ImageView; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.paint.Color; +import javafx.scene.shape.ClosePath; +import javafx.scene.shape.LineTo; +import javafx.scene.shape.MoveTo; +import javafx.scene.shape.Path; +import javafx.scene.shape.QuadCurveTo; +import javafx.scene.shape.Rectangle; +import javafx.stage.Screen; +import net.modtale.launcher.model.project.ProjectSummary; +import net.modtale.launcher.ui.common.CachedImageLoader; + +public final class ProjectCardMedia { + + private static final double CARD_RADIUS = 16; + private static final double RESTING_OUTLINE_WIDTH = 1; + private static final double HOVER_OUTLINE_WIDTH = 3; + private static final double MAX_IMAGE_RENDER_SCALE = 3; + private static final double PROJECT_ICON_RADIUS = 16; + private static final double COMPACT_PROJECT_ICON_RADIUS = 10; + + private final CachedImageLoader imageLoader; + private final double imageRenderScale; + + public ProjectCardMedia(Function assetResolver, Executor executor) { + this.imageLoader = new CachedImageLoader(assetResolver, executor); + this.imageRenderScale = computeImageRenderScale(); + } + + public void clearImageCache() { + imageLoader.clearMemory(); + } + + public StackPane banner(ProjectSummary project, double width, double height) { + StackPane banner = new StackPane(); + banner.getStyleClass().add("project-banner"); + lockWidth(banner, width); + banner.setMinHeight(height); + banner.setPrefHeight(height); + banner.setMaxHeight(height); + banner.setClip(topRoundedClip(banner, 16)); + StackPane mediaLayer = new StackPane(); + mediaLayer.getStyleClass().add("project-banner-media"); + mediaLayer.setMouseTransparent(true); + mediaLayer.setMaxSize(Double.MAX_VALUE, Double.MAX_VALUE); + StackPane.setAlignment(mediaLayer, Pos.TOP_CENTER); + banner.getChildren().add(mediaLayer); + + if (project.bannerUrl() != null && !project.bannerUrl().isBlank()) { + mediaLayer.getStyleClass().add("letterboxed"); + ImageView image = remoteBannerImage(project.bannerUrl(), width, height); + image.fitWidthProperty().bind(banner.widthProperty()); + image.fitHeightProperty().bind(banner.heightProperty()); + image.setSmooth(true); + image.setMouseTransparent(true); + image.getStyleClass().add("project-banner-image"); + StackPane.setAlignment(image, Pos.CENTER); + mediaLayer.getChildren().add(image); + } else { + Region fallback = new Region(); + fallback.getStyleClass().add("project-banner-fallback"); + fallback.setMouseTransparent(true); + fallback.setMaxSize(Double.MAX_VALUE, Double.MAX_VALUE); + mediaLayer.getChildren().add(fallback); + } + return banner; + } + + public StackPane projectIcon(ProjectSummary project, double size, double borderWidth) { + StackPane icon = new StackPane(); + icon.getStyleClass().add("project-icon"); + if (borderWidth <= 2) { + icon.getStyleClass().add("project-icon-compact"); + } + icon.setMinSize(size, size); + icon.setPrefSize(size, size); + icon.setMaxSize(size, size); + icon.setPadding(new javafx.geometry.Insets(borderWidth)); + + double mediaSize = Math.max(1, size - borderWidth * 2); + StackPane media = new StackPane(); + media.getStyleClass().add("project-icon-media"); + media.setMinSize(mediaSize, mediaSize); + media.setPrefSize(mediaSize, mediaSize); + media.setMaxSize(mediaSize, mediaSize); + + Rectangle clip = new Rectangle(mediaSize, mediaSize); + double mediaRadius = projectIconMediaRadius(borderWidth); + clip.setArcWidth(mediaRadius * 2); + clip.setArcHeight(mediaRadius * 2); + media.setClip(clip); + + if (project.imageUrl() != null && !project.imageUrl().isBlank()) { + media.getChildren().add(iconBackdrop(mediaSize)); + + ImageView foreground = remoteImage(project.imageUrl(), mediaSize, mediaSize); + foreground.setMouseTransparent(true); + media.getChildren().add(foreground); + } else { + media.getStyleClass().add("project-icon-fallback-media"); + Label initial = new Label(value(project.title(), "M").substring(0, 1).toUpperCase(Locale.ROOT)); + media.getChildren().add(initial); + } + icon.getChildren().add(media); + return icon; + } + + public Rectangle hoverOutline(Region visualOwner, Region hoverOwner) { + Rectangle outline = outline(visualOwner, HOVER_OUTLINE_WIDTH); + outline.getStyleClass().add("project-hover-outline"); + outline.setStroke(Color.web("#3b82f6")); + outline.visibleProperty().bind(hoverOwner.hoverProperty()); + return outline; + } + + public Rectangle restingOutline(Region visualOwner) { + Rectangle outline = outline(visualOwner, RESTING_OUTLINE_WIDTH); + outline.setStroke(Color.rgb(255, 255, 255, 0.20)); + return outline; + } + + public static void lockWidth(Region region, double width) { + region.setMinWidth(width); + region.setPrefWidth(width); + region.setMaxWidth(width); + } + + public static void lockHeight(Region region, double height) { + region.setMinHeight(height); + region.setPrefHeight(height); + region.setMaxHeight(height); + } + + private StackPane iconBackdrop(double size) { + StackPane backdrop = new StackPane(); + backdrop.getStyleClass().add("project-icon-backdrop"); + backdrop.setMinSize(size, size); + backdrop.setPrefSize(size, size); + backdrop.setMaxSize(size, size); + backdrop.setMouseTransparent(true); + return backdrop; + } + + private double projectIconMediaRadius(double borderWidth) { + double outerRadius = borderWidth <= 2 ? COMPACT_PROJECT_ICON_RADIUS : PROJECT_ICON_RADIUS; + return Math.max(0, outerRadius - borderWidth); + } + + private Rectangle outline(Region visualOwner, double strokeWidth) { + Rectangle outline = new Rectangle(); + double inset = strokeWidth / 2.0; + outline.setX(inset); + outline.setY(inset); + outline.widthProperty().bind(visualOwner.widthProperty().subtract(strokeWidth)); + outline.heightProperty().bind(visualOwner.heightProperty().subtract(strokeWidth)); + outline.setArcWidth(CARD_RADIUS * 2); + outline.setArcHeight(CARD_RADIUS * 2); + outline.setFill(Color.TRANSPARENT); + outline.setStrokeWidth(strokeWidth); + outline.setMouseTransparent(true); + outline.setManaged(false); + return outline; + } + + private Path topRoundedClip(Region owner, double radius) { + MoveTo start = new MoveTo(0, radius); + QuadCurveTo topLeft = new QuadCurveTo(0, 0, radius, 0); + LineTo top = new LineTo(); + top.xProperty().bind(owner.widthProperty().subtract(radius)); + top.setY(0); + QuadCurveTo topRight = new QuadCurveTo(); + topRight.controlXProperty().bind(owner.widthProperty()); + topRight.setControlY(0); + topRight.xProperty().bind(owner.widthProperty()); + topRight.setY(radius); + LineTo right = new LineTo(); + right.xProperty().bind(owner.widthProperty()); + right.yProperty().bind(owner.heightProperty()); + LineTo bottom = new LineTo(); + bottom.setX(0); + bottom.yProperty().bind(owner.heightProperty()); + Path clip = new Path(start, topLeft, top, topRight, right, bottom, new ClosePath()); + clip.setFill(Color.BLACK); + clip.setStroke(null); + return clip; + } + + private ImageView remoteImage(String rawUrl, double width, double height) { + ImageView view = imageView(width, height, false); + imageLoader.loadInto(view, rawUrl, requestedImageDimension(width, imageRenderScale), requestedImageDimension(height, imageRenderScale)); + return view; + } + + private ImageView remoteBannerImage(String rawUrl, double width, double height) { + ImageView view = imageView(width, height, true); + imageLoader.loadInto(view, rawUrl, + requestedImageDimension(width, imageRenderScale), + requestedImageDimension(height, imageRenderScale), + true); + return view; + } + + private ImageView imageView(double width, double height, boolean preserveRatio) { + ImageView view = new ImageView(); + view.setPreserveRatio(preserveRatio); + view.setFitWidth(width); + if (height > 0) { + view.setFitHeight(height); + } + view.setSmooth(true); + return view; + } + + private double computeImageRenderScale() { + return Math.max(1, Math.min(MAX_IMAGE_RENDER_SCALE, Screen.getScreens().stream() + .mapToDouble(screen -> Math.max(screen.getOutputScaleX(), screen.getOutputScaleY())) + .max() + .orElse(1))); + } + + private double requestedImageDimension(double logicalSize, double renderScale) { + if (!Double.isFinite(logicalSize) || logicalSize <= 0) { + return 0; + } + return Math.ceil(logicalSize * renderScale); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/browse/card/ProjectCardViewStyle.java b/launcher/src/main/java/net/modtale/launcher/ui/browse/card/ProjectCardViewStyle.java new file mode 100644 index 00000000..eb4af87a --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/browse/card/ProjectCardViewStyle.java @@ -0,0 +1,7 @@ +package net.modtale.launcher.ui.browse.card; + +public enum ProjectCardViewStyle { + GRID, + LIST, + COMPACT +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/browse/controls/BrowseOptions.java b/launcher/src/main/java/net/modtale/launcher/ui/browse/controls/BrowseOptions.java new file mode 100644 index 00000000..631263f8 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/browse/controls/BrowseOptions.java @@ -0,0 +1,153 @@ +package net.modtale.launcher.ui.browse.controls; + +import java.util.Arrays; +import java.util.List; +import net.modtale.launcher.model.project.ProjectClassification; +import net.modtale.launcher.ui.common.LauncherIcons; + +public final class BrowseOptions { + + public static final List BROWSE_VIEWS = List.of(BrowseViewOption.values()); + + public static final List PROJECT_TYPES = List.of( + ClassificationOption.ALL, + ClassificationOption.MODPACKS, + ClassificationOption.PLUGINS, + ClassificationOption.WORLDS, + ClassificationOption.ART, + ClassificationOption.DATA + ); + + public static final List BROWSE_ITEMS_PER_PAGE_OPTIONS = List.of(6, 12, 24, 48, 96); + + public static final int DEFAULT_ITEMS_PER_PAGE = 12; + + public static final List GLOBAL_TAGS = List.of( + "Adventure", "RPG", "Sci-Fi", "Fantasy", "Survival", "Magic", "Tech", "Exploration", + "Minigame", "PvP", "Parkour", "Hardcore", "Skyblock", "Puzzle", "Quests", "Mobs", + "Economy", "Protection", "Admin Tools", "Chat", "Anti-Cheat", "Performance", "NPCs", + "Library", "API", "Mechanics", "World Gen", "Recipes", "Loot Tables", "Functions", + "Decoration", "Vanilla+", "Kitchen Sink", "City", "Landscape", "Spawn", "Lobby", + "Medieval", "Modern", "Futuristic", "Models", "Textures", "Animations", "Particles" + ); + + private BrowseOptions() { + } + + public static BrowseViewOption browseView(BrowseViewOption view) { + return view == null ? BrowseViewOption.defaultOption() : view; + } + + public static int itemsPerPage(Integer value) { + return BROWSE_ITEMS_PER_PAGE_OPTIONS.contains(value) ? value : DEFAULT_ITEMS_PER_PAGE; + } + + public static ClassificationOption classification(String classification) { + if (classification == null || classification.isBlank()) { + return ClassificationOption.defaultOption(); + } + return Arrays.stream(ClassificationOption.values()) + .filter(option -> option.apiValue().equalsIgnoreCase(classification.trim())) + .findFirst() + .orElse(ClassificationOption.defaultOption()); + } + + public enum BrowseViewOption { + ALL("All Projects", null, ProjectBrowseSort.RELEVANCE, LauncherIcons.Glyph.GLOBE), + POPULAR("Popular", null, ProjectBrowseSort.POPULAR, LauncherIcons.Glyph.STAR), + TRENDING("Trending", null, ProjectBrowseSort.TRENDING, LauncherIcons.Glyph.FLAME), + NEW("New Releases", null, ProjectBrowseSort.NEWEST, LauncherIcons.Glyph.ZAP), + UPDATED("Recently Updated", null, ProjectBrowseSort.UPDATED, LauncherIcons.Glyph.CLOCK), + FAVORITES("My Favorites", "favorites", ProjectBrowseSort.RELEVANCE, LauncherIcons.Glyph.HEART); + + private final String label; + private final String category; + private final ProjectBrowseSort defaultSort; + private final LauncherIcons.Glyph icon; + + BrowseViewOption(String label, String category, ProjectBrowseSort defaultSort, LauncherIcons.Glyph icon) { + this.label = label; + this.category = category; + this.defaultSort = defaultSort; + this.icon = icon; + } + + public String label() { + return label; + } + + public String category() { + return category; + } + + public ProjectBrowseSort defaultSort() { + return defaultSort; + } + + public LauncherIcons.Glyph icon() { + return icon; + } + + public boolean isDefault() { + return this == ALL; + } + + public static BrowseViewOption defaultOption() { + return ALL; + } + } + + public enum ClassificationOption { + ALL("All Projects", null, "All Projects", LauncherIcons.Glyph.LAYOUT), + PLUGINS("Plugins", ProjectClassification.PLUGIN, "Plugins", LauncherIcons.Glyph.FILE_CODE), + DATA("Data", ProjectClassification.DATA, "Data Assets", LauncherIcons.Glyph.DATABASE), + ART("Art", ProjectClassification.ART, "Art Assets", LauncherIcons.Glyph.PALETTE), + WORLDS("Worlds", ProjectClassification.SAVE, "Worlds", LauncherIcons.Glyph.SAVE), + MODPACKS("Modpacks", ProjectClassification.MODPACK, "Modpacks", LauncherIcons.Glyph.LAYERS); + + private final String label; + private final ProjectClassification projectClassification; + private final String browseMenuLabel; + private final LauncherIcons.Glyph icon; + + ClassificationOption( + String label, + ProjectClassification projectClassification, + String browseMenuLabel, + LauncherIcons.Glyph icon + ) { + this.label = label; + this.projectClassification = projectClassification; + this.browseMenuLabel = browseMenuLabel; + this.icon = icon; + } + + public String label() { + return label; + } + + public String classification() { + return apiValue(); + } + + public String apiValue() { + return projectClassification == null ? "" : projectClassification.apiValue(); + } + + public String browseMenuLabel() { + return browseMenuLabel; + } + + public LauncherIcons.Glyph icon() { + return icon; + } + + public boolean isDefault() { + return this == ALL; + } + + public static ClassificationOption defaultOption() { + return ALL; + } + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/browse/controls/GameVersionFilterCatalog.java b/launcher/src/main/java/net/modtale/launcher/ui/browse/controls/GameVersionFilterCatalog.java new file mode 100644 index 00000000..316df392 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/browse/controls/GameVersionFilterCatalog.java @@ -0,0 +1,104 @@ +package net.modtale.launcher.ui.browse.controls; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import net.modtale.launcher.model.project.GameVersionCatalog; + +public final class GameVersionFilterCatalog { + + private static final GameVersionFilterCatalog EMPTY = new GameVersionFilterCatalog(List.of(), Set.of()); + + private final List allVersions; + private final Set preReleaseVersions; + + private GameVersionFilterCatalog(List allVersions, Set preReleaseVersions) { + this.allVersions = allVersions; + this.preReleaseVersions = preReleaseVersions; + } + + public static GameVersionFilterCatalog empty() { + return EMPTY; + } + + public static GameVersionFilterCatalog fromVersions(List versions) { + return new GameVersionFilterCatalog(distinctVersions(versions), Set.of()); + } + + public static GameVersionFilterCatalog from(GameVersionCatalog catalog) { + if (catalog == null) { + return empty(); + } + Set preReleases = preReleaseVersions(catalog); + List ordered = orderedVersions(catalog); + if (ordered.isEmpty()) { + ordered = distinctVersions(catalog.releaseVersions()); + } + return new GameVersionFilterCatalog(ordered, preReleases); + } + + public boolean hasPreReleases() { + return !preReleaseVersions.isEmpty(); + } + + public List visibleVersions(boolean includePreReleases) { + if (allVersions.isEmpty()) { + return List.of(); + } + if (!includePreReleases || preReleaseVersions.isEmpty()) { + return allVersions.stream() + .filter(version -> !preReleaseVersions.contains(version)) + .toList(); + } + List preReleases = allVersions.stream() + .filter(preReleaseVersions::contains) + .toList(); + List releases = allVersions.stream() + .filter(version -> !preReleaseVersions.contains(version)) + .toList(); + List visible = new ArrayList<>(preReleases.size() + releases.size()); + visible.addAll(preReleases); + visible.addAll(releases); + return visible; + } + + private static List orderedVersions(GameVersionCatalog catalog) { + if (catalog.versions() != null && !catalog.versions().isEmpty()) { + return distinctVersions(catalog.versions().stream() + .filter(Objects::nonNull) + .map(GameVersionCatalog.GameVersionEntry::version) + .toList()); + } + return distinctVersions(catalog.allVersions()); + } + + private static Set preReleaseVersions(GameVersionCatalog catalog) { + LinkedHashSet versions = new LinkedHashSet<>(); + if (catalog.versions() != null && !catalog.versions().isEmpty()) { + catalog.versions().stream() + .filter(Objects::nonNull) + .filter(GameVersionCatalog.GameVersionEntry::preRelease) + .map(GameVersionCatalog.GameVersionEntry::version) + .filter(version -> version != null && !version.isBlank()) + .forEach(versions::add); + } + if (versions.isEmpty() && catalog.preReleaseVersions() != null) { + catalog.preReleaseVersions().stream() + .filter(version -> version != null && !version.isBlank()) + .forEach(versions::add); + } + return versions; + } + + private static List distinctVersions(List versions) { + if (versions == null || versions.isEmpty()) { + return List.of(); + } + return versions.stream() + .filter(version -> version != null && !version.isBlank()) + .distinct() + .toList(); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/browse/controls/ProjectBrowseCategories.java b/launcher/src/main/java/net/modtale/launcher/ui/browse/controls/ProjectBrowseCategories.java new file mode 100644 index 00000000..6e197e3c --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/browse/controls/ProjectBrowseCategories.java @@ -0,0 +1,255 @@ +package net.modtale.launcher.ui.browse.controls; + +import static net.modtale.launcher.ui.common.LauncherUi.pseudo; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import javafx.animation.Interpolator; +import javafx.animation.KeyFrame; +import javafx.animation.KeyValue; +import javafx.animation.Timeline; +import javafx.application.Platform; +import javafx.beans.property.ReadOnlyBooleanProperty; +import javafx.beans.property.ReadOnlyBooleanWrapper; +import javafx.beans.property.ReadOnlyDoubleProperty; +import javafx.beans.property.ReadOnlyDoubleWrapper; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.text.Font; +import javafx.scene.text.FontWeight; +import javafx.util.Duration; +import net.modtale.launcher.ui.common.LauncherIcons; +import net.modtale.launcher.ui.common.LauncherScrollSupport; + +public final class ProjectBrowseCategories { + + private static final double VIEWPORT_CHROME_BUFFER = 8; + private static final double OVERFLOW_TOLERANCE = 12; + private static final Interpolator PILL_EASE = Interpolator.SPLINE(0.16, 1.0, 0.30, 1.0); + private static final Duration PILL_TRANSITION_DURATION = Duration.millis(700); + + private final LauncherScrollSupport scrollSupport; + private final Runnable onSearch; + private final Map categoryButtons = new LinkedHashMap<>(); + private final Region pillIndicator = new Region(); + private final Region leftFade = new Region(); + private final Region rightFade = new Region(); + private final ReadOnlyDoubleWrapper contentWidth = new ReadOnlyDoubleWrapper(); + private final ReadOnlyBooleanWrapper overflowing = new ReadOnlyBooleanWrapper(); + + private Timeline pillTimeline; + private BrowseOptions.ClassificationOption selectedClassification = BrowseOptions.ClassificationOption.defaultOption(); + private Node view; + + public ProjectBrowseCategories(LauncherScrollSupport scrollSupport, Runnable onSearch) { + this.scrollSupport = scrollSupport; + this.onSearch = onSearch; + } + + public Node view() { + if (view == null) { + view = buildView(); + } + return view; + } + + public BrowseOptions.ClassificationOption selectedClassification() { + return selectedClassification; + } + + public double contentWidth() { + return contentWidth.get(); + } + + public ReadOnlyDoubleProperty contentWidthProperty() { + return contentWidth.getReadOnlyProperty(); + } + + public boolean isOverflowing() { + return overflowing.get(); + } + + public ReadOnlyBooleanProperty overflowingProperty() { + return overflowing.getReadOnlyProperty(); + } + + public void selectClassification(BrowseOptions.ClassificationOption classification) { + selectedClassification = classification == null + ? BrowseOptions.ClassificationOption.defaultOption() + : classification; + refresh(); + onSearch.run(); + } + + public void refresh() { + categoryButtons.forEach((key, button) -> pseudo(button, "selected", key == selectedClassification)); + animatePill(); + } + + private Node buildView() { + HBox pills = new HBox(4); + pills.getStyleClass().add("category-pill-buttons"); + for (BrowseOptions.ClassificationOption option : BrowseOptions.PROJECT_TYPES) { + addCategory(pills, option); + } + pillIndicator.getStyleClass().add("category-pill-indicator"); + pillIndicator.setMouseTransparent(true); + pillIndicator.setVisible(false); + pillIndicator.setMinWidth(0); + pillIndicator.setMinHeight(36); + pillIndicator.setPrefHeight(36); + pillIndicator.setMaxWidth(0); + pillIndicator.setMaxHeight(36); + pillIndicator.setScaleX(1); + StackPane pillShell = new StackPane(pillIndicator, pills); + pillShell.getStyleClass().add("category-pills"); + pillShell.setMinHeight(46); + pillShell.setPrefHeight(46); + pillShell.setAlignment(Pos.CENTER_LEFT); + StackPane.setAlignment(pillIndicator, Pos.CENTER_LEFT); + StackPane.setAlignment(pills, Pos.CENTER_LEFT); + + ScrollPane categoryScroll = new ScrollPane(pillShell); + categoryScroll.getStyleClass().add("category-scroll"); + categoryScroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + categoryScroll.setVbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + categoryScroll.setFitToHeight(true); + categoryScroll.setPannable(true); + categoryScroll.setMinWidth(0); + categoryScroll.setPrefHeight(48); + scrollSupport.configure(categoryScroll, true); + configureFades(categoryScroll); + + StackPane frame = new StackPane(categoryScroll, leftFade, rightFade); + frame.getStyleClass().add("category-scroll-frame"); + frame.setMinWidth(0); + frame.setPrefHeight(48); + StackPane.setAlignment(categoryScroll, Pos.CENTER_LEFT); + StackPane.setAlignment(leftFade, Pos.CENTER_LEFT); + StackPane.setAlignment(rightFade, Pos.CENTER_RIGHT); + HBox.setHgrow(frame, Priority.NEVER); + pillShell.layoutBoundsProperty().addListener((observable, oldValue, bounds) -> + updateContentWidth(frame, bounds.getWidth())); + Platform.runLater(() -> updateContentWidth(frame, pillShell.getLayoutBounds().getWidth())); + Platform.runLater(this::refresh); + return frame; + } + + private void addCategory(HBox pane, BrowseOptions.ClassificationOption option) { + Button button = new Button(); + button.getStyleClass().add("pill"); + HBox content = new HBox(8, LauncherIcons.icon(option.icon(), 15), + spacedTitleText(option.label(), "pill-label", 14)); + content.setAlignment(Pos.CENTER); + content.setMouseTransparent(true); + button.setGraphic(content); + button.setOnAction(event -> selectClassification(option)); + categoryButtons.put(option, button); + pane.getChildren().add(button); + } + + private Label text(String value, String styleClass) { + Label label = new Label(value); + label.getStyleClass().add(styleClass); + return label; + } + + private Node spacedTitleText(String value, String styleClass, double fontSize) { + HBox text = new HBox(); + text.getStyleClass().add(styleClass); + text.setAlignment(Pos.CENTER); + for (char letter : value.toCharArray()) { + Label letterLabel = text(String.valueOf(letter), styleClass + "-letter"); + letterLabel.setFont(Font.font("Arial Black", FontWeight.EXTRA_BOLD, fontSize)); + text.getChildren().add(letterLabel); + } + return text; + } + + private void configureFades(ScrollPane categoryScroll) { + leftFade.getStyleClass().setAll("category-edge-fade", "left"); + rightFade.getStyleClass().setAll("category-edge-fade", "right"); + for (Region fade : List.of(leftFade, rightFade)) { + fade.setMouseTransparent(true); + fade.setMinWidth(42); + fade.setPrefWidth(42); + fade.setMaxWidth(42); + fade.setMaxHeight(Double.MAX_VALUE); + fade.setVisible(false); + } + + categoryScroll.hvalueProperty().addListener((observable, oldValue, newValue) -> updateFades(categoryScroll)); + categoryScroll.viewportBoundsProperty().addListener((observable, oldValue, newValue) -> updateFades(categoryScroll)); + categoryScroll.getContent().layoutBoundsProperty().addListener((observable, oldValue, newValue) -> updateFades(categoryScroll)); + Platform.runLater(() -> updateFades(categoryScroll)); + } + + private void updateFades(ScrollPane categoryScroll) { + double contentWidth = categoryScroll.getContent().getLayoutBounds().getWidth(); + double viewportWidth = categoryScroll.getViewportBounds().getWidth(); + boolean overflow = contentWidth > viewportWidth + OVERFLOW_TOLERANCE; + overflowing.set(overflow); + double hValue = categoryScroll.getHvalue(); + leftFade.setVisible(overflow && hValue > 0.01); + rightFade.setVisible(overflow && hValue < 0.99); + } + + private void updateContentWidth(Region frame, double width) { + if (!Double.isFinite(width) || width <= 0) { + return; + } + double nextWidth = Math.ceil(width); + contentWidth.set(nextWidth); + frame.setPrefWidth(nextWidth + VIEWPORT_CHROME_BUFFER); + frame.setMaxWidth(nextWidth + VIEWPORT_CHROME_BUFFER); + } + + private void animatePill() { + Button selected = categoryButtons.getOrDefault( + selectedClassification, + categoryButtons.get(BrowseOptions.ClassificationOption.defaultOption()) + ); + if (selected == null) { + return; + } + if (selected.getWidth() <= 0) { + Platform.runLater(this::animatePill); + return; + } + pillIndicator.setVisible(true); + double targetX = selected.getBoundsInParent().getMinX(); + double targetWidth = selected.getWidth(); + if (pillTimeline != null) { + pillTimeline.stop(); + } + if (pillIndicator.getPrefWidth() <= 0) { + pillIndicator.setTranslateX(targetX); + pillIndicator.setMinWidth(targetWidth); + pillIndicator.setPrefWidth(targetWidth); + pillIndicator.setMaxWidth(targetWidth); + pillIndicator.setScaleX(1); + return; + } + double startWidth = Math.max(1, pillIndicator.getBoundsInParent().getWidth()); + double startX = pillIndicator.getBoundsInParent().getMinX(); + double startScale = startWidth / targetWidth; + double startTranslate = startX - (targetWidth - startWidth) / 2.0; + pillIndicator.setMinWidth(targetWidth); + pillIndicator.setPrefWidth(targetWidth); + pillIndicator.setMaxWidth(targetWidth); + pillIndicator.setTranslateX(startTranslate); + pillIndicator.setScaleX(startScale); + pillTimeline = new Timeline(new KeyFrame(PILL_TRANSITION_DURATION, + new KeyValue(pillIndicator.translateXProperty(), targetX, PILL_EASE), + new KeyValue(pillIndicator.scaleXProperty(), 1, PILL_EASE))); + pillTimeline.play(); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/browse/controls/ProjectBrowseDownloadTimeframeSelector.java b/launcher/src/main/java/net/modtale/launcher/ui/browse/controls/ProjectBrowseDownloadTimeframeSelector.java new file mode 100644 index 00000000..9451303c --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/browse/controls/ProjectBrowseDownloadTimeframeSelector.java @@ -0,0 +1,101 @@ +package net.modtale.launcher.ui.browse.controls; + +import static net.modtale.launcher.ui.common.LauncherUi.pseudo; +import static net.modtale.launcher.ui.common.LauncherUi.setVisibleManaged; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Supplier; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.Tooltip; +import javafx.scene.layout.HBox; + +public final class ProjectBrowseDownloadTimeframeSelector { + + private final Consumer onSelect; + private final Supplier selectedRange; + private final Map buttons = new LinkedHashMap<>(); + private HBox view; + + public ProjectBrowseDownloadTimeframeSelector(Consumer onSelect, Supplier selectedRange) { + this.onSelect = onSelect; + this.selectedRange = selectedRange; + } + + public Node view() { + if (view == null) { + view = new HBox(4); + view.getStyleClass().add("download-timeframe-control"); + view.setAlignment(Pos.CENTER); + addButton(DownloadTimeframe.SEVEN_DAYS); + addButton(DownloadTimeframe.THIRTY_DAYS); + addButton(DownloadTimeframe.NINETY_DAYS); + addButton(DownloadTimeframe.ALL_TIME); + refresh(); + } + return view; + } + + public void setVisible(boolean visible) { + setVisibleManaged(view(), visible); + } + + public void refresh() { + DownloadTimeframe active = DownloadTimeframe.fromDateRange(selectedRange.get()); + buttons.forEach((range, button) -> pseudo(button, "selected", range.equals(active))); + } + + private void addButton(DownloadTimeframe timeframe) { + Button button = new Button(timeframe.label()); + button.getStyleClass().add("download-timeframe-button"); + button.setTooltip(new Tooltip(timeframe.tooltip())); + button.setOnAction(event -> onSelect.accept(timeframe.dateRange())); + buttons.put(timeframe, button); + view.getChildren().add(button); + } + + private enum DownloadTimeframe { + SEVEN_DAYS("7d", "7d", "Downloads in the last 7 days"), + THIRTY_DAYS("30d", "30d", "Downloads in the last 30 days"), + NINETY_DAYS("90d", "90d", "Downloads in the last 90 days"), + ALL_TIME("All", null, "All-time downloads"); + + private final String label; + private final String dateRange; + private final String tooltip; + + DownloadTimeframe(String label, String dateRange, String tooltip) { + this.label = label; + this.dateRange = dateRange; + this.tooltip = tooltip; + } + + String label() { + return label; + } + + String dateRange() { + return dateRange; + } + + String tooltip() { + return tooltip; + } + + static DownloadTimeframe fromDateRange(String range) { + if (range == null || range.isBlank()) { + return ALL_TIME; + } + String normalized = range.trim(); + for (DownloadTimeframe timeframe : values()) { + if (normalized.equals(timeframe.dateRange)) { + return timeframe; + } + } + return ALL_TIME; + } + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/browse/controls/ProjectBrowseFilterOptions.java b/launcher/src/main/java/net/modtale/launcher/ui/browse/controls/ProjectBrowseFilterOptions.java new file mode 100644 index 00000000..5b7ca6a0 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/browse/controls/ProjectBrowseFilterOptions.java @@ -0,0 +1,503 @@ +package net.modtale.launcher.ui.browse.controls; + +import static net.modtale.launcher.ui.common.LauncherUi.dangerButton; +import static net.modtale.launcher.ui.common.LauncherUi.pseudo; +import static net.modtale.launcher.ui.common.LauncherUi.styleInput; + +import java.time.LocalDate; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Consumer; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.ContentDisplay; +import javafx.scene.control.DatePicker; +import javafx.scene.control.Label; +import javafx.scene.control.TextField; +import javafx.scene.input.ScrollEvent; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import net.modtale.launcher.model.project.GameVersionCatalog; +import net.modtale.launcher.ui.common.GameVersionDropdown; +import net.modtale.launcher.ui.common.GameVersionGroups; +import net.modtale.launcher.ui.common.LauncherIcons; + +public final class ProjectBrowseFilterOptions { + + private final Runnable onSearch; + private final Runnable onChange; + private final Runnable onResetTags; + private final VBox popover = new VBox(20); + private final GameVersionDropdown gameVersionDropdown = GameVersionDropdown.multiSelect(); + private final Button preReleaseToggle = new Button("Pre Releases"); + private final Button openSourceButton = new Button(); + private final TextField customMinFavoritesField = new TextField(); + private final TextField customMinDownloadsField = new TextField(); + private final DatePicker updatedAfterPicker = new DatePicker(); + private final Map minFavoritesButtons = new LinkedHashMap<>(); + private final Map minDownloadsButtons = new LinkedHashMap<>(); + private final Map dateRangeButtons = new LinkedHashMap<>(); + private GameVersionFilterCatalog gameVersions = GameVersionFilterCatalog.empty(); + private Integer minFavoritesPreset; + private Integer minDownloadsPreset; + private DateRangePreset dateRangePreset = DateRangePreset.ANY; + private boolean showPreReleases; + private boolean openSourceOnly; + private boolean downloadSort; + private boolean suppressSearch; + + public ProjectBrowseFilterOptions(Runnable onSearch, Runnable onChange, Runnable onResetTags) { + this.onSearch = onSearch; + this.onChange = onChange; + this.onResetTags = onResetTags; + configureInputs(); + configurePopover(); + } + + public VBox popover() { + return popover; + } + + public int activeFilterCount() { + int count = 0; + if (selectedGameVersion() != null) { + count++; + } + if (openSourceOnly) { + count++; + } + if (selectedMinimumFavorites() != null) { + count++; + } + if (selectedMinimumDownloads() != null) { + count++; + } + if (!downloadSort && selectedDateRange() != null) { + count++; + } + return count; + } + + public void setDownloadSort(boolean downloadSort) { + this.downloadSort = downloadSort; + } + + public String selectedGameVersion() { + return gameVersionDropdown.selectedQuery(); + } + + public Boolean selectedOpenSource() { + return openSourceOnly ? Boolean.TRUE : null; + } + + public Integer selectedMinimumFavorites() { + Integer custom = parsePositiveInteger(customMinFavoritesField.getText()); + if (custom != null) { + return custom; + } + return minFavoritesPreset; + } + + public Integer selectedMinimumDownloads() { + Integer custom = parsePositiveInteger(customMinDownloadsField.getText()); + if (custom != null) { + return custom; + } + return minDownloadsPreset; + } + + public String selectedDateRange() { + LocalDate customDate = updatedAfterPicker.getValue(); + if (customDate != null) { + return customDate.toString(); + } + return dateRangePreset.apiValue(); + } + + public void replaceGameVersions(List versions) { + String selected = selectedGameVersion(); + withSuppressedSearch(() -> { + gameVersions = GameVersionFilterCatalog.fromVersions(versions); + showPreReleases = false; + updateGameVersionOptions(selected); + }); + refreshAndNotify(false); + } + + public void replaceGameVersionCatalog(GameVersionCatalog catalog) { + if (catalog == null) { + replaceGameVersions(List.of()); + return; + } + + String selected = selectedGameVersion(); + withSuppressedSearch(() -> { + gameVersions = GameVersionFilterCatalog.from(catalog); + showPreReleases = false; + updateGameVersionOptions(selected); + }); + refreshAndNotify(false); + } + + public void selectDateRange(String dateRange) { + withSuppressedSearch(() -> { + dateRangePreset = DateRangePreset.fromApiValue(dateRange); + updatedAfterPicker.setValue(null); + }); + refreshAndNotify(true); + } + + private void configureInputs() { + gameVersionDropdown.setOnSelectionChange(ignored -> changedAndSearch()); + gameVersionDropdown.getStyleClass().add("filter-game-version-dropdown"); + gameVersionDropdown.setMaxListHeight(192); + preReleaseToggle.getStyleClass().add("pre-release-toggle"); + preReleaseToggle.setGraphic(LauncherIcons.icon(LauncherIcons.Glyph.CHECK, 11)); + preReleaseToggle.setOnAction(event -> togglePreReleases()); + openSourceButton.getStyleClass().add("filter-toggle-button"); + openSourceButton.setMaxWidth(Double.MAX_VALUE); + HBox openSourceContent = openSourceButtonContent(); + openSourceContent.prefWidthProperty().bind(openSourceButton.widthProperty().subtract(24)); + openSourceButton.setGraphic(openSourceContent); + openSourceButton.setContentDisplay(ContentDisplay.GRAPHIC_ONLY); + openSourceButton.setAccessibleText("Open source license filter"); + openSourceButton.setOnAction(event -> toggleOpenSource()); + updatedAfterPicker.setOnAction(event -> { + if (updatedAfterPicker.getValue() != null) { + dateRangePreset = DateRangePreset.ANY; + } + changedAndSearch(); + }); + customMinFavoritesField.setPromptText("Custom min favorites..."); + customMinFavoritesField.textProperty().addListener((observable, oldValue, newValue) -> changedAndSearch()); + customMinDownloadsField.setPromptText("Custom min downloads..."); + customMinDownloadsField.textProperty().addListener((observable, oldValue, newValue) -> changedAndSearch()); + updatedAfterPicker.setPromptText("Pick a date"); + styleInput(customMinFavoritesField, customMinDownloadsField); + customMinFavoritesField.getStyleClass().add("filter-input"); + customMinDownloadsField.getStyleClass().add("filter-input"); + customMinFavoritesField.getStyleClass().add("filter-input-with-icon"); + customMinDownloadsField.getStyleClass().add("filter-input-with-icon"); + updatedAfterPicker.getStyleClass().addAll("date-picker", "filter-date-picker"); + } + + private void configurePopover() { + popover.getStyleClass().add("filter-popover"); + popover.setPrefWidth(288); + popover.setMaxWidth(288); + popover.setVisible(false); + popover.setManaged(false); + popover.addEventHandler(ScrollEvent.SCROLL, ScrollEvent::consume); + + Node version = gameVersionSection(); + Node license = filterSection("LICENSE", openSourceButton); + Node favorites = filterSection( + "MINIMUM FAVORITES", + numberPresetRow(minFavoritesButtons, List.of( + new NumberPreset("Any", null), + new NumberPreset("10+", 10), + new NumberPreset("50+", 50), + new NumberPreset("100+", 100) + ), this::selectMinimumFavoritesPreset), + inputWithIcon(customMinFavoritesField, LauncherIcons.Glyph.HEART) + ); + Node downloads = filterSection( + "DOWNLOADS", + numberPresetRow(minDownloadsButtons, List.of( + new NumberPreset("Any", null), + new NumberPreset("1k+", 1_000), + new NumberPreset("5k+", 5_000), + new NumberPreset("10k+", 10_000) + ), this::selectMinimumDownloadsPreset), + inputWithIcon(customMinDownloadsField, LauncherIcons.Glyph.DOWNLOAD) + ); + Node updated = filterSection( + "LAST UPDATED", + datePresetRow(), + datePickerWithIcon() + ); + + Button reset = dangerButton("Reset Filters"); + reset.setGraphic(LauncherIcons.icon(LauncherIcons.Glyph.ROTATE_CCW, 14)); + reset.getStyleClass().add("filter-reset-button"); + reset.setMaxWidth(Double.MAX_VALUE); + reset.setOnAction(event -> reset(true)); + VBox resetSection = new VBox(reset); + resetSection.getStyleClass().add("filter-reset-section"); + + popover.getChildren().setAll(version, license, favorites, downloads, updated, resetSection); + refreshPresetButtons(); + } + + private Node gameVersionSection() { + VBox box = new VBox(6); + box.getStyleClass().add("filter-section"); + HBox header = new HBox(8); + header.setAlignment(Pos.CENTER_LEFT); + Label labelNode = new Label("GAME VERSION"); + labelNode.getStyleClass().add("filter-label"); + Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + header.getChildren().addAll(labelNode, spacer, preReleaseToggle); + box.getChildren().addAll(header, gameVersionDropdown); + gameVersionDropdown.setMaxWidth(Double.MAX_VALUE); + return box; + } + + private HBox openSourceButtonContent() { + HBox content = new HBox(8); + content.getStyleClass().add("filter-toggle-content"); + content.setAlignment(Pos.CENTER_LEFT); + Node check = LauncherIcons.icon(LauncherIcons.Glyph.CHECK, 13); + check.getStyleClass().add("filter-toggle-check"); + Label label = new Label("Open Source"); + label.getStyleClass().add("filter-toggle-label"); + Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + Node scale = LauncherIcons.icon(LauncherIcons.Glyph.SCALE, 14); + scale.getStyleClass().add("filter-toggle-icon"); + content.getChildren().addAll(check, label, spacer, scale); + return content; + } + + private Node inputWithIcon(TextField field, LauncherIcons.Glyph glyph) { + StackPane wrapper = new StackPane(field); + wrapper.getStyleClass().add("filter-input-wrap"); + Node icon = LauncherIcons.icon(glyph, 14); + icon.getStyleClass().add("filter-input-icon"); + icon.setMouseTransparent(true); + StackPane.setAlignment(icon, Pos.CENTER_LEFT); + StackPane.setMargin(icon, new Insets(0, 0, 0, 12)); + wrapper.getChildren().add(icon); + field.setMaxWidth(Double.MAX_VALUE); + return wrapper; + } + + private Node datePickerWithIcon() { + StackPane wrapper = new StackPane(updatedAfterPicker); + wrapper.getStyleClass().add("filter-input-wrap"); + Node icon = LauncherIcons.icon(LauncherIcons.Glyph.CALENDAR, 14); + icon.getStyleClass().add("filter-input-icon"); + icon.setMouseTransparent(true); + StackPane.setAlignment(icon, Pos.CENTER_LEFT); + StackPane.setMargin(icon, new Insets(0, 0, 0, 12)); + wrapper.getChildren().add(icon); + updatedAfterPicker.setMaxWidth(Double.MAX_VALUE); + return wrapper; + } + + private Node filterSection(String label, Node... controls) { + VBox box = new VBox(6); + Label labelNode = new Label(label); + labelNode.getStyleClass().add("filter-label"); + box.getStyleClass().add("filter-section"); + box.getChildren().add(labelNode); + for (Node control : controls) { + if (control instanceof Region region) { + region.setMaxWidth(Double.MAX_VALUE); + } + box.getChildren().add(control); + } + return box; + } + + private HBox numberPresetRow( + Map buttons, + List presets, + Consumer onSelect + ) { + HBox row = new HBox(4); + row.getStyleClass().add("filter-preset-row"); + row.setAlignment(Pos.CENTER); + for (NumberPreset preset : presets) { + Button button = presetButton(preset.label()); + button.setOnAction(event -> onSelect.accept(preset.value())); + buttons.put(preset.value(), button); + row.getChildren().add(button); + HBox.setHgrow(button, Priority.ALWAYS); + } + return row; + } + + private HBox datePresetRow() { + HBox row = new HBox(4); + row.getStyleClass().add("filter-preset-row"); + row.setAlignment(Pos.CENTER); + for (DateRangePreset preset : List.of( + DateRangePreset.ANY, + DateRangePreset.SEVEN_DAYS, + DateRangePreset.THIRTY_DAYS, + DateRangePreset.NINETY_DAYS + )) { + Button button = presetButton(preset.label()); + button.setOnAction(event -> selectDateRange(preset.apiValue())); + dateRangeButtons.put(preset, button); + row.getChildren().add(button); + HBox.setHgrow(button, Priority.ALWAYS); + } + return row; + } + + private Button presetButton(String label) { + Button button = new Button(label); + button.getStyleClass().add("filter-preset-button"); + button.setMaxWidth(Double.MAX_VALUE); + return button; + } + + public void reset(boolean runSearch) { + withSuppressedSearch(() -> { + gameVersionDropdown.setSelectedVersions(List.of()); + minFavoritesPreset = null; + minDownloadsPreset = null; + dateRangePreset = DateRangePreset.ANY; + showPreReleases = false; + openSourceOnly = false; + customMinFavoritesField.clear(); + customMinDownloadsField.clear(); + updatedAfterPicker.setValue(null); + onResetTags.run(); + updateGameVersionOptions(null); + }); + refreshAndNotify(runSearch); + } + + private void selectMinimumFavoritesPreset(Integer value) { + withSuppressedSearch(() -> { + minFavoritesPreset = value; + customMinFavoritesField.clear(); + }); + refreshAndNotify(true); + } + + private void selectMinimumDownloadsPreset(Integer value) { + withSuppressedSearch(() -> { + minDownloadsPreset = value; + customMinDownloadsField.clear(); + }); + refreshAndNotify(true); + } + + private void refreshAndNotify(boolean runSearch) { + refreshPresetButtons(); + onChange.run(); + if (runSearch) { + onSearch.run(); + } + } + + private void refreshPresetButtons() { + boolean customFavorites = parsePositiveInteger(customMinFavoritesField.getText()) != null; + boolean customDownloads = parsePositiveInteger(customMinDownloadsField.getText()) != null; + boolean customDate = updatedAfterPicker.getValue() != null; + minFavoritesButtons.forEach((value, button) -> + pseudo(button, "selected", !customFavorites && Objects.equals(value, minFavoritesPreset))); + minDownloadsButtons.forEach((value, button) -> + pseudo(button, "selected", !customDownloads && Objects.equals(value, minDownloadsPreset))); + dateRangeButtons.forEach((value, button) -> + pseudo(button, "selected", !customDate && value == dateRangePreset)); + pseudo(openSourceButton, "selected", openSourceOnly); + boolean hasPreReleases = gameVersions.hasPreReleases(); + preReleaseToggle.setVisible(hasPreReleases); + preReleaseToggle.setManaged(hasPreReleases); + pseudo(preReleaseToggle, "selected", showPreReleases); + } + + private void changedAndSearch() { + if (suppressSearch) { + return; + } + refreshAndNotify(true); + } + + private void withSuppressedSearch(Runnable work) { + boolean previous = suppressSearch; + suppressSearch = true; + try { + work.run(); + } finally { + suppressSearch = previous; + } + } + + private void togglePreReleases() { + String previous = selectedGameVersion(); + withSuppressedSearch(() -> { + showPreReleases = !showPreReleases; + updateGameVersionOptions(previous); + }); + refreshAndNotify(!Objects.equals(previous, selectedGameVersion())); + } + + private void toggleOpenSource() { + openSourceOnly = !openSourceOnly; + changedAndSearch(); + } + + private void updateGameVersionOptions(String preferredSelection) { + List options = gameVersions.visibleVersions(showPreReleases); + List preferred = GameVersionGroups.parseSelection(preferredSelection).stream() + .filter(options::contains) + .toList(); + gameVersionDropdown.setVersions(options); + gameVersionDropdown.setSelectedVersions(preferred); + } + + private static Integer parsePositiveInteger(String value) { + if (value == null || value.isBlank()) { + return null; + } + try { + int parsed = Integer.parseInt(value.trim()); + return parsed > 0 ? parsed : null; + } catch (NumberFormatException ex) { + return null; + } + } + + private record NumberPreset(String label, Integer value) { + } + + private enum DateRangePreset { + ANY("Any", null), + SEVEN_DAYS("7d", "7d"), + THIRTY_DAYS("30d", "30d"), + NINETY_DAYS("90d", "90d"); + + private final String label; + private final String apiValue; + + DateRangePreset(String label, String apiValue) { + this.label = label; + this.apiValue = apiValue; + } + + String label() { + return label; + } + + String apiValue() { + return apiValue; + } + + static DateRangePreset fromApiValue(String rawValue) { + if (rawValue == null || rawValue.isBlank()) { + return ANY; + } + String normalized = rawValue.trim(); + for (DateRangePreset preset : values()) { + if (Objects.equals(preset.apiValue, normalized)) { + return preset; + } + } + return ANY; + } + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/browse/controls/ProjectBrowseSort.java b/launcher/src/main/java/net/modtale/launcher/ui/browse/controls/ProjectBrowseSort.java new file mode 100644 index 00000000..ce4a2de9 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/browse/controls/ProjectBrowseSort.java @@ -0,0 +1,62 @@ +package net.modtale.launcher.ui.browse.controls; + +import java.util.Arrays; +import java.util.Locale; + +public enum ProjectBrowseSort { + RELEVANCE("relevance", "Relevance", ""), + POPULAR("popular", "Popular", "Popular"), + TRENDING("trending", "Trending", "Trending"), + DOWNLOADS("downloads", "Downloads", "Most Downloaded"), + FAVORITES("favorites", "Favorites", "Most Favorited"), + NEWEST("newest", "Newest", "New Releases"), + UPDATED("updated", "Updated", "Recently Updated"); + + private final String apiValue; + private final String label; + private final String title; + + ProjectBrowseSort(String apiValue, String label, String title) { + this.apiValue = apiValue; + this.label = label; + this.title = title; + } + + public String apiValue() { + return apiValue; + } + + public String label() { + return label; + } + + public String title() { + return title; + } + + public BrowseOptions.BrowseViewOption browseView() { + return switch (this) { + case POPULAR -> BrowseOptions.BrowseViewOption.POPULAR; + case TRENDING -> BrowseOptions.BrowseViewOption.TRENDING; + case NEWEST -> BrowseOptions.BrowseViewOption.NEW; + case UPDATED -> BrowseOptions.BrowseViewOption.UPDATED; + case RELEVANCE, DOWNLOADS, FAVORITES -> BrowseOptions.BrowseViewOption.ALL; + }; + } + + public static ProjectBrowseSort defaultSort() { + return RELEVANCE; + } + + public static ProjectBrowseSort fromLabel(String label) { + if (label == null || label.isBlank()) { + return defaultSort(); + } + String normalized = label.trim().toLowerCase(Locale.ROOT); + return Arrays.stream(values()) + .filter(sort -> sort.label.toLowerCase(Locale.ROOT).equals(normalized) + || sort.title.toLowerCase(Locale.ROOT).equals(normalized)) + .findFirst() + .orElse(defaultSort()); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/browse/controls/ProjectBrowseTags.java b/launcher/src/main/java/net/modtale/launcher/ui/browse/controls/ProjectBrowseTags.java new file mode 100644 index 00000000..08e184be --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/browse/controls/ProjectBrowseTags.java @@ -0,0 +1,135 @@ +package net.modtale.launcher.ui.browse.controls; + +import static net.modtale.launcher.ui.common.LauncherUi.pseudo; + +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import javafx.geometry.Pos; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; +import javafx.scene.input.ScrollEvent; +import javafx.scene.layout.FlowPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.VBox; + +public final class ProjectBrowseTags { + + private final Runnable onSearch; + private final Runnable onChange; + private final Map tagButtons = new LinkedHashMap<>(); + private final Set selectedTags = new LinkedHashSet<>(); + private final VBox popover = new VBox(12); + + public ProjectBrowseTags(Runnable onSearch, Runnable onChange) { + this.onSearch = onSearch; + this.onChange = onChange; + configurePopover(); + } + + public VBox popover() { + return popover; + } + + public boolean isEmpty() { + return selectedTags.isEmpty(); + } + + public int selectedCount() { + return selectedTags.size(); + } + + public String selectedQuery() { + return selectedTags.isEmpty() ? null : String.join(",", selectedTags); + } + + public String title() { + String first = selectedTags.iterator().next(); + int remaining = selectedTags.size() - 1; + return remaining > 0 ? "Tagged: " + first + " (+" + remaining + ")" : "Tagged: " + first; + } + + public void clear() { + selectedTags.clear(); + updateButtons(); + } + + public void refresh() { + updateButtons(); + } + + private void configurePopover() { + popover.getStyleClass().addAll("filter-popover", "tag-popover"); + popover.setPrefWidth(288); + popover.setMaxWidth(288); + popover.setVisible(false); + popover.setManaged(false); + popover.addEventHandler(ScrollEvent.SCROLL, ScrollEvent::consume); + + HBox header = new HBox(12); + header.getStyleClass().add("tag-popover-header"); + header.setAlignment(Pos.CENTER_LEFT); + Label title = new Label("Filter by Tag"); + title.getStyleClass().add("popover-title"); + Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + Button clear = new Button("Clear All"); + clear.getStyleClass().add("tag-clear-button"); + clear.setOnAction(event -> { + selectedTags.clear(); + updateButtons(); + onSearch.run(); + }); + header.getChildren().addAll(title, spacer, clear); + + FlowPane tags = new FlowPane(8, 8); + tags.getStyleClass().add("tag-grid"); + for (String tag : BrowseOptions.GLOBAL_TAGS) { + Button button = new Button(tag); + button.getStyleClass().add("tag-chip"); + button.setOnAction(event -> { + if (!selectedTags.add(tag)) { + selectedTags.remove(tag); + } + updateButtons(); + onSearch.run(); + }); + tagButtons.put(tag, button); + tags.getChildren().add(button); + } + + ScrollPane tagScroll = new ScrollPane(tags); + tagScroll.getStyleClass().add("tag-scroll"); + tagScroll.setFitToWidth(true); + tagScroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + tagScroll.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED); + tagScroll.setMaxHeight(240); + tagScroll.addEventFilter(ScrollEvent.SCROLL, event -> scrollTags(tagScroll, event)); + header.addEventFilter(ScrollEvent.SCROLL, ScrollEvent::consume); + popover.getChildren().setAll(header, tagScroll); + } + + private void updateButtons() { + tagButtons.forEach((tag, button) -> pseudo(button, "selected", selectedTags.contains(tag))); + onChange.run(); + } + + private static void scrollTags(ScrollPane scrollPane, ScrollEvent event) { + double scrollable = scrollPane.getContent().getLayoutBounds().getHeight() + - scrollPane.getViewportBounds().getHeight(); + if (scrollable > 1) { + double pixels = switch (event.getTextDeltaYUnits()) { + case LINES -> event.getTextDeltaY() * 48; + case PAGES -> event.getTextDeltaY() * Math.max(120, scrollPane.getViewportBounds().getHeight() * 0.86); + case NONE -> event.getDeltaY(); + }; + double next = scrollPane.getVvalue() - pixels / scrollable; + scrollPane.setVvalue(Math.max(scrollPane.getVmin(), Math.min(next, scrollPane.getVmax()))); + } + event.consume(); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/browse/controls/ProjectBrowseViewStyleSelector.java b/launcher/src/main/java/net/modtale/launcher/ui/browse/controls/ProjectBrowseViewStyleSelector.java new file mode 100644 index 00000000..1164d888 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/browse/controls/ProjectBrowseViewStyleSelector.java @@ -0,0 +1,61 @@ +package net.modtale.launcher.ui.browse.controls; + +import static net.modtale.launcher.ui.common.LauncherUi.pseudo; + +import java.util.LinkedHashMap; +import java.util.Map; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.Tooltip; +import javafx.scene.layout.HBox; +import net.modtale.launcher.ui.browse.card.ProjectCardViewStyle; +import net.modtale.launcher.ui.common.LauncherIcons; + +public final class ProjectBrowseViewStyleSelector { + + private final Runnable onRender; + private final Map buttons = new LinkedHashMap<>(); + private ProjectCardViewStyle style = ProjectCardViewStyle.GRID; + private Node view; + + public ProjectBrowseViewStyleSelector(Runnable onRender) { + this.onRender = onRender; + } + + public ProjectCardViewStyle style() { + return style; + } + + public Node view() { + if (view == null) { + HBox selector = new HBox(4); + selector.getStyleClass().add("segmented-control"); + selector.setAlignment(Pos.CENTER); + addButton(selector, ProjectCardViewStyle.GRID, LauncherIcons.Glyph.GRID, "Grid"); + addButton(selector, ProjectCardViewStyle.LIST, LauncherIcons.Glyph.LIST, "List"); + addButton(selector, ProjectCardViewStyle.COMPACT, LauncherIcons.Glyph.ALIGN_JUSTIFY, "Compact"); + refresh(); + view = selector; + } + return view; + } + + public void refresh() { + buttons.forEach((candidate, button) -> pseudo(button, "selected", candidate == style)); + } + + private void addButton(HBox selector, ProjectCardViewStyle candidate, LauncherIcons.Glyph icon, String label) { + Button button = new Button(); + button.getStyleClass().add("segmented-button"); + button.setGraphic(LauncherIcons.icon(icon, 16)); + button.setTooltip(new Tooltip(label)); + button.setOnAction(event -> { + style = candidate; + refresh(); + onRender.run(); + }); + buttons.put(candidate, button); + selector.getChildren().add(button); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/browse/render/ProjectBrowserRenderer.java b/launcher/src/main/java/net/modtale/launcher/ui/browse/render/ProjectBrowserRenderer.java new file mode 100644 index 00000000..2ea2fbc2 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/browse/render/ProjectBrowserRenderer.java @@ -0,0 +1,385 @@ +package net.modtale.launcher.ui.browse.render; + +import static net.modtale.launcher.ui.common.LauncherUi.emptyState; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; +import javafx.geometry.Bounds; +import javafx.scene.Node; +import javafx.scene.control.ScrollPane; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Pane; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import net.modtale.launcher.LauncherPerformanceProbe; +import net.modtale.launcher.model.project.ProjectSummary; +import net.modtale.launcher.ui.browse.card.ProjectCardFactory; +import net.modtale.launcher.ui.browse.card.ProjectCardViewStyle; + +public final class ProjectBrowserRenderer { + + private static final int MAX_CACHED_PROJECT_CARDS = 256; + private static final int LIST_PAGE_SIZE = 12; + private static final int CARD_PAGE_SIZE = 12; + private static final int COMPACT_PAGE_SIZE = 45; + private static final double GRID_MIN_CARD_BODY_HEIGHT = 216; + private static final double GRID_MAX_CARD_BODY_HEIGHT = 228; + private static final double GRID_FALLBACK_WIDTH = 936; + private static final double GRID_THREE_COLUMN_WIDTH = 1320; + private static final double GRID_HORIZONTAL_GAP = 27; + private static final double GRID_VERTICAL_GAP = 30; + private static final double COMPACT_FALLBACK_WIDTH = 936; + private static final double COMPACT_CARD_HEIGHT = 90; + private static final double COMPACT_THREE_COLUMN_WIDTH = 1120; + private static final double COMPACT_HORIZONTAL_GAP = 19.5; + private static final double COMPACT_VERTICAL_GAP = 19.5; + + private final StackPane projectResults; + private final StackPane viewDeck; + private final Supplier contentBody; + private final ProjectCardFactory projectCardFactory; + private final Function favoriteResolver; + private final Supplier gameVersion; + private final Consumer onInstall; + private final Consumer onOpenPage; + private final Consumer onOpenCreator; + private final Consumer onToggleFavorite; + private final Map projectCardCache = new LinkedHashMap<>(128, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > MAX_CACHED_PROJECT_CARDS; + } + }; + + private LayoutMetrics lastRenderedLayout = LayoutMetrics.unset(); + + public ProjectBrowserRenderer( + StackPane projectResults, + StackPane viewDeck, + Supplier contentBody, + ProjectCardFactory projectCardFactory, + Function favoriteResolver, + Supplier gameVersion, + Consumer onInstall, + Consumer onOpenPage, + Consumer onOpenCreator, + Consumer onToggleFavorite + ) { + this.projectResults = projectResults; + this.viewDeck = viewDeck; + this.contentBody = contentBody; + this.projectCardFactory = projectCardFactory; + this.favoriteResolver = favoriteResolver; + this.gameVersion = gameVersion; + this.onInstall = onInstall; + this.onOpenPage = onOpenPage; + this.onOpenCreator = onOpenCreator; + this.onToggleFavorite = onToggleFavorite; + } + + public void render(List projects, ProjectCardViewStyle cardViewStyle) { + render(projects, cardViewStyle, pageSizeForView(cardViewStyle)); + } + + public void render(List projects, ProjectCardViewStyle cardViewStyle, int pageSize) { + long operationStart = LauncherPerformanceProbe.operationStartNanos(); + try { + if (projects.isEmpty()) { + lastRenderedLayout = LayoutMetrics.unset(); + projectResults.getChildren().setAll(emptyState("No matches found", "Try another search term or category.")); + return; + } + LayoutMetrics layout = layoutMetricsFor(cardViewStyle, pageSize); + Node container = resultsContainer(cardViewStyle, layout); + int cardCount = Math.min(projects.size(), layout.pageSize()); + List cards = new ArrayList<>(cardCount); + String selectedGameVersion = gameVersion.get(); + for (int i = 0; i < cardCount; i++) { + cards.add(projectCard(projects.get(i), cardViewStyle, selectedGameVersion, + layout.cardWidth(), layout.cardHeight())); + } + if (container instanceof GridPane gridPane) { + gridPane.getChildren().clear(); + for (int i = 0; i < cards.size(); i++) { + gridPane.add(cards.get(i), i % layout.columns(), i / layout.columns()); + } + } else if (container instanceof VBox vBox) { + vBox.getChildren().setAll(cards); + } + projectResults.getChildren().setAll(container); + lastRenderedLayout = layout; + } finally { + LauncherPerformanceProbe.recordOperation("browse.render", operationStart); + } + } + + public boolean shouldRenderForLayout(ProjectCardViewStyle cardViewStyle) { + return shouldRenderForLayout(cardViewStyle, pageSizeForView(cardViewStyle)); + } + + public boolean shouldRenderForLayout(ProjectCardViewStyle cardViewStyle, int pageSize) { + if (cardViewStyle == ProjectCardViewStyle.LIST) { + return false; + } + LayoutMetrics nextLayout = layoutMetricsFor(cardViewStyle, pageSize); + return !nextLayout.sameGeometry(lastRenderedLayout); + } + + public int pageSizeForView(ProjectCardViewStyle cardViewStyle) { + return layoutMetricsFor(cardViewStyle).pageSize(); + } + + int columnsForView(ProjectCardViewStyle cardViewStyle) { + return layoutMetricsFor(cardViewStyle).columns(); + } + + int pageSizeForView(ProjectCardViewStyle cardViewStyle, int requestedPageSize) { + return layoutMetricsFor(cardViewStyle, requestedPageSize).pageSize(); + } + + private Node projectCard( + ProjectSummary project, + ProjectCardViewStyle cardViewStyle, + String selectedGameVersion, + double cardWidth, + double cardHeight + ) { + boolean favorite = Boolean.TRUE.equals(favoriteResolver.apply(project.id())); + ProjectCardKey key = new ProjectCardKey(project.routeKey(), cardViewStyle, selectedGameVersion, favorite, + cardWidthKey(cardViewStyle, cardWidth), cardHeightKey(cardViewStyle, cardHeight), projectSignature(project)); + Node card = projectCardCache.get(key); + if (card == null) { + card = projectCardFactory.create(project, cardViewStyle, selectedGameVersion, favorite, + onInstall, onOpenPage, onOpenCreator, onToggleFavorite, cardWidth, cardHeight); + projectCardCache.put(key, card); + } + detachFromParent(card); + return card; + } + + private Node resultsContainer(ProjectCardViewStyle cardViewStyle, LayoutMetrics layout) { + if (cardViewStyle == ProjectCardViewStyle.LIST) { + VBox list = new VBox(21); + list.getStyleClass().add("project-list"); + list.setFillWidth(true); + return list; + } + + GridPane grid = new GridPane(); + grid.getStyleClass().add(cardViewStyle == ProjectCardViewStyle.COMPACT ? "compact-grid" : "project-grid"); + grid.setAlignment(javafx.geometry.Pos.TOP_LEFT); + grid.setHgap(layout.horizontalGap()); + grid.setVgap(layout.verticalGap()); + grid.setMinWidth(0); + grid.setMaxWidth(Double.MAX_VALUE); + grid.setPrefWidth(layout.availableWidth()); + return grid; + } + + private void detachFromParent(Node node) { + if (node.getParent() instanceof Pane pane) { + pane.getChildren().remove(node); + } + } + + private int cardWidthKey(ProjectCardViewStyle cardViewStyle, double cardWidth) { + return cardViewStyle == ProjectCardViewStyle.LIST ? 0 : (int) Math.round(cardWidth); + } + + private int cardHeightKey(ProjectCardViewStyle cardViewStyle, double cardHeight) { + return cardViewStyle == ProjectCardViewStyle.LIST ? 0 : (int) Math.round(cardHeight); + } + + private int projectSignature(ProjectSummary project) { + return Objects.hash(project.slug(), project.title(), project.description(), project.authorId(), project.author(), + project.imageUrl(), project.bannerUrl(), project.classification(), project.downloadCount(), + project.favoriteCount(), project.updatedAt(), project.versions()); + } + + private LayoutMetrics layoutMetricsFor(ProjectCardViewStyle cardViewStyle) { + return layoutMetricsFor(cardViewStyle, defaultPageSizeForView(cardViewStyle)); + } + + private LayoutMetrics layoutMetricsFor(ProjectCardViewStyle cardViewStyle, int requestedPageSize) { + int pageSize = sanitizePageSize(requestedPageSize); + return switch (cardViewStyle) { + case LIST -> LayoutMetrics.list(pageSize); + case COMPACT -> compactLayoutMetrics(pageSize); + case GRID -> gridLayoutMetrics(pageSize); + }; + } + + private int defaultPageSizeForView(ProjectCardViewStyle cardViewStyle) { + return switch (cardViewStyle) { + case LIST -> LIST_PAGE_SIZE; + case COMPACT -> COMPACT_PAGE_SIZE; + case GRID -> CARD_PAGE_SIZE; + }; + } + + private int sanitizePageSize(int requestedPageSize) { + return Math.max(1, Math.min(100, requestedPageSize)); + } + + private LayoutMetrics gridLayoutMetrics(int pageSize) { + double availableWidth = measuredOrFallback(browseResultsWidth(), GRID_FALLBACK_WIDTH); + int columns = breakpointColumns(availableWidth, GRID_THREE_COLUMN_WIDTH); + double cardWidth = cellSize(availableWidth, columns, GRID_HORIZONTAL_GAP); + double bannerHeight = Math.round(cardWidth / 3.0); + double bodyHeight = Math.round(clamp(cardWidth * 0.52, GRID_MIN_CARD_BODY_HEIGHT, GRID_MAX_CARD_BODY_HEIGHT)); + double cardHeight = bannerHeight + bodyHeight; + return new LayoutMetrics( + columns, + rowsForPageSize(columns, pageSize), + pageSize, + cardWidth, + cardHeight, + availableWidth, + GRID_HORIZONTAL_GAP, + GRID_VERTICAL_GAP + ); + } + + private LayoutMetrics compactLayoutMetrics(int pageSize) { + double availableWidth = measuredOrFallback(browseResultsWidth(), COMPACT_FALLBACK_WIDTH); + int columns = breakpointColumns(availableWidth, COMPACT_THREE_COLUMN_WIDTH); + double cardWidth = cellSize(availableWidth, columns, COMPACT_HORIZONTAL_GAP); + return new LayoutMetrics( + columns, + rowsForPageSize(columns, pageSize), + pageSize, + cardWidth, + COMPACT_CARD_HEIGHT, + availableWidth, + COMPACT_HORIZONTAL_GAP, + COMPACT_VERTICAL_GAP + ); + } + + private int breakpointColumns(double availableWidth, double threeColumnWidth) { + if (availableWidth >= threeColumnWidth) { + return 3; + } + return 2; + } + + private int rowsForPageSize(int columns, int pageSize) { + return Math.max(1, (int) Math.ceil((double) pageSize / Math.max(1, columns))); + } + + private double cellSize(double available, int count, double gap) { + return Math.max(1, (available - gap * (count - 1)) / count); + } + + private double measuredOrFallback(double measured, double fallback) { + return Double.isFinite(measured) && measured > 0 ? measured : fallback; + } + + private double clamp(double value, double min, double max) { + return Math.max(min, Math.min(max, value)); + } + + private double browseResultsWidth() { + double viewportWidth = enclosingScrollViewportWidth(); + if (viewportWidth > 0) { + return viewportWidth; + } + double parentWidth = nodeWidth(projectResults.getParent()); + if (parentWidth > 0) { + return parentWidth; + } + double deckWidth = nodeWidth(viewDeck); + if (deckWidth > 0) { + return deckWidth; + } + VBox body = contentBody.get(); + double bodyWidth = nodeWidth(body); + if (bodyWidth > 0) { + return bodyWidth; + } + return nodeWidth(projectResults); + } + + private double enclosingScrollViewportWidth() { + double width = scrollViewportWidth(projectResults); + if (width > 0) { + return width; + } + return scrollViewportWidth(contentBody.get()); + } + + private double scrollViewportWidth(Node start) { + for (Node current = start; current != null; current = current.getParent()) { + if (current instanceof ScrollPane scrollPane) { + Bounds viewport = scrollPane.getViewportBounds(); + return viewport == null ? 0 : usableWidth(viewport.getWidth()); + } + } + return 0; + } + + private double nodeWidth(Node node) { + if (node == null) { + return 0; + } + if (node instanceof Region region) { + double regionWidth = usableWidth(region.getWidth()); + if (regionWidth > 0) { + return regionWidth; + } + } + return usableWidth(node.getLayoutBounds().getWidth()); + } + + private double usableWidth(double width) { + return Double.isFinite(width) && width > 0 ? width : 0; + } + + private record ProjectCardKey( + String projectId, + ProjectCardViewStyle viewStyle, + String gameVersion, + boolean favorite, + int cardWidth, + int cardHeight, + int projectSignature + ) { + } + + private record LayoutMetrics( + int columns, + int rows, + int pageSize, + double cardWidth, + double cardHeight, + double availableWidth, + double horizontalGap, + double verticalGap + ) { + + static LayoutMetrics list(int pageSize) { + return new LayoutMetrics(1, pageSize, pageSize, 0, 0, 0, 0, 0); + } + + static LayoutMetrics unset() { + return new LayoutMetrics(0, 0, 0, -1, -1, -1, 0, 0); + } + + boolean sameGeometry(LayoutMetrics other) { + return other != null + && columns == other.columns + && rows == other.rows + && pageSize == other.pageSize + && Math.abs(cardWidth - other.cardWidth) <= 1 + && Math.abs(cardHeight - other.cardHeight) <= 1 + && Math.abs(availableWidth - other.availableWidth) <= 1; + } + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/browse/search/ProjectBrowseSearchState.java b/launcher/src/main/java/net/modtale/launcher/ui/browse/search/ProjectBrowseSearchState.java new file mode 100644 index 00000000..886d62e5 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/browse/search/ProjectBrowseSearchState.java @@ -0,0 +1,62 @@ +package net.modtale.launcher.ui.browse.search; + +import java.util.List; +import java.util.Objects; +import net.modtale.launcher.api.ProjectSearchQuery; +import net.modtale.launcher.model.project.ProjectSummary; + +public final class ProjectBrowseSearchState { + + public static final long DUPLICATE_SEARCH = -1; + + private long sequence; + private ProjectSearchQuery inFlightSearch; + private ProjectSearchQuery lastCompletedSearch; + + public long start(ProjectSearchQuery query) { + if (query.equals(inFlightSearch)) { + return DUPLICATE_SEARCH; + } + inFlightSearch = query; + return ++sequence; + } + + public boolean acceptCompletion(ProjectSearchQuery query, long requestId) { + if (query.equals(inFlightSearch)) { + inFlightSearch = null; + } + return requestId == sequence; + } + + public void recordCompleted(ProjectSearchQuery query) { + lastCompletedSearch = query; + } + + public boolean shouldSearchForLayout(ProjectSearchQuery nextQuery, List currentProjects) { + if (lastCompletedSearch == null) { + return true; + } + if (!sameSearchWithoutSize(nextQuery, lastCompletedSearch)) { + return true; + } + if (nextQuery.size() < lastCompletedSearch.size()) { + return true; + } + return nextQuery.size() > lastCompletedSearch.size() + && currentProjects.size() >= lastCompletedSearch.size(); + } + + private static boolean sameSearchWithoutSize(ProjectSearchQuery left, ProjectSearchQuery right) { + return Objects.equals(left.search(), right.search()) + && Objects.equals(left.classification(), right.classification()) + && Objects.equals(left.gameVersion(), right.gameVersion()) + && Objects.equals(left.sort(), right.sort()) + && left.page() == right.page() + && Objects.equals(left.tags(), right.tags()) + && Objects.equals(left.minDownloads(), right.minDownloads()) + && Objects.equals(left.minFavorites(), right.minFavorites()) + && Objects.equals(left.category(), right.category()) + && Objects.equals(left.dateRange(), right.dateRange()) + && Objects.equals(left.openSource(), right.openSource()); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/common/CachedImageLoader.java b/launcher/src/main/java/net/modtale/launcher/ui/common/CachedImageLoader.java new file mode 100644 index 00000000..2e3de011 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/common/CachedImageLoader.java @@ -0,0 +1,199 @@ +package net.modtale.launcher.ui.common; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.util.Collections; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.Executor; +import java.util.function.Function; +import javafx.application.Platform; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import net.modtale.launcher.cache.LauncherCachePaths; + +public final class CachedImageLoader { + + private static final String IMAGE_KEY_PROPERTY = CachedImageLoader.class.getName() + ".imageKey"; + private static final int MAX_MEMORY_IMAGES = 384; + + private final Function assetResolver; + private final Executor executor; + private final HttpClient httpClient; + private final Path cacheDirectory; + private final ConcurrentMap> downloads = new ConcurrentHashMap<>(); + private final Map memoryImages = Collections.synchronizedMap(new LinkedHashMap<>(64, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > MAX_MEMORY_IMAGES; + } + }); + + public CachedImageLoader(Function assetResolver, Executor executor) { + this(assetResolver, executor, LauncherCachePaths.cacheDirectory("images")); + } + + public CachedImageLoader(Function assetResolver, Executor executor, Path cacheDirectory) { + this.assetResolver = assetResolver; + this.executor = executor; + this.cacheDirectory = cacheDirectory; + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(12)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + } + + public void loadInto(ImageView view, String rawUrl, double requestedWidth, double requestedHeight) { + loadInto(view, rawUrl, requestedWidth, requestedHeight, false); + } + + public void loadInto(ImageView view, String rawUrl, double requestedWidth, double requestedHeight, boolean preserveRatio) { + String resolvedUrl = assetResolver.apply(rawUrl); + ImageKey key = new ImageKey(resolvedUrl, requestedWidth, requestedHeight, preserveRatio); + view.getProperties().put(IMAGE_KEY_PROPERTY, key); + + Image memoryImage = memoryImages.get(key); + if (memoryImage != null) { + view.setImage(memoryImage); + return; + } + + if (!isHttpUrl(resolvedUrl)) { + setImage(view, key, imageFor(key, resolvedUrl)); + return; + } + + Path cachedFile = cacheFile(resolvedUrl); + if (Files.isRegularFile(cachedFile)) { + setImage(view, key, imageFor(key, cachedFile.toUri().toString())); + return; + } + + downloads.computeIfAbsent(resolvedUrl, this::downloadAsync) + .whenComplete((path, error) -> { + if (error != null || path == null) { + return; + } + Platform.runLater(() -> { + if (Objects.equals(view.getProperties().get(IMAGE_KEY_PROPERTY), key)) { + setImage(view, key, imageFor(key, path.toUri().toString())); + } + }); + }); + } + + public void clearMemory() { + memoryImages.clear(); + downloads.clear(); + } + + public void clear(ImageView view) { + view.getProperties().remove(IMAGE_KEY_PROPERTY); + view.setImage(null); + } + + private CompletableFuture downloadAsync(String resolvedUrl) { + return CompletableFuture.supplyAsync(() -> download(resolvedUrl), executor) + .whenComplete((path, error) -> downloads.remove(resolvedUrl)); + } + + private Path download(String resolvedUrl) { + URI uri = URI.create(resolvedUrl); + HttpRequest request = HttpRequest.newBuilder(uri) + .timeout(Duration.ofSeconds(45)) + .header("Accept", "image/png,image/jpeg,image/gif,image/bmp,image/*;q=0.8,*/*;q=0.5") + .header("User-Agent", "ModtaleLauncher/0.1") + .GET() + .build(); + try { + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IOException("Image request returned HTTP " + response.statusCode()); + } + + Files.createDirectories(cacheDirectory); + Path destination = cacheFile(resolvedUrl); + Path temporary = Files.createTempFile(cacheDirectory, "image-", ".tmp"); + try (InputStream body = response.body()) { + Files.copy(body, temporary, StandardCopyOption.REPLACE_EXISTING); + } + try { + Files.move(temporary, destination, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException ex) { + Files.move(temporary, destination, StandardCopyOption.REPLACE_EXISTING); + } + return destination; + } catch (IOException ex) { + throw new IllegalStateException("Could not cache image " + resolvedUrl, ex); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Image download was interrupted.", ex); + } + } + + private void setImage(ImageView view, ImageKey key, Image image) { + if (!Objects.equals(view.getProperties().get(IMAGE_KEY_PROPERTY), key)) { + return; + } + view.setImage(image); + } + + private Image imageFor(ImageKey key, String imageUrl) { + Image cached = memoryImages.get(key); + if (cached != null) { + return cached; + } + Image image = new Image(imageUrl, key.requestedWidth(), key.requestedHeight(), key.preserveRatio(), true, true); + memoryImages.put(key, image); + return image; + } + + private Path cacheFile(String resolvedUrl) { + return cacheDirectory.resolve(sha256(resolvedUrl) + imageExtension(resolvedUrl)); + } + + private static boolean isHttpUrl(String url) { + return url != null && (url.startsWith("http://") || url.startsWith("https://")); + } + + private static String sha256(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] bytes = digest.digest(value.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(bytes); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException("SHA-256 is not available.", ex); + } + } + + private static String imageExtension(String resolvedUrl) { + String path = URI.create(resolvedUrl).getPath(); + String normalized = path == null ? "" : path.toLowerCase(java.util.Locale.ROOT); + for (String extension : java.util.List.of(".png", ".jpg", ".jpeg", ".gif", ".bmp")) { + if (normalized.endsWith(extension)) { + return extension; + } + } + return ".img"; + } + + private record ImageKey(String url, double requestedWidth, double requestedHeight, boolean preserveRatio) { + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/common/GameVersionDropdown.java b/launcher/src/main/java/net/modtale/launcher/ui/common/GameVersionDropdown.java new file mode 100644 index 00000000..a6862f0f --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/common/GameVersionDropdown.java @@ -0,0 +1,335 @@ +package net.modtale.launcher.ui.common; + +import static net.modtale.launcher.ui.common.LauncherUi.pseudo; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Consumer; +import javafx.css.PseudoClass; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.ContentDisplay; +import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.VBox; + +public final class GameVersionDropdown extends VBox { + + private static final PseudoClass PARTIAL = PseudoClass.getPseudoClass("indeterminate"); + + private final Button toggle = new Button(); + private final Label toggleLabel = new Label("Any"); + private final Node toggleChevron = LauncherIcons.icon(LauncherIcons.Glyph.CHEVRON_DOWN, 14); + private final VBox panel = new VBox(0); + private final VBox options = new VBox(0); + private final ScrollPane scroll = new ScrollPane(options); + private final Set expandedGroups = new LinkedHashSet<>(); + + private List versions = List.of(); + private List selectedVersions = List.of(); + private String emptyText = "No versions found"; + private String anyLabel = "Any"; + private boolean allowEmptySelection = true; + private double maxListHeight = 224; + private Consumer> selectionListener = ignored -> { + }; + private Consumer openListener = ignored -> { + }; + + public GameVersionDropdown() { + getStyleClass().add("game-version-dropdown"); + setSpacing(6); + setMaxWidth(Double.MAX_VALUE); + + toggle.getStyleClass().add("game-version-dropdown-toggle"); + toggle.setMaxWidth(Double.MAX_VALUE); + toggle.setContentDisplay(ContentDisplay.GRAPHIC_ONLY); + HBox toggleContent = toggleContent(); + toggleContent.prefWidthProperty().bind(toggle.widthProperty().subtract(22)); + toggle.setGraphic(toggleContent); + toggle.setOnAction(event -> setOpen(!isOpen())); + + scroll.getStyleClass().add("game-version-dropdown-scroll"); + scroll.setFitToWidth(true); + scroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + scroll.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED); + scroll.setMaxHeight(maxListHeight); + + panel.getStyleClass().add("game-version-dropdown-panel"); + panel.getChildren().add(scroll); + panel.setVisible(false); + panel.setManaged(false); + + getChildren().setAll(toggle, panel); + refresh(); + } + + public static GameVersionDropdown multiSelect() { + return new GameVersionDropdown(); + } + + public void setVersions(List versions) { + List safeVersions = versions == null ? List.of() : versions.stream() + .filter(version -> version != null && !version.isBlank()) + .distinct() + .toList(); + this.versions = List.copyOf(safeVersions); + this.selectedVersions = GameVersionGroups.orderedSelection(this.selectedVersions, this.versions); + refresh(); + } + + public List versions() { + return versions; + } + + public void setSelectedVersions(List selectedVersions) { + this.selectedVersions = selectedVersions(selectedVersions); + refresh(); + } + + public List selectedVersions() { + return selectedVersions; + } + + public String selectedQuery() { + return GameVersionGroups.selectionQuery(selectedVersions, versions); + } + + public void setAnyLabel(String anyLabel) { + this.anyLabel = anyLabel == null || anyLabel.isBlank() ? "Any" : anyLabel; + refresh(); + } + + public void setAllowEmptySelection(boolean allowEmptySelection) { + this.allowEmptySelection = allowEmptySelection; + this.selectedVersions = selectedVersions(this.selectedVersions); + refresh(); + } + + public void setEmptyText(String emptyText) { + this.emptyText = emptyText == null || emptyText.isBlank() ? "No versions found" : emptyText; + refresh(); + } + + public void setMaxListHeight(double maxListHeight) { + this.maxListHeight = Math.max(120, maxListHeight); + scroll.setMaxHeight(this.maxListHeight); + } + + public boolean isOpen() { + return panel.isVisible(); + } + + public void setOpen(boolean open) { + if (panel.isVisible() == open) { + return; + } + panel.setVisible(open); + panel.setManaged(open); + pseudo(toggle, "open", open); + toggleChevron.setRotate(open ? 180 : 0); + openListener.accept(open); + } + + public void setOnSelectionChange(Consumer> selectionListener) { + this.selectionListener = selectionListener == null ? ignored -> { + } : selectionListener; + } + + public void setOnOpenChange(Consumer openListener) { + this.openListener = openListener == null ? ignored -> { + } : openListener; + } + + private HBox toggleContent() { + HBox content = new HBox(8); + content.getStyleClass().add("game-version-dropdown-toggle-content"); + content.setAlignment(Pos.CENTER_LEFT); + toggleLabel.getStyleClass().add("game-version-dropdown-toggle-label"); + toggleLabel.setMaxWidth(Double.MAX_VALUE); + HBox.setHgrow(toggleLabel, Priority.ALWAYS); + toggleChevron.getStyleClass().add("game-version-dropdown-chevron"); + content.getChildren().addAll(toggleLabel, toggleChevron); + return content; + } + + private void refresh() { + String fallbackLabel = allowEmptySelection ? anyLabel : requiredSelectionFallbackLabel(); + toggleLabel.setText(GameVersionGroups.displayLabel(selectedVersions, versions, fallbackLabel)); + options.getChildren().setAll(optionNodes()); + } + + private String requiredSelectionFallbackLabel() { + return versions.isEmpty() ? emptyText : versions.getFirst(); + } + + private List optionNodes() { + List nodes = new ArrayList<>(); + if (allowEmptySelection) { + nodes.add(optionRow(anyLabel, selectedVersions.isEmpty(), () -> commitSelection(List.of()))); + } + List groups = GameVersionGroups.build(versions); + if (groups.isEmpty()) { + Label empty = new Label(emptyText); + empty.getStyleClass().add("game-version-dropdown-empty"); + nodes.add(empty); + return nodes; + } + for (GameVersionGroups.Group group : groups) { + if (!group.grouped()) { + String version = group.versions().isEmpty() ? "" : group.versions().getFirst(); + if (!version.isBlank()) { + nodes.add(optionRow(version, selectedVersions.contains(version), () -> toggleVersion(version))); + } + continue; + } + nodes.add(groupRow(group)); + if (expandedGroups.contains(group.label())) { + for (String version : group.versions()) { + nodes.add(childRow(version, selectedVersions.contains(version), () -> toggleVersion(version))); + } + } + } + return nodes; + } + + private Button optionRow(String label, boolean selected, Runnable action) { + Button row = new Button(); + row.getStyleClass().add("game-version-dropdown-row"); + row.setMaxWidth(Double.MAX_VALUE); + HBox content = rowContent(label, selected, null); + content.prefWidthProperty().bind(row.widthProperty().subtract(20)); + row.setGraphic(content); + row.setContentDisplay(ContentDisplay.GRAPHIC_ONLY); + pseudo(row, "selected", selected); + row.setOnAction(event -> action.run()); + return row; + } + + private Button childRow(String label, boolean selected, Runnable action) { + Button row = optionRow(label, selected, action); + row.getStyleClass().add("game-version-dropdown-child-row"); + return row; + } + + private HBox groupRow(GameVersionGroups.Group group) { + HBox row = new HBox(0); + row.getStyleClass().add("game-version-dropdown-group-row"); + row.setAlignment(Pos.CENTER_LEFT); + row.setMaxWidth(Double.MAX_VALUE); + + int selectedCount = (int) group.versions().stream().filter(selectedVersions::contains).count(); + boolean selected = selectedCount == group.versions().size(); + boolean partial = selectedCount > 0 && !selected; + pseudo(row, "selected", selected); + row.pseudoClassStateChanged(PARTIAL, partial); + + Button select = new Button(); + select.getStyleClass().add("game-version-dropdown-group-select"); + select.setContentDisplay(ContentDisplay.GRAPHIC_ONLY); + HBox selectContent = rowContent(group.label(), false, partial ? selectedCount + "/" + group.versions().size() : null); + selectContent.prefWidthProperty().bind(select.widthProperty().subtract(10)); + select.setGraphic(selectContent); + select.setMaxWidth(Double.MAX_VALUE); + select.setOnAction(event -> toggleGroupSelection(group.versions())); + HBox.setHgrow(select, Priority.ALWAYS); + + Button expand = new Button(null, LauncherIcons.icon(LauncherIcons.Glyph.CHEVRON_RIGHT, 13)); + expand.getStyleClass().add("game-version-dropdown-expand"); + expand.setContentDisplay(ContentDisplay.GRAPHIC_ONLY); + expand.setOnAction(event -> toggleGroupExpanded(group.label())); + expand.getGraphic().setRotate(expandedGroups.contains(group.label()) ? 90 : 0); + + row.getChildren().addAll(select, expand); + if (selected) { + Node check = LauncherIcons.icon(LauncherIcons.Glyph.CHECK, 14); + check.getStyleClass().add("game-version-dropdown-check"); + row.getChildren().add(check); + } + return row; + } + + private HBox rowContent(String label, boolean selected, String count) { + HBox content = new HBox(8); + content.getStyleClass().add("game-version-dropdown-row-content"); + content.setAlignment(Pos.CENTER_LEFT); + Label text = new Label(label); + text.getStyleClass().add("game-version-dropdown-row-label"); + text.setMaxWidth(Double.MAX_VALUE); + HBox.setHgrow(text, Priority.ALWAYS); + content.getChildren().add(text); + if (count != null && !count.isBlank()) { + Label countLabel = new Label(count); + countLabel.getStyleClass().add("game-version-dropdown-count"); + content.getChildren().add(countLabel); + } + Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + content.getChildren().add(spacer); + if (selected) { + Node check = LauncherIcons.icon(LauncherIcons.Glyph.CHECK, 14); + check.getStyleClass().add("game-version-dropdown-check"); + content.getChildren().add(check); + } + return content; + } + + private void toggleVersion(String version) { + List next = new ArrayList<>(selectedVersions); + if (next.contains(version)) { + next.remove(version); + } else { + next.add(version); + } + commitSelection(next); + } + + private void toggleGroupSelection(List groupVersions) { + boolean hasEntireGroup = groupVersions.stream().allMatch(selectedVersions::contains); + List next = new ArrayList<>(selectedVersions); + if (hasEntireGroup) { + next.removeIf(groupVersions::contains); + } else { + for (String version : groupVersions) { + if (!next.contains(version)) { + next.add(version); + } + } + } + commitSelection(next); + } + + private void toggleGroupExpanded(String label) { + if (expandedGroups.contains(label)) { + expandedGroups.remove(label); + } else { + expandedGroups.add(label); + } + refresh(); + } + + private void commitSelection(List nextSelection) { + selectedVersions = selectedVersions(nextSelection); + refresh(); + selectionListener.accept(selectedVersions); + } + + private List selectedVersions(List selectedVersions) { + if (selectedVersions == null || selectedVersions.isEmpty()) { + return allowEmptySelection || versions.isEmpty() ? List.of() : List.of(versions.getFirst()); + } + Set selected = new LinkedHashSet<>(selectedVersions); + List ordered = versions.stream() + .filter(selected::contains) + .toList(); + return ordered.isEmpty() && !allowEmptySelection && !versions.isEmpty() + ? List.of(versions.getFirst()) + : ordered; + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/common/GameVersionGroups.java b/launcher/src/main/java/net/modtale/launcher/ui/common/GameVersionGroups.java new file mode 100644 index 00000000..dc95b43f --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/common/GameVersionGroups.java @@ -0,0 +1,106 @@ +package net.modtale.launcher.ui.common; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public final class GameVersionGroups { + + private GameVersionGroups() { + } + + public static List parseSelection(String value) { + if (value == null || value.isBlank() || "Any".equalsIgnoreCase(value.trim())) { + return List.of(); + } + return Arrays.stream(value.split(",")) + .map(String::trim) + .filter(version -> !version.isBlank() && !"Any".equalsIgnoreCase(version)) + .distinct() + .toList(); + } + + public static String selectionQuery(List selectedVersions, List orderedVersions) { + List ordered = orderedSelection(selectedVersions, orderedVersions); + return ordered.isEmpty() ? null : String.join(",", ordered); + } + + public static List orderedSelection(List selectedVersions, List orderedVersions) { + if (selectedVersions == null || selectedVersions.isEmpty()) { + return List.of(); + } + Set selected = new LinkedHashSet<>(selectedVersions); + List ordered = new ArrayList<>(); + if (orderedVersions != null) { + for (String version : orderedVersions) { + if (selected.remove(version)) { + ordered.add(version); + } + } + } + ordered.addAll(selected); + return List.copyOf(ordered); + } + + public static String rangeLabel(String version) { + if (version == null || version.isBlank()) { + return null; + } + String base = version.split("-", 2)[0]; + String[] parts = base.split("\\."); + if (parts.length < 2 || !isDigits(parts[0]) || !isDigits(parts[1])) { + return null; + } + return parts[0] + "." + parts[1] + ".x"; + } + + public static List build(List versions) { + if (versions == null || versions.isEmpty()) { + return List.of(); + } + Map> groups = new LinkedHashMap<>(); + for (String version : versions) { + if (version == null || version.isBlank()) { + continue; + } + String label = rangeLabel(version); + String key = label == null ? version : label; + groups.computeIfAbsent(key, ignored -> new ArrayList<>()).add(version); + } + return groups.entrySet().stream() + .map(entry -> new Group(entry.getKey(), List.copyOf(entry.getValue()), entry.getValue().size() > 1)) + .toList(); + } + + public static String displayLabel(List selectedVersions, List orderedVersions, String anyLabel) { + List selected = orderedSelection(selectedVersions, orderedVersions); + if (selected.isEmpty()) { + return anyLabel == null || anyLabel.isBlank() ? "Any" : anyLabel; + } + for (Group group : build(orderedVersions)) { + if (group.grouped() + && selected.size() == group.versions().size() + && selected.containsAll(group.versions())) { + return group.label(); + } + } + if (selected.size() == 1) { + return selected.getFirst(); + } + return selected.size() + " versions"; + } + + private static boolean isDigits(String value) { + return value != null && !value.isBlank() && value.chars().allMatch(Character::isDigit); + } + + public record Group(String label, List versions, boolean grouped) { + public Group { + versions = versions == null ? List.of() : List.copyOf(versions); + } + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/common/LauncherAssetResolver.java b/launcher/src/main/java/net/modtale/launcher/ui/common/LauncherAssetResolver.java new file mode 100644 index 00000000..68e0d6ae --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/common/LauncherAssetResolver.java @@ -0,0 +1,52 @@ +package net.modtale.launcher.ui.common; + +import java.util.Objects; +import java.util.function.Supplier; +import net.modtale.launcher.api.ModtaleApiClient; +import net.modtale.launcher.settings.LauncherConfig; + +public final class LauncherAssetResolver { + + private final ModtaleApiClient apiClient; + private final Supplier fallbackAssetUrl; + + public LauncherAssetResolver(ModtaleApiClient apiClient, Supplier fallbackAssetUrl) { + this.apiClient = apiClient; + this.fallbackAssetUrl = fallbackAssetUrl; + } + + public String resolve(String rawUrl) { + if (rawUrl == null || rawUrl.isBlank()) { + return Objects.requireNonNull(fallbackAssetUrl.get()); + } + if (rawUrl.startsWith("http://") || rawUrl.startsWith("https://")) { + return rawUrl; + } + if (rawUrl.startsWith("/api")) { + return apiRoot() + rawUrl; + } + if (rawUrl.startsWith("/")) { + return LauncherConfig.siteBaseUrl().replaceAll("/+$", "") + rawUrl; + } + return rawUrl; + } + + public String resolveBackendAsset(String rawUrl) { + if (rawUrl == null || rawUrl.isBlank()) { + return Objects.requireNonNull(fallbackAssetUrl.get()); + } + if (rawUrl.startsWith("http://") || rawUrl.startsWith("https://")) { + return rawUrl; + } + if (rawUrl.startsWith("/")) { + return apiRoot() + rawUrl; + } + return rawUrl; + } + + private String apiRoot() { + var uri = apiClient.apiBaseUri(); + int port = uri.getPort(); + return uri.getScheme() + "://" + uri.getHost() + (port < 0 ? "" : ":" + port); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/common/LauncherExternalLinks.java b/launcher/src/main/java/net/modtale/launcher/ui/common/LauncherExternalLinks.java new file mode 100644 index 00000000..a14d7c04 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/common/LauncherExternalLinks.java @@ -0,0 +1,47 @@ +package net.modtale.launcher.ui.common; + +import java.awt.Desktop; +import java.net.URI; +import java.util.function.BiConsumer; +import net.modtale.launcher.logging.LogSanitizer; +import net.modtale.launcher.settings.LauncherConfig; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +public final class LauncherExternalLinks { + + private static final Logger LOG = LogManager.getLogger(LauncherExternalLinks.class); + + private LauncherExternalLinks() { + } + + public static void open(String rawLink, BiConsumer toast) { + URI uri = resolve(rawLink); + if (!Desktop.isDesktopSupported() || !Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { + showError(toast, "Desktop browser integration is not available."); + return; + } + try { + Desktop.getDesktop().browse(uri); + } catch (Exception ex) { + LOG.warn("Could not open {}", LogSanitizer.uri(uri), ex); + showError(toast, "Could not open " + uri + "."); + } + } + + public static URI resolve(String rawLink) { + String link = rawLink == null || rawLink.isBlank() ? "/" : rawLink.trim(); + if (link.startsWith("http://") || link.startsWith("https://")) { + return URI.create(link); + } + String base = LauncherConfig.siteBaseUrl().replaceAll("/+$", ""); + String path = link.startsWith("/") ? link : "/" + link; + return URI.create(base + path); + } + + private static void showError(BiConsumer toast, String message) { + if (toast != null) { + toast.accept("Could not open link", message); + } + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/common/LauncherFonts.java b/launcher/src/main/java/net/modtale/launcher/ui/common/LauncherFonts.java new file mode 100644 index 00000000..7913c327 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/common/LauncherFonts.java @@ -0,0 +1,30 @@ +package net.modtale.launcher.ui.common; + +import java.net.URL; +import java.util.concurrent.atomic.AtomicBoolean; +import javafx.scene.text.Font; + +public final class LauncherFonts { + + private static final AtomicBoolean LOADED = new AtomicBoolean(); + private static final String[] INTER_FONTS = { + "/net/modtale/launcher/ui/nativefx/fonts/Inter-Regular.ttf", + "/net/modtale/launcher/ui/nativefx/fonts/Inter-Bold.ttf", + "/net/modtale/launcher/ui/nativefx/fonts/Inter-Black.ttf" + }; + + private LauncherFonts() { + } + + public static void load() { + if (!LOADED.compareAndSet(false, true)) { + return; + } + for (String fontPath : INTER_FONTS) { + URL font = LauncherFonts.class.getResource(fontPath); + if (font != null) { + Font.loadFont(font.toExternalForm(), 16); + } + } + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/common/LauncherIcons.java b/launcher/src/main/java/net/modtale/launcher/ui/common/LauncherIcons.java new file mode 100644 index 00000000..e0ae4efc --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/common/LauncherIcons.java @@ -0,0 +1,165 @@ +package net.modtale.launcher.ui.common; + +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.Group; +import javafx.scene.layout.StackPane; +import javafx.scene.paint.Color; +import javafx.scene.shape.SVGPath; +import javafx.scene.shape.StrokeLineCap; +import javafx.scene.shape.StrokeLineJoin; + +public final class LauncherIcons { + + public enum Glyph { + ALIGN_JUSTIFY("M3 6h18 M3 12h18 M3 18h18"), + ARROW_BIG_DOWN("M15 5H9v6H4.16a1 1 0 0 0-.82 1.57l8.84 9.58a1 1 0 0 0 1.48 0l8.84-9.58A1 1 0 0 0 21.84 11H17V5a2 2 0 0 0-2-2Z"), + ARROW_BIG_UP("M9 19h6v-6h4.84a1 1 0 0 0 .82-1.57l-8.84-9.58a1 1 0 0 0-1.48 0L2.34 11.43A1 1 0 0 0 3.16 13H8v6a2 2 0 0 0 2 2Z"), + BELL("M10.3 21a2 2 0 0 0 3.4 0 M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"), + BOOK_OPEN("M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2Z M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7Z"), + BOX("M21 8a2 2 0 0 0-1-1.73L12 2 4 6.27A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73L12 22l8-4.27A2 2 0 0 0 21 16Z M3.3 7 12 12l8.7-5 M12 22V12"), + CALENDAR("M8 2v4 M16 2v4 M3 10h18 M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Z"), + CHECK("M20 6 9 17l-5-5"), + ALERT_CIRCLE("M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Z M12 8v4 M12 16h.01"), + ALERT_TRIANGLE("M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z M12 9v4 M12 17h.01"), + ARROW_RIGHT("M5 12h14 M12 5l7 7-7 7"), + CHEVRON_DOWN("m6 9 6 6 6-6"), + CHEVRON_LEFT("m15 18-6-6 6-6"), + CHEVRON_RIGHT("m9 18 6-6-6-6"), + CHEVRON_UP("m18 15-6-6-6 6"), + CIRCLE("M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Z"), + CLOCK("M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Z M12 6v6l4 2"), + CODE("m16 18 6-6-6-6 M8 6l-6 6 6 6"), + COPY("M8 8h11a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H10a2 2 0 0 1-2-2Z M16 8V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h3"), + CORNER_DOWN_LEFT("m9 10-5 5 5 5 M20 4v7a4 4 0 0 1-4 4H4"), + CORNER_DOWN_RIGHT("m15 10 5 5-5 5 M4 4v7a4 4 0 0 0 4 4h12"), + DATABASE("M3 6c0 2 4 4 9 4s9-2 9-4-4-4-9-4-9 2-9 4Z M3 6v6c0 2 4 4 9 4s9-2 9-4V6 M3 12v6c0 2 4 4 9 4s9-2 9-4v-6"), + DOWNLOAD("M12 3v12 M7 10l5 5 5-5 M5 21h14"), + EDIT("M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7 M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4Z"), + EXTERNAL_LINK("M15 3h6v6 M10 14 21 3 M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"), + FILE_CODE("M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z M14 2v6h6 M10 13l-2 2 2 2 M14 17l2-2-2-2"), + FILTER("M22 3H2l8 9.46V19l4 2v-8.54Z"), + FLAG("M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z M4 22V15"), + FLAME("M8.5 14.5A4.5 4.5 0 1 0 16 11c0-4-4-6-4-9-3 2-5 5-5 9a5 5 0 0 0 1.5 3.5Z"), + GEAR("M12.2 2h-.4a2 2 0 0 0-2 2v.2a2 2 0 0 1-1 1.7l-.4.2a2 2 0 0 1-2 0l-.2-.1a2 2 0 0 0-2.7.7l-.2.4A2 2 0 0 0 4 9.8l.2.1a2 2 0 0 1 1 1.7v.6a2 2 0 0 1-1 1.7l-.2.1a2 2 0 0 0-.7 2.7l.2.4a2 2 0 0 0 2.7.7l.2-.1a2 2 0 0 1 2 0l.4.2a2 2 0 0 1 1 1.7v.4a2 2 0 0 0 2 2h.4a2 2 0 0 0 2-2v-.2a2 2 0 0 1 1-1.7l.4-.2a2 2 0 0 1 2 0l.2.1a2 2 0 0 0 2.7-.7l.2-.4a2 2 0 0 0-.7-2.7l-.2-.1a2 2 0 0 1-1-1.7v-.5a2 2 0 0 1 1-1.7l.2-.1a2 2 0 0 0 .7-2.7l-.2-.4a2 2 0 0 0-2.7-.7l-.2.1a2 2 0 0 1-2 0l-.4-.2a2 2 0 0 1-1-1.7V4a2 2 0 0 0-2-2Z M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z"), + GEM("M6 3h12l4 6-10 12L2 9Z M2 9h20 M6 3l6 18 6-18"), + GLOBE("M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Z M2 12h20 M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10Z"), + GRID("M3 3h7v7H3Z M14 3h7v7h-7Z M14 14h7v7h-7Z M3 14h7v7H3Z"), + HASH("M4 9h16 M4 15h16 M10 3 8 21 M16 3l-2 18"), + HEART("M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.6l-1-1a5.5 5.5 0 1 0-7.8 7.8l1 1L12 21l7.8-7.6 1-1a5.5 5.5 0 0 0 0-7.8Z"), + IMAGE("M21 15V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h11 M21 15l-5-5L5 21 M14 14l2-2 5 5 M8.5 8.5h.01"), + INFO("M12 16v-4 M12 8h.01 M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0Z"), + LAYERS("m12 2 9 5-9 5-9-5Z M3 12l9 5 9-5 M3 17l9 5 9-5"), + LAYOUT("M3 3h18v18H3Z M3 9h18 M9 21V9"), + LINK("M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71 M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"), + LIST("M8 6h13 M8 12h13 M8 18h13 M3 6h.01 M3 12h.01 M3 18h.01"), + LOADER_2("M21 12a9 9 0 1 1-6.219-8.56"), + LOG_OUT("M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4 M16 17l5-5-5-5 M21 12H9"), + MAXIMIZE("M8 3H5a2 2 0 0 0-2 2v3 M16 3h3a2 2 0 0 1 2 2v3 M21 16v3a2 2 0 0 1-2 2h-3 M8 21H5a2 2 0 0 1-2-2v-3"), + MESSAGE_SQUARE("M21 15a4 4 0 0 1-4 4H7l-4 4V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4Z"), + MINUS("M5 12h14"), + PALETTE("M12 22a10 10 0 1 1 10-10c0 2-1.5 3-3.5 3H17a2 2 0 0 0 0 4h.5c-1.6 1.9-3.6 3-5.5 3Z M6.5 11.5h.01 M9.5 7.5h.01 M14.5 7.5h.01 M17.5 11.5h.01"), + PACKAGE_PLUS("M16 16h6 M19 13v6 M21 8a2 2 0 0 0-1-1.73L12 2 4 6.27A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73L12 22l3-1.6 M3.3 7 12 12l8.7-5 M12 22V12"), + REFRESH_CW("M21 12a9 9 0 0 0-9-9 9.8 9.8 0 0 0-6.7 2.7L3 8 M3 3v5h5 M3 12a9 9 0 0 0 9 9 9.8 9.8 0 0 0 6.7-2.7L21 16 M16 16h5v5"), + RESTORE("M8 3h11a2 2 0 0 1 2 2v11 M3 8h11a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2Z"), + ROTATE_CCW("M3 12a9 9 0 1 0 3-6.7L3 8 M3 3v5h5"), + SAVE("M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z M17 21v-8H7v8 M7 3v5h8"), + SCALE("M16 16l3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z M2 16l3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z M7 21h10 M12 3v18 M3 7h2c2 0 5-1 7-4 2 3 5 4 7 4h2"), + SEARCH("M21 21l-4.35-4.35 M11 19a8 8 0 1 1 0-16 8 8 0 0 1 0 16Z"), + SEND("m22 2-7 20-4-9-9-4 20-7Z M22 2 11 13"), + SHARE_2("M18 2a3 3 0 1 1 0 6a3 3 0 1 1 0-6 M6 9a3 3 0 1 1 0 6a3 3 0 1 1 0-6 M18 16a3 3 0 1 1 0 6a3 3 0 1 1 0-6 M8.59 13.51l6.83 3.98 M15.41 6.51l-6.82 3.98"), + SLIDERS("M21 4h-7 M10 4H3 M21 12h-9 M8 12H3 M21 20h-5 M12 20H3 M14 2v4 M8 10v4 M16 18v4"), + STAR("M12 2l3.1 6.3 6.9 1-5 4.9 1.2 6.8L12 17.8 5.8 21 7 14.2 2 9.3l6.9-1Z"), + TAG("M20.6 13.4 13.4 20.6a2 2 0 0 1-2.8 0L3 13V3h10l7.6 7.6a2 2 0 0 1 0 2.8Z M7.5 7.5h.01"), + TRASH("M3 6h18 M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2 M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6 M10 11v6 M14 11v6"), + USER("M20 21a8 8 0 0 0-16 0 M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z"), + X("M18 6 6 18 M6 6l12 12"), + ZAP("M13 2 3 14h8l-1 8 10-12h-8Z"); + + private final String path; + + Glyph(String path) { + this.path = path; + } + } + + public enum BrandGlyph { + DISCORD(127.14, 96.36, new BrandPath[]{ + new BrandPath("M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.11,77.11,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.89,105.89,0,0,0,126.6,80.22c2.36-24.44-4.2-48.62-18.9-72.15ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z", "#5865F2") + }), + GITHUB(24, 24, new BrandPath[]{ + new BrandPath("M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z", "#ffffff") + }), + GITLAB(24, 24, new BrandPath[]{ + new BrandPath("M22.65 14.39L12 22.13L1.35 14.39L4.74 3.99C4.82 3.73 5.12 3.63 5.33 3.82L8.99 7.5L12 10.5L15.01 7.5L18.67 3.82C18.88 3.63 19.18 3.73 19.26 3.99L22.65 14.39Z", "#FC6D26") + }), + GOOGLE(32, 32, new BrandPath[]{ + new BrandPath("M23.75,16A7.7446,7.7446,0,0,1,8.7177,18.6259L4.2849,22.1721A13.244,13.244,0,0,0,29.25,16", "#00ac47"), + new BrandPath("M23.75,16a7.7387,7.7387,0,0,1-3.2516,6.2987l4.3824,3.5059A13.2042,13.2042,0,0,0,29.25,16", "#4285f4"), + new BrandPath("M8.25,16a7.698,7.698,0,0,1,.4677-2.6259L4.2849,9.8279a13.177,13.177,0,0,0,0,12.3442l4.4328-3.5462A7.698,7.698,0,0,1,8.25,16Z", "#ffba00"), + new BrandPath("M16,8.25a7.699,7.699,0,0,1,4.558,1.4958l4.06-3.7893A13.2152,13.2152,0,0,0,4.2849,9.8279l4.4328,3.5462A7.756,7.756,0,0,1,16,8.25Z", "#ea4435"), + new BrandPath("M29.25,15v1L27,19.5H16.5V14H28.25A1,1,0,0,1,29.25,15Z", "#4285f4") + }), + TWITTER(24, 24, new BrandPath[]{ + new BrandPath("M23.954 4.569c-.885.392-1.83.656-2.825.775a4.932 4.932 0 0 0 2.163-2.723 9.864 9.864 0 0 1-3.127 1.195 4.916 4.916 0 0 0-8.38 4.482A13.944 13.944 0 0 1 1.671 3.149a4.916 4.916 0 0 0 1.523 6.557 4.897 4.897 0 0 1-2.228-.616v.06a4.918 4.918 0 0 0 3.946 4.827 4.996 4.996 0 0 1-2.212.085 4.923 4.923 0 0 0 4.604 3.417A9.867 9.867 0 0 1 0 19.54a13.93 13.93 0 0 0 7.548 2.212c9.057 0 14.01-7.503 14.01-14.01 0-.213-.005-.425-.014-.636a10.012 10.012 0 0 0 2.46-2.548Z", "#ffffff") + }), + FACEBOOK(24, 24, new BrandPath[]{ + new BrandPath("M22.675 0H1.325C.593 0 0 .593 0 1.326v21.348C0 23.407.593 24 1.325 24h11.495v-9.294H9.692v-3.622h3.128V8.413c0-3.1 1.893-4.788 4.659-4.788 1.325 0 2.463.099 2.795.143v3.24l-1.918.001c-1.504 0-1.795.715-1.795 1.763v2.312h3.587l-.467 3.622h-3.12V24h6.116C23.407 24 24 23.407 24 22.674V1.326C24 .593 23.407 0 22.675 0Z", "#ffffff") + }); + + private final double viewBoxWidth; + private final double viewBoxHeight; + private final BrandPath[] paths; + + BrandGlyph(double viewBoxWidth, double viewBoxHeight, BrandPath[] paths) { + this.viewBoxWidth = viewBoxWidth; + this.viewBoxHeight = viewBoxHeight; + this.paths = paths; + } + } + + private LauncherIcons() { + } + + public static Node icon(Glyph glyph, double size) { + SVGPath path = new SVGPath(); + path.setContent(glyph.path); + path.setStrokeLineCap(StrokeLineCap.ROUND); + path.setStrokeLineJoin(StrokeLineJoin.ROUND); + path.getStyleClass().add("svg-icon"); + path.setScaleX(size / 24.0); + path.setScaleY(size / 24.0); + + StackPane pane = new StackPane(path); + pane.getStyleClass().add("icon-wrap"); + pane.setAlignment(Pos.CENTER); + pane.setMinSize(size, size); + pane.setPrefSize(size, size); + pane.setMaxSize(size, size); + return pane; + } + + public static Node brandIcon(BrandGlyph glyph, double size) { + Group group = new Group(); + for (BrandPath brandPath : glyph.paths) { + SVGPath path = new SVGPath(); + path.setContent(brandPath.path); + path.setFill(Color.web(brandPath.fill)); + group.getChildren().add(path); + } + double scale = size / Math.max(glyph.viewBoxWidth, glyph.viewBoxHeight); + group.setScaleX(scale); + group.setScaleY(scale); + + StackPane pane = new StackPane(group); + pane.getStyleClass().add("brand-icon-wrap"); + pane.setAlignment(Pos.CENTER); + pane.setMinSize(size, size); + pane.setPrefSize(size, size); + pane.setMaxSize(size, size); + return pane; + } + + private record BrandPath(String path, String fill) { + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/common/LauncherLayout.java b/launcher/src/main/java/net/modtale/launcher/ui/common/LauncherLayout.java new file mode 100644 index 00000000..b29a2fbc --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/common/LauncherLayout.java @@ -0,0 +1,36 @@ +package net.modtale.launcher.ui.common; + +import javafx.geometry.Insets; + +public final class LauncherLayout { + + public static final double WORKSPACE_HORIZONTAL_INSET = 112; + public static final double NAVBAR_RIGHT_EXTRA_INSET = 16; + public static final Insets WORKSPACE_INSETS = new Insets( + 18, + WORKSPACE_HORIZONTAL_INSET, + 28, + WORKSPACE_HORIZONTAL_INSET + ); + public static final Insets LAUNCHER_WORKSPACE_INSETS = new Insets(18, 40, 28, 40); + public static final Insets NAVBAR_INSETS = navbarInsets(0, 0); + + private LauncherLayout() { + } + + public static double navbarLeftInset() { + return WORKSPACE_HORIZONTAL_INSET; + } + + public static double navbarRightInset() { + return WORKSPACE_HORIZONTAL_INSET + NAVBAR_RIGHT_EXTRA_INSET; + } + + public static Insets navbarInsets(double top, double bottom) { + return new Insets(top, navbarRightInset(), bottom, navbarLeftInset()); + } + + public static Insets launcherPageInsets(double top, double bottom) { + return navbarInsets(top, bottom); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/common/LauncherOverlaySupport.java b/launcher/src/main/java/net/modtale/launcher/ui/common/LauncherOverlaySupport.java new file mode 100644 index 00000000..3d70d0b4 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/common/LauncherOverlaySupport.java @@ -0,0 +1,29 @@ +package net.modtale.launcher.ui.common; + +import javafx.event.EventTarget; +import javafx.scene.Node; + +public final class LauncherOverlaySupport { + + private LauncherOverlaySupport() { + } + + public static boolean eventTargetInside(EventTarget target, Node root) { + if (!(target instanceof Node node) || root == null) { + return false; + } + for (Node current = node; current != null; current = current.getParent()) { + if (current == root) { + return true; + } + } + return false; + } + + public static double clamp(double value, double min, double max) { + if (max < min) { + return min; + } + return Math.max(min, Math.min(value, max)); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/common/LauncherScrollSupport.java b/launcher/src/main/java/net/modtale/launcher/ui/common/LauncherScrollSupport.java new file mode 100644 index 00000000..cf93a2fb --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/common/LauncherScrollSupport.java @@ -0,0 +1,330 @@ +package net.modtale.launcher.ui.common; + +import java.util.function.Supplier; +import javafx.animation.AnimationTimer; +import javafx.animation.PauseTransition; +import javafx.css.PseudoClass; +import javafx.scene.Node; +import javafx.scene.control.ScrollPane; +import javafx.scene.input.ScrollEvent; +import javafx.util.Duration; +import net.modtale.launcher.LauncherPerformanceProbe; +import net.modtale.launcher.ui.browse.card.ProjectCardFactory; + +public final class LauncherScrollSupport { + + private static final String DROPDOWN_POPOVER_STYLE_CLASS = "filter-popover"; + private static final double LINE_PIXELS = 48; + private static final double WHEEL_VELOCITY_GAIN = 8.6; + private static final double PIXEL_VELOCITY_GAIN = 0.42; + private static final double INERTIA_VELOCITY_GAIN = 0.12; + private static final double VELOCITY_CARRY = 0.28; + private static final double REVERSE_VELOCITY_CARRY = 0.08; + private static final double FRICTION_PER_SECOND = 0.035; + private static final double FRAME_SECONDS_CAP = 0.032; + private static final double FIRST_INPUT_SECONDS = 1.0 / 60.0; + private static final double INPUT_SECONDS_MIN = 1.0 / 180.0; + private static final double INPUT_SECONDS_MAX = 1.0 / 24.0; + private static final double MAX_VELOCITY = 8200; + private static final double STOP_VELOCITY = 10; + private static final double SMOOTH_PIXELS_EPSILON = 0.35; + private static final double WHEEL_SMOOTHING_SECONDS = 0.052; + private static final double PENDING_PIXELS_CAP = 2100; + private static final PseudoClass SCROLLING = PseudoClass.getPseudoClass("scrolling"); + + private final Supplier rootSupplier; + private final PauseTransition interactionCooldown = new PauseTransition(Duration.millis(120)); + + public LauncherScrollSupport(Supplier rootSupplier) { + this.rootSupplier = rootSupplier; + interactionCooldown.setOnFinished(event -> clearScrollInteraction()); + } + + public void configure(ScrollPane scrollPane, boolean horizontal) { + new MomentumScrollController(scrollPane, horizontal).install(); + } + + private final class MomentumScrollController { + + private final ScrollPane scrollPane; + private final boolean horizontal; + private double directPixels; + private double smoothPixels; + private double velocity; + private long previousInput; + private long previousFrame; + + private final AnimationTimer timer = new AnimationTimer() { + @Override + public void handle(long now) { + animate(now); + } + }; + + private MomentumScrollController(ScrollPane scrollPane, boolean horizontal) { + this.scrollPane = scrollPane; + this.horizontal = horizontal; + } + + private void install() { + scrollPane.addEventFilter(ScrollEvent.SCROLL, this::handleScroll); + } + + private void handleScroll(ScrollEvent event) { + long operationStart = LauncherPerformanceProbe.operationStartNanos(); + try { + if (event.isControlDown() || scrollPane.getContent() == null) { + return; + } + if (eventTargetInsideStyleClass(event, DROPDOWN_POPOVER_STYLE_CLASS) + || eventTargetInsideNestedScrollPane(event, scrollPane)) { + stopMomentum(); + return; + } + double eventPixels = scrollPixels(event, horizontal, viewportSize(scrollPane, horizontal)); + if (Math.abs(eventPixels) < 0.5) { + return; + } + double movementPixels = -eventPixels; + if (!canScrollByPixels(scrollPane, horizontal, movementPixels)) { + stopMomentum(); + return; + } + activateScrollInteraction(); + ScrollInputKind inputKind = inputKind(event, horizontal); + if (inputKind == ScrollInputKind.PIXEL) { + directPixels = clamp(directPixels + movementPixels, -PENDING_PIXELS_CAP, PENDING_PIXELS_CAP); + } else { + smoothPixels = clamp(smoothPixels + movementPixels, -PENDING_PIXELS_CAP, PENDING_PIXELS_CAP); + } + long inputNow = System.nanoTime(); + double inputVelocity = inputVelocity(event, inputKind, movementPixels, inputNow); + previousInput = inputNow; + velocity = nextVelocity(velocity, inputVelocity); + timer.start(); + event.consume(); + } finally { + LauncherPerformanceProbe.recordOperation("scroll.input", operationStart); + } + } + + private double inputVelocity(ScrollEvent event, ScrollInputKind inputKind, double movementPixels, long inputNow) { + if (inputKind == ScrollInputKind.PIXEL) { + double seconds = previousInput == 0 + ? FIRST_INPUT_SECONDS + : clamp((inputNow - previousInput) / 1_000_000_000.0, INPUT_SECONDS_MIN, INPUT_SECONDS_MAX); + double gain = event.isInertia() ? INERTIA_VELOCITY_GAIN : PIXEL_VELOCITY_GAIN; + return movementPixels / seconds * gain; + } + return movementPixels * WHEEL_VELOCITY_GAIN; + } + + private void animate(long now) { + long operationStart = LauncherPerformanceProbe.operationStartNanos(); + try { + if (previousFrame == 0) { + previousFrame = now - Math.round(FIRST_INPUT_SECONDS * 1_000_000_000.0); + } + double deltaSeconds = Math.min(FRAME_SECONDS_CAP, (now - previousFrame) / 1_000_000_000.0); + previousFrame = now; + + double movementPixels = directPixels + drainSmoothPixels(deltaSeconds); + directPixels = 0; + boolean hasPendingPixels = Math.abs(movementPixels) >= 0.25 || Math.abs(smoothPixels) >= SMOOTH_PIXELS_EPSILON; + if (!hasPendingPixels && Math.abs(velocity) >= STOP_VELOCITY) { + movementPixels += velocity * deltaSeconds; + } + if (!hasPendingPixels && Math.abs(movementPixels) < 0.25 && Math.abs(velocity) < STOP_VELOCITY) { + stopMomentum(); + return; + } + if (Math.abs(movementPixels) >= 0.25 && !scrollByPixels(scrollPane, horizontal, movementPixels)) { + stopMomentum(); + return; + } + velocity *= Math.pow(FRICTION_PER_SECOND, deltaSeconds); + } finally { + LauncherPerformanceProbe.recordOperation("scroll.animate", operationStart); + } + } + + private double drainSmoothPixels(double deltaSeconds) { + if (Math.abs(smoothPixels) < SMOOTH_PIXELS_EPSILON) { + double pixels = smoothPixels; + smoothPixels = 0; + return pixels; + } + double pixels = smoothPixels * smoothingFactor(deltaSeconds); + smoothPixels -= pixels; + return pixels; + } + + private void stopMomentum() { + directPixels = 0; + smoothPixels = 0; + velocity = 0; + previousInput = 0; + previousFrame = 0; + timer.stop(); + scheduleScrollInteractionIdle(); + } + } + + private enum ScrollInputKind { + PIXEL, + WHEEL + } + + private void activateScrollInteraction() { + interactionCooldown.stop(); + Node root = rootSupplier.get(); + if (root != null + && !Boolean.TRUE.equals(root.getProperties().get(ProjectCardFactory.SCROLL_ACTIVE_PROPERTY))) { + root.getProperties().put(ProjectCardFactory.SCROLL_ACTIVE_PROPERTY, Boolean.TRUE); + root.pseudoClassStateChanged(SCROLLING, true); + } + } + + private void scheduleScrollInteractionIdle() { + interactionCooldown.playFromStart(); + } + + private void clearScrollInteraction() { + Node root = rootSupplier.get(); + if (root != null + && Boolean.TRUE.equals(root.getProperties().get(ProjectCardFactory.SCROLL_ACTIVE_PROPERTY))) { + root.getProperties().remove(ProjectCardFactory.SCROLL_ACTIVE_PROPERTY); + root.pseudoClassStateChanged(SCROLLING, false); + } + } + + private static boolean canScrollByPixels(ScrollPane scrollPane, boolean horizontal, double pixels) { + double scrollable = scrollablePixels(scrollPane, horizontal); + if (scrollable <= 1) { + return false; + } + double previous = scrollValue(scrollPane, horizontal); + double next = clamp(previous + pixels / scrollable, scrollMin(scrollPane, horizontal), scrollMax(scrollPane, horizontal)); + return Math.abs(next - previous) >= 0.0001; + } + + private static boolean scrollByPixels(ScrollPane scrollPane, boolean horizontal, double pixels) { + double scrollable = scrollablePixels(scrollPane, horizontal); + if (scrollable <= 1) { + return false; + } + double previous = scrollValue(scrollPane, horizontal); + double next = clamp(previous + pixels / scrollable, scrollMin(scrollPane, horizontal), scrollMax(scrollPane, horizontal)); + if (Math.abs(next - previous) < 0.0001) { + return false; + } + setScrollValue(scrollPane, horizontal, next); + return true; + } + + private static boolean eventTargetInsideStyleClass(ScrollEvent event, String styleClass) { + if (!(event.getTarget() instanceof Node node)) { + return false; + } + for (Node current = node; current != null; current = current.getParent()) { + if (current.getStyleClass().contains(styleClass)) { + return true; + } + } + return false; + } + + private static boolean eventTargetInsideNestedScrollPane(ScrollEvent event, ScrollPane outerScrollPane) { + if (!(event.getTarget() instanceof Node node)) { + return false; + } + for (Node current = node; current != null && current != outerScrollPane; current = current.getParent()) { + if (current instanceof ScrollPane nestedScrollPane + && nestedScrollPane != outerScrollPane + && nestedScrollPane.getContent() != null + && nestedScrollPaneCanHandle(nestedScrollPane, event)) { + return true; + } + } + return false; + } + + private static boolean nestedScrollPaneCanHandle(ScrollPane scrollPane, ScrollEvent event) { + double verticalPixels = scrollPixels(event, false, viewportSize(scrollPane, false)); + if (Math.abs(verticalPixels) >= 0.5 && canScrollByPixels(scrollPane, false, -verticalPixels)) { + return true; + } + double horizontalPixels = scrollPixels(event, true, viewportSize(scrollPane, true)); + return Math.abs(horizontalPixels) >= 0.5 && canScrollByPixels(scrollPane, true, -horizontalPixels); + } + + private static double scrollablePixels(ScrollPane scrollPane, boolean horizontal) { + return horizontal + ? scrollPane.getContent().getLayoutBounds().getWidth() - scrollPane.getViewportBounds().getWidth() + : scrollPane.getContent().getLayoutBounds().getHeight() - scrollPane.getViewportBounds().getHeight(); + } + + private static double viewportSize(ScrollPane scrollPane, boolean horizontal) { + return horizontal ? scrollPane.getViewportBounds().getWidth() : scrollPane.getViewportBounds().getHeight(); + } + + private static double scrollPixels(ScrollEvent event, boolean horizontal, double viewportSize) { + if (horizontal && Math.abs(event.getDeltaX()) > 0.5) { + return event.getDeltaX(); + } + return switch (event.getTextDeltaYUnits()) { + case LINES -> event.getTextDeltaY() * LINE_PIXELS; + case PAGES -> event.getTextDeltaY() * Math.max(120, viewportSize * 0.86); + case NONE -> event.getDeltaY(); + }; + } + + private static ScrollInputKind inputKind(ScrollEvent event, boolean horizontal) { + if (event.isInertia() || (horizontal && Math.abs(event.getDeltaX()) > 0.5)) { + return ScrollInputKind.PIXEL; + } + return event.getTextDeltaYUnits() == ScrollEvent.VerticalTextScrollUnits.NONE + ? ScrollInputKind.PIXEL + : ScrollInputKind.WHEEL; + } + + private static double nextVelocity(double currentVelocity, double inputVelocity) { + double carry = Math.signum(currentVelocity) != 0 + && Math.signum(inputVelocity) != 0 + && Math.signum(currentVelocity) != Math.signum(inputVelocity) + ? REVERSE_VELOCITY_CARRY + : VELOCITY_CARRY; + return clamp(currentVelocity * carry + inputVelocity, -MAX_VELOCITY, MAX_VELOCITY); + } + + public static double smoothingFactor(double deltaSeconds) { + if (deltaSeconds <= 0) { + return 0; + } + return 1 - Math.exp(-deltaSeconds / WHEEL_SMOOTHING_SECONDS); + } + + private static double scrollValue(ScrollPane scrollPane, boolean horizontal) { + return horizontal ? scrollPane.getHvalue() : scrollPane.getVvalue(); + } + + private static void setScrollValue(ScrollPane scrollPane, boolean horizontal, double value) { + if (horizontal) { + scrollPane.setHvalue(value); + } else { + scrollPane.setVvalue(value); + } + } + + private static double scrollMin(ScrollPane scrollPane, boolean horizontal) { + return horizontal ? scrollPane.getHmin() : scrollPane.getVmin(); + } + + private static double scrollMax(ScrollPane scrollPane, boolean horizontal) { + return horizontal ? scrollPane.getHmax() : scrollPane.getVmax(); + } + + private static double clamp(double value, double min, double max) { + return Math.max(min, Math.min(max, value)); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/common/LauncherUi.java b/launcher/src/main/java/net/modtale/launcher/ui/common/LauncherUi.java new file mode 100644 index 00000000..d32c8444 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/common/LauncherUi.java @@ -0,0 +1,180 @@ +package net.modtale.launcher.ui.common; + +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.CheckBox; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.TextField; +import javafx.scene.layout.ColumnConstraints; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import net.modtale.launcher.model.project.ProjectClassification; + +public final class LauncherUi { + + private LauncherUi() { + } + + public static VBox panel(String title, String subtitle) { + VBox panel = new VBox(14); + panel.getStyleClass().add("glass-panel"); + Label heading = new Label(title); + heading.getStyleClass().add("panel-title"); + Label sub = new Label(subtitle); + sub.getStyleClass().add("panel-subtitle"); + panel.getChildren().addAll(heading, sub); + return panel; + } + + public static GridPane formGrid() { + GridPane grid = new GridPane(); + grid.getStyleClass().add("form-grid"); + grid.setHgap(14); + grid.setVgap(14); + ColumnConstraints labelColumn = new ColumnConstraints(); + labelColumn.setMinWidth(98); + labelColumn.setPrefWidth(118); + ColumnConstraints fieldColumn = new ColumnConstraints(); + fieldColumn.setHgrow(Priority.ALWAYS); + fieldColumn.setFillWidth(true); + grid.getColumnConstraints().addAll(labelColumn, fieldColumn); + return grid; + } + + public static void addField(GridPane grid, int row, String label, Node field) { + Label labelNode = new Label(label); + labelNode.getStyleClass().add("field-label"); + grid.add(labelNode, 0, row); + if (field instanceof Region region) { + region.setMaxWidth(Double.MAX_VALUE); + } + grid.add(field, 1, row); + GridPane.setHgrow(field, Priority.ALWAYS); + GridPane.setFillWidth(field, true); + } + + public static void styleInput(TextField... fields) { + for (TextField field : fields) { + field.getStyleClass().add("input"); + } + } + + public static void styleCombo(ComboBox... combos) { + for (ComboBox combo : combos) { + combo.getStyleClass().add("select"); + combo.setMaxWidth(Double.MAX_VALUE); + } + } + + public static Button primaryButton(String label) { + Button button = new Button(label); + button.getStyleClass().addAll("btn", "primary"); + return button; + } + + public static Button secondaryButton(String label) { + Button button = new Button(label); + button.getStyleClass().addAll("btn", "secondary"); + return button; + } + + public static Button dangerButton(String label) { + Button button = new Button(label); + button.getStyleClass().addAll("btn", "danger"); + return button; + } + + public static Node statusDot() { + Region dot = new Region(); + dot.getStyleClass().add("status-dot"); + return dot; + } + + public static StackPane miniIcon(String text) { + Label label = new Label(text); + StackPane pane = new StackPane(label); + pane.getStyleClass().add("row-icon"); + return pane; + } + + public static Node toggleCard(CheckBox checkBox) { + StackPane card = new StackPane(checkBox); + card.getStyleClass().add("toggle-card"); + HBox.setHgrow(card, Priority.ALWAYS); + return card; + } + + public static Node metricCard(String label, Label value) { + VBox card = new VBox(6); + card.getStyleClass().add("metric-card"); + Label title = new Label(label); + title.getStyleClass().add("metric-label"); + value.getStyleClass().add("metric-value"); + card.getChildren().addAll(title, value); + HBox.setHgrow(card, Priority.ALWAYS); + return card; + } + + public static VBox emptyState(String title, String subtitle) { + VBox box = new VBox(8); + box.getStyleClass().add("empty-state"); + box.setAlignment(javafx.geometry.Pos.CENTER); + Label heading = new Label(title); + heading.getStyleClass().add("empty-title"); + Label sub = new Label(subtitle); + sub.getStyleClass().add("empty-subtitle"); + box.getChildren().addAll(heading, sub); + return box; + } + + public static HBox rowCard(String title, String subtitle) { + HBox row = new HBox(12); + row.getStyleClass().add("row-card"); + row.setAlignment(javafx.geometry.Pos.CENTER_LEFT); + StackPane icon = miniIcon("M"); + VBox copy = new VBox(4); + Label titleLabel = new Label(value(title, "Untitled Project")); + titleLabel.getStyleClass().add("row-title"); + Label subtitleLabel = new Label(subtitle); + subtitleLabel.getStyleClass().add("row-subtitle"); + copy.getChildren().addAll(titleLabel, subtitleLabel); + HBox.setHgrow(copy, Priority.ALWAYS); + row.getChildren().addAll(icon, copy); + return row; + } + + public static void setVisibleManaged(Node node, boolean visible) { + if (node == null) { + return; + } + node.setVisible(visible); + node.setManaged(visible); + } + + public static String readableField(String fieldName) { + return switch (fieldName) { + case "hytaleModsPath" -> "Hytale mods folder"; + case "hytaleGamePath" -> "Hytale game folder"; + case "hytaleUserDataPath" -> "Hytale user data folder"; + case "hytaleJavaPath" -> "Java executable"; + default -> "path"; + }; + } + + public static String value(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value; + } + + public static String classificationLabel(String classification) { + return ProjectClassification.labelFor(classification); + } + + public static void pseudo(Node node, String pseudoClass, boolean active) { + node.pseudoClassStateChanged(javafx.css.PseudoClass.getPseudoClass(pseudoClass), active); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/common/LauncherView.java b/launcher/src/main/java/net/modtale/launcher/ui/common/LauncherView.java new file mode 100644 index 00000000..24104ae9 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/common/LauncherView.java @@ -0,0 +1,37 @@ +package net.modtale.launcher.ui.common; + +import java.util.Arrays; + +public enum LauncherView { + DISCOVER("discover"), + PLAY("play"), + LIBRARY("library"), + UPDATES("updates"), + NOTIFICATIONS("notifications"), + SETTINGS("settings"), + PROJECT("project"); + + private final String id; + + LauncherView(String id) { + this.id = id; + } + + public String id() { + return id; + } + + public static LauncherView defaultView() { + return PLAY; + } + + public static LauncherView fromId(String id) { + if (id == null || id.isBlank()) { + return defaultView(); + } + return Arrays.stream(values()) + .filter(view -> view.id.equalsIgnoreCase(id.trim())) + .findFirst() + .orElse(defaultView()); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/common/StatusModal.java b/launcher/src/main/java/net/modtale/launcher/ui/common/StatusModal.java new file mode 100644 index 00000000..f569b04e --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/common/StatusModal.java @@ -0,0 +1,482 @@ +package net.modtale.launcher.ui.common; + +import java.util.ArrayList; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.function.Supplier; +import javafx.animation.AnimationTimer; +import javafx.application.Platform; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.Parent; +import javafx.scene.canvas.Canvas; +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.effect.Effect; +import javafx.scene.effect.GaussianBlur; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import javafx.scene.paint.Color; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +public final class StatusModal { + + private static final Logger LOG = LogManager.getLogger(StatusModal.class); + + public enum Type { + SUCCESS("success", LauncherIcons.Glyph.CHECK), + ERROR("error", LauncherIcons.Glyph.ALERT_TRIANGLE), + WARNING("warning", LauncherIcons.Glyph.TRASH), + INFO("info", LauncherIcons.Glyph.INFO); + + private final String styleSuffix; + private final LauncherIcons.Glyph glyph; + + Type(String styleSuffix, LauncherIcons.Glyph glyph) { + this.styleSuffix = styleSuffix; + this.glyph = glyph; + } + } + + public enum Result { + PRIMARY, + SECONDARY, + CLOSED + } + + public static Builder builder(Supplier host) { + return new Builder(host); + } + + private static final double MODAL_WIDTH = 448; + private static final double BODY_TEXT_WIDTH = 400; + + private final Supplier host; + private final Type type; + private final String title; + private final String message; + private final String actionLabel; + private final LauncherIcons.Glyph actionIcon; + private final String secondaryLabel; + private final Node content; + private final Map backdropEffects = new IdentityHashMap<>(); + + private StackPane overlay; + private StatusConfetti confetti; + private boolean completed; + + private StatusModal(Builder builder) { + this.host = builder.host == null ? () -> null : builder.host; + this.type = builder.type == null ? Type.INFO : builder.type; + this.title = value(builder.title); + this.message = value(builder.message); + this.actionLabel = value(builder.actionLabel).isBlank() ? "Close" : value(builder.actionLabel); + this.actionIcon = builder.actionIcon; + this.secondaryLabel = value(builder.secondaryLabel); + this.content = builder.content; + } + + public Result showAndWait() { + if (!Platform.isFxApplicationThread()) { + throw new IllegalStateException("StatusModal must be shown on the JavaFX application thread."); + } + if (!show()) { + return Result.CLOSED; + } + Object result = Platform.enterNestedEventLoop(this); + return result instanceof Result modalResult ? modalResult : Result.CLOSED; + } + + private boolean show() { + StackPane hostPane = host.get(); + if (hostPane == null) { + return false; + } + if (type == Type.ERROR) { + LOG.warn("Error modal: {} - {}", title, message); + } + overlay = overlayShell(); + blurBackdrop(hostPane); + if (type == Type.SUCCESS) { + confetti = new StatusConfetti(); + overlay.getChildren().setAll(confetti.canvas, card()); + } else { + overlay.getChildren().setAll(card()); + } + hostPane.getChildren().add(overlay); + if (confetti != null) { + confetti.start(); + } + Platform.runLater(overlay::requestFocus); + return true; + } + + private StackPane overlayShell() { + StackPane shell = new StackPane(); + shell.getStyleClass().add("status-modal-overlay"); + shell.setMaxSize(Double.MAX_VALUE, Double.MAX_VALUE); + shell.setFocusTraversable(true); + shell.addEventHandler(KeyEvent.KEY_PRESSED, event -> { + if (event.getCode() == KeyCode.ESCAPE) { + complete(Result.CLOSED); + event.consume(); + } + }); + shell.setOnMouseClicked(event -> { + if (event.getTarget() == shell) { + complete(Result.CLOSED); + } + }); + return shell; + } + + private StackPane card() { + StackPane card = new StackPane(); + card.getStyleClass().addAll("status-modal", "status-modal-" + type.styleSuffix); + card.setMaxWidth(MODAL_WIDTH); + card.setMaxHeight(Region.USE_PREF_SIZE); + card.setPrefWidth(MODAL_WIDTH); + card.setOnMouseClicked(event -> event.consume()); + + VBox layout = new VBox(0); + layout.getChildren().addAll(body(), footer()); + + Button close = new Button(null, LauncherIcons.icon(LauncherIcons.Glyph.X, 20)); + close.getStyleClass().add("status-modal-close"); + close.setOnAction(event -> complete(Result.CLOSED)); + StackPane.setAlignment(close, Pos.TOP_RIGHT); + StackPane.setMargin(close, new Insets(16, 16, 0, 0)); + + card.getChildren().addAll(layout, close); + return card; + } + + private VBox body() { + VBox body = new VBox(0); + body.getStyleClass().add("status-modal-body"); + body.setAlignment(Pos.CENTER); + + StackPane icon = new StackPane(LauncherIcons.icon(type.glyph, 32)); + icon.getStyleClass().add("status-modal-icon"); + + Label titleLabel = new Label(title); + titleLabel.getStyleClass().add("status-modal-title"); + titleLabel.setWrapText(true); + titleLabel.setAlignment(Pos.CENTER); + titleLabel.setMaxWidth(BODY_TEXT_WIDTH); + + Label messageLabel = new Label(message); + messageLabel.getStyleClass().add("status-modal-message"); + messageLabel.setWrapText(true); + messageLabel.setAlignment(Pos.CENTER); + messageLabel.setMaxWidth(BODY_TEXT_WIDTH); + + body.getChildren().addAll(icon, titleLabel, messageLabel); + VBox.setMargin(icon, new Insets(0, 0, 16, 0)); + VBox.setMargin(titleLabel, new Insets(0, 0, 8, 0)); + if (content != null) { + content.getStyleClass().add("status-modal-custom-content"); + if (content instanceof Region region) { + region.setMaxWidth(BODY_TEXT_WIDTH); + } + if (content instanceof VBox box) { + box.setAlignment(Pos.CENTER); + } else if (content instanceof HBox box) { + box.setAlignment(Pos.CENTER); + } else if (content instanceof StackPane pane) { + pane.setAlignment(Pos.CENTER); + } + VBox.setMargin(content, new Insets(16, 0, 0, 0)); + body.getChildren().add(content); + } + return body; + } + + private HBox footer() { + HBox footer = new HBox(12); + footer.getStyleClass().add("status-modal-footer"); + footer.setAlignment(Pos.CENTER); + + if (showsSecondaryAction()) { + Button secondary = new Button(secondaryLabel.isBlank() ? "Cancel" : secondaryLabel); + secondary.getStyleClass().add("status-modal-secondary"); + secondary.setOnAction(event -> complete(Result.SECONDARY)); + footer.getChildren().add(secondary); + } + + Button primary = new Button(actionLabel); + primary.getStyleClass().add("status-modal-primary"); + primary.setOnAction(event -> complete(Result.PRIMARY)); + if (actionIcon != null || type == Type.SUCCESS) { + primary.setGraphic(LauncherIcons.icon(actionIcon == null ? LauncherIcons.Glyph.ARROW_RIGHT : actionIcon, 20)); + } + footer.getChildren().add(primary); + return footer; + } + + private boolean showsSecondaryAction() { + return !secondaryLabel.isBlank() || type == Type.WARNING || type == Type.INFO; + } + + private void complete(Result result) { + if (completed) { + return; + } + completed = true; + hide(); + Platform.exitNestedEventLoop(this, result); + } + + private void hide() { + if (overlay == null) { + return; + } + if (confetti != null) { + confetti.stop(); + confetti = null; + } + Parent parent = overlay.getParent(); + if (parent instanceof StackPane stack) { + stack.getChildren().remove(overlay); + } + overlay = null; + restoreBackdrop(); + } + + private void blurBackdrop(StackPane hostPane) { + restoreBackdrop(); + for (Node child : hostPane.getChildren()) { + backdropEffects.put(child, child.getEffect()); + child.setEffect(new GaussianBlur(6)); + } + } + + private void restoreBackdrop() { + backdropEffects.forEach(Node::setEffect); + backdropEffects.clear(); + } + + private static String value(String value) { + return value == null ? "" : value.trim(); + } + + public static final class Builder { + private final Supplier host; + private Type type = Type.INFO; + private String title = ""; + private String message = ""; + private String actionLabel = ""; + private LauncherIcons.Glyph actionIcon; + private String secondaryLabel = ""; + private Node content; + + private Builder(Supplier host) { + this.host = host; + } + + public Builder type(Type type) { + this.type = type; + return this; + } + + public Builder title(String title) { + this.title = title; + return this; + } + + public Builder message(String message) { + this.message = message; + return this; + } + + public Builder actionLabel(String actionLabel) { + this.actionLabel = actionLabel; + return this; + } + + public Builder actionIcon(LauncherIcons.Glyph actionIcon) { + this.actionIcon = actionIcon; + return this; + } + + public Builder secondaryLabel(String secondaryLabel) { + this.secondaryLabel = secondaryLabel; + return this; + } + + public Builder content(Node content) { + this.content = content; + return this; + } + + public Result showAndWait() { + return new StatusModal(this).showAndWait(); + } + } + + private static final class StatusConfetti { + private static final int PARTICLE_COUNT = 400; + private static final String[] COLORS = { + "#3b82f6", "#ef4444", "#10b981", "#f59e0b", + "#8b5cf6", "#ec4899", "#06b6d4", "#ffffff" + }; + + private final Canvas canvas = new Canvas(); + private final Random random = new Random(); + private final List particles = new ArrayList<>(PARTICLE_COUNT); + private final AnimationTimer timer = new AnimationTimer() { + @Override + public void handle(long now) { + draw(); + } + }; + + private StatusConfetti() { + canvas.getStyleClass().add("status-modal-confetti"); + canvas.setMouseTransparent(true); + canvas.parentProperty().addListener((observable, oldParent, newParent) -> { + canvas.widthProperty().unbind(); + canvas.heightProperty().unbind(); + if (newParent instanceof Region region) { + canvas.widthProperty().bind(region.widthProperty()); + canvas.heightProperty().bind(region.heightProperty()); + } else { + canvas.setWidth(0); + canvas.setHeight(0); + } + }); + } + + private void start() { + timer.start(); + } + + private void stop() { + timer.stop(); + } + + private void draw() { + double width = canvas.getWidth(); + double height = canvas.getHeight(); + if (width <= 0 || height <= 0) { + return; + } + if (particles.isEmpty()) { + seed(width, height); + } + GraphicsContext graphics = canvas.getGraphicsContext2D(); + graphics.clearRect(0, 0, width, height); + int activeCount = 0; + for (Particle particle : particles) { + if (!particle.active) { + continue; + } + particle.x += particle.vx; + particle.y += particle.vy; + particle.vy += particle.gravity; + particle.vx *= particle.drag; + particle.vy *= particle.drag; + particle.tilt += particle.tiltAngleIncrement; + particle.angle += particle.rotationSpeed; + if (particle.y > height + 100) { + particle.active = false; + continue; + } + activeCount++; + graphics.save(); + graphics.translate(particle.x, particle.y); + graphics.rotate(Math.toDegrees(particle.angle)); + graphics.scale(1, Math.cos(particle.tilt)); + graphics.setFill(particle.color); + graphics.fillRect(-particle.w / 2, -particle.h / 2, particle.w, particle.h); + graphics.restore(); + } + if (activeCount == 0) { + stop(); + } + } + + private void seed(double width, double height) { + double centerX = width / 2; + double centerY = height / 2; + for (int i = 0; i < PARTICLE_COUNT; i++) { + double angle = random.nextDouble() * Math.PI * 2; + double velocity = random.nextDouble() * 35 + 10; + particles.add(new Particle( + centerX, + centerY, + Math.cos(angle) * velocity, + Math.sin(angle) * velocity, + Color.web(COLORS[random.nextInt(COLORS.length)]), + random.nextDouble() * 12 + 4, + random.nextDouble() * 6 + 4, + 0.6, + 0.92, + random.nextDouble() * Math.PI * 2, + (random.nextDouble() - 0.5) * 0.3, + random.nextDouble() * 10, + random.nextDouble() * 0.1 + 0.05, + true + )); + } + } + } + + private static final class Particle { + private double x; + private double y; + private double vx; + private double vy; + private final Color color; + private final double w; + private final double h; + private final double gravity; + private final double drag; + private double angle; + private final double rotationSpeed; + private double tilt; + private final double tiltAngleIncrement; + private boolean active; + + private Particle( + double x, + double y, + double vx, + double vy, + Color color, + double w, + double h, + double gravity, + double drag, + double angle, + double rotationSpeed, + double tilt, + double tiltAngleIncrement, + boolean active + ) { + this.x = x; + this.y = y; + this.vx = vx; + this.vy = vy; + this.color = color; + this.w = w; + this.h = h; + this.gravity = gravity; + this.drag = drag; + this.angle = angle; + this.rotationSpeed = rotationSpeed; + this.tilt = tilt; + this.tiltAngleIncrement = tiltAngleIncrement; + this.active = active; + } + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/feedback/LauncherFeedback.java b/launcher/src/main/java/net/modtale/launcher/ui/feedback/LauncherFeedback.java new file mode 100644 index 00000000..9e309d56 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/feedback/LauncherFeedback.java @@ -0,0 +1,172 @@ +package net.modtale.launcher.ui.feedback; + +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.function.Supplier; +import javafx.application.Platform; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Label; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import net.modtale.launcher.ui.common.LauncherIcons; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +public final class LauncherFeedback { + + private static final Logger LOG = LogManager.getLogger(LauncherFeedback.class); + private static final DateTimeFormatter LOG_TIME = DateTimeFormatter.ofPattern("HH:mm"); + private static final String TOAST_SUCCESS = "toast-success"; + private static final String TOAST_ERROR = "toast-error"; + private static final String TOAST_NEUTRAL = "toast-neutral"; + + private final Executor executor; + private final Label statusText; + private final VBox logList; + private final StackPane toast; + private final Label toastTitle; + private final Label toastMessage; + private final Supplier idleStatus; + private StackPane toastIcon; + + public LauncherFeedback( + Executor executor, + Label statusText, + VBox logList, + StackPane toast, + Label toastTitle, + Label toastMessage, + Supplier idleStatus + ) { + this.executor = executor; + this.statusText = statusText; + this.logList = logList; + this.toast = toast; + this.toastTitle = toastTitle; + this.toastMessage = toastMessage; + this.idleStatus = idleStatus; + } + + public void runAsync(String status, Supplier work, Consumer onSuccess) { + runAsync(status, work, onSuccess, ignored -> { + }); + } + + public void runAsync(String status, Supplier work, Consumer onSuccess, Consumer onError) { + Platform.runLater(() -> statusText.setText(status)); + log(status); + CompletableFuture.supplyAsync(work, executor) + .whenComplete((value, error) -> Platform.runLater(() -> { + statusText.setText(idleStatus.get()); + if (error != null) { + Throwable cause = error.getCause() == null ? error : error.getCause(); + LOG.error("Async action failed: {}", status, cause); + log("Error: " + cause.getMessage()); + showToast("Action failed", cause.getMessage()); + onError.accept(cause); + return; + } + onSuccess.accept(value); + })); + } + + public void log(String message) { + LOG.info(message); + Platform.runLater(() -> { + HBox line = new HBox(10); + line.getStyleClass().add("log-line"); + Label time = new Label(LOG_TIME.format(java.time.LocalTime.now())); + time.getStyleClass().add("log-time"); + Label text = new Label(message); + text.getStyleClass().add("log-text"); + line.getChildren().addAll(time, text); + logList.getChildren().add(line); + if (logList.getChildren().size() > 80) { + logList.getChildren().remove(0); + } + }); + } + + public void showToast(String title, String message) { + toastTitle.setText(title == null ? "Modtale" : title); + toastMessage.setText(message == null ? "" : message); + ToastTone tone = toneFor(title); + if (tone == ToastTone.ERROR) { + LOG.warn("Error toast: {} - {}", toastTitle.getText(), toastMessage.getText()); + } + if (toast.getChildren().isEmpty()) { + toastIcon = new StackPane(); + toastIcon.getStyleClass().add("toast-icon"); + + VBox copy = new VBox(1, toastTitle, toastMessage); + copy.getStyleClass().add("toast-copy"); + copy.setMaxWidth(292); + HBox.setHgrow(copy, Priority.ALWAYS); + + HBox box = new HBox(10, toastIcon, copy); + box.getStyleClass().add("toast-content"); + box.setAlignment(Pos.TOP_LEFT); + toastTitle.getStyleClass().add("toast-title"); + toastTitle.setWrapText(true); + toastTitle.setMaxWidth(292); + toastMessage.getStyleClass().add("toast-message"); + toastMessage.setWrapText(true); + toastMessage.setMaxWidth(292); + toast.getChildren().add(box); + } + toast.getStyleClass().removeAll(TOAST_SUCCESS, TOAST_ERROR, TOAST_NEUTRAL); + toast.getStyleClass().add(tone.styleClass); + if (toastIcon != null) { + Node icon = LauncherIcons.icon(tone.glyph, 14); + toastIcon.getChildren().setAll(icon); + } + toast.setVisible(true); + toast.setManaged(true); + CompletableFuture.delayedExecutor(5, TimeUnit.SECONDS).execute(() -> + Platform.runLater(() -> { + toast.setVisible(false); + toast.setManaged(false); + })); + } + + private static ToastTone toneFor(String title) { + String normalized = title == null ? "" : title.toLowerCase(Locale.ROOT); + if (normalized.contains("failed") + || normalized.contains("error") + || normalized.startsWith("could not")) { + return ToastTone.ERROR; + } + if (normalized.contains("signed in") + || normalized.contains("saved") + || normalized.contains("installed") + || normalized.contains("ready") + || normalized.contains("cleared") + || normalized.contains("liked") + || normalized.contains("updated") + || normalized.equals("accepted")) { + return ToastTone.SUCCESS; + } + return ToastTone.NEUTRAL; + } + + private enum ToastTone { + SUCCESS(TOAST_SUCCESS, LauncherIcons.Glyph.CHECK), + ERROR(TOAST_ERROR, LauncherIcons.Glyph.X), + NEUTRAL(TOAST_NEUTRAL, LauncherIcons.Glyph.BELL); + + private final String styleClass; + private final LauncherIcons.Glyph glyph; + + ToastTone(String styleClass, LauncherIcons.Glyph glyph) { + this.styleClass = styleClass; + this.glyph = glyph; + } + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/feedback/LauncherFeedbackView.java b/launcher/src/main/java/net/modtale/launcher/ui/feedback/LauncherFeedbackView.java new file mode 100644 index 00000000..fda309cb --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/feedback/LauncherFeedbackView.java @@ -0,0 +1,49 @@ +package net.modtale.launcher.ui.feedback; + +import static net.modtale.launcher.ui.common.LauncherUi.statusDot; + +import java.util.concurrent.Executor; +import java.util.function.Supplier; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Label; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; + +public final class LauncherFeedbackView { + + private final Label statusText = new Label("Ready"); + private final Label toastTitle = new Label(); + private final Label toastMessage = new Label(); + private final VBox logList = new VBox(4); + private final StackPane toast = new StackPane(); + + public LauncherFeedbackView() { + toast.getStyleClass().add("toast"); + toast.setMaxWidth(360); + toast.setMaxHeight(Region.USE_PREF_SIZE); + toast.setVisible(false); + toast.setManaged(false); + } + + public Label statusText() { + return statusText; + } + + public StackPane toast() { + return toast; + } + + public LauncherFeedback feedback(Executor executor, Supplier idleStatus) { + return new LauncherFeedback(executor, statusText, logList, toast, toastTitle, toastMessage, idleStatus); + } + + public Node statusChip() { + HBox status = new HBox(8, statusDot(), statusText); + status.getStyleClass().add("status-chip"); + status.setAlignment(Pos.CENTER_LEFT); + return status; + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/library/InstallDuplicateWarning.java b/launcher/src/main/java/net/modtale/launcher/ui/library/InstallDuplicateWarning.java new file mode 100644 index 00000000..44964a31 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/library/InstallDuplicateWarning.java @@ -0,0 +1,258 @@ +package net.modtale.launcher.ui.library; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import net.modtale.launcher.model.install.InstalledProject; +import net.modtale.launcher.model.install.InstalledProjectReference; +import net.modtale.launcher.model.project.ProjectDependency; +import net.modtale.launcher.model.project.ProjectDetail; +import net.modtale.launcher.model.project.ProjectSummary; +import net.modtale.launcher.model.project.ProjectVersion; +import net.modtale.launcher.model.worldlist.WorldModList; + +final class InstallDuplicateWarning { + + private InstallDuplicateWarning() { + } + + static Result forSummary(List installedProjects, ProjectSummary summary) { + if (summary == null) { + return Result.empty(); + } + return findDuplicates(installedProjects, List.of(new Candidate( + summary.id(), + summary.slug(), + summary.title(), + "" + ))); + } + + static Result forProject( + List installedProjects, + ProjectDetail project, + ProjectVersion version, + List dependencies, + boolean includeOptionalDependencies + ) { + if (project == null) { + return Result.empty(); + } + List candidates = new ArrayList<>(); + candidates.add(new Candidate( + project.id(), + project.slug(), + project.title(), + version == null ? "" : version.versionNumber() + )); + for (ProjectDependency dependency : installableDependencies(dependencies, includeOptionalDependencies)) { + candidates.add(new Candidate( + dependency.projectId(), + dependency.slug(), + first(dependency.title(), dependency.projectTitle(), dependency.projectId()), + dependency.versionNumber() + )); + } + return findDuplicates(installedProjects, candidates); + } + + static Result forWorldModList(List installedProjects, WorldModList list) { + if (list == null || list.mods().isEmpty()) { + return Result.empty(); + } + List candidates = list.mods().stream() + .filter(item -> item != null && item.downloadable()) + .map(item -> new Candidate( + item.projectId(), + item.slug(), + first(item.title(), item.projectId(), item.slug(), item.modId()), + item.versionNumber() + )) + .toList(); + return findDuplicates(installedProjects, candidates); + } + + private static Result findDuplicates(List installedProjects, List candidates) { + Map installedByKey = installedByKey(installedProjects); + Map duplicates = new LinkedHashMap<>(); + for (Candidate candidate : candidates) { + for (String key : candidate.keys()) { + InstalledEntry installed = installedByKey.get(key); + if (installed == null) { + continue; + } + duplicates.putIfAbsent(candidate.duplicateKey(), new Duplicate(candidate, installed)); + break; + } + } + return new Result(List.copyOf(duplicates.values())); + } + + private static Map installedByKey(List installedProjects) { + Map entries = new LinkedHashMap<>(); + if (installedProjects == null || installedProjects.isEmpty()) { + return entries; + } + for (InstalledProject installed : installedProjects) { + if (installed == null) { + continue; + } + InstalledEntry projectEntry = new InstalledEntry( + installed.projectId(), + installed.slug(), + first(installed.title(), installed.slug(), installed.projectId(), "Installed mod"), + installed.installedVersion() + ); + putEntry(entries, projectEntry); + for (InstalledProjectReference reference : bundledReferences(installed)) { + InstalledEntry referenceEntry = new InstalledEntry( + reference.projectId(), + reference.slug(), + first(reference.displayName(), reference.slug(), reference.projectId(), "Bundled mod"), + reference.versionNumber() + ); + putEntry(entries, referenceEntry); + } + } + return entries; + } + + private static void putEntry(Map entries, InstalledEntry entry) { + for (String key : entry.keys()) { + entries.putIfAbsent(key, entry); + } + } + + private static List bundledReferences(InstalledProject installed) { + if (installed == null) { + return List.of(); + } + if (!installed.bundledProjects().isEmpty()) { + return installed.bundledProjects(); + } + List references = new ArrayList<>(); + installed.dependencyProjectIds().forEach(id -> references.add(new InstalledProjectReference( + id, id, "", id, "", "", "", "MODTALE", "", "", "", "", "", "", null, null + ))); + return references; + } + + private static List installableDependencies( + List dependencies, + boolean includeOptionalDependencies + ) { + if (dependencies == null || dependencies.isEmpty()) { + return List.of(); + } + return dependencies.stream() + .filter(dependency -> dependency != null + && !dependency.isExternal() + && !dependency.isEmbedded() + && !isBlank(dependency.projectId()) + && (includeOptionalDependencies || !dependency.isOptional())) + .toList(); + } + + private static String first(String... values) { + if (values == null) { + return ""; + } + for (String value : values) { + if (!isBlank(value)) { + return value.trim(); + } + } + return ""; + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + private static Set keys(String projectId, String slug) { + Set keys = new LinkedHashSet<>(); + addKey(keys, projectId); + addKey(keys, slug); + return keys; + } + + private static void addKey(Set keys, String value) { + String key = normalizeKey(value); + if (!key.isBlank()) { + keys.add(key); + } + } + + private static String normalizeKey(String value) { + return value == null ? "" : value.trim().toLowerCase(Locale.ROOT); + } + + record Result(List duplicates) { + Result { + duplicates = duplicates == null ? List.of() : List.copyOf(duplicates); + } + + static Result empty() { + return new Result(List.of()); + } + + boolean hasDuplicates() { + return !duplicates.isEmpty(); + } + + int count() { + return duplicates.size(); + } + + String summary() { + List names = duplicates.stream() + .map(Duplicate::displayName) + .filter(name -> !name.isBlank()) + .distinct() + .limit(4) + .toList(); + int remaining = duplicates.size() - names.size(); + return String.join(", ", names) + (remaining > 0 ? ", +" + remaining + " more" : ""); + } + } + + record Duplicate(Candidate candidate, InstalledEntry installed) { + String displayName() { + return first(candidate.title(), installed.title(), candidate.projectId(), candidate.slug()); + } + } + + private record Candidate(String projectId, String slug, String title, String version) { + private Candidate { + projectId = first(projectId); + slug = first(slug); + title = first(title, projectId, slug); + version = first(version); + } + + private Set keys() { + return InstallDuplicateWarning.keys(projectId, slug); + } + + private String duplicateKey() { + return first(projectId, slug, title).toLowerCase(Locale.ROOT); + } + } + + private record InstalledEntry(String projectId, String slug, String title, String version) { + private InstalledEntry { + projectId = first(projectId); + slug = first(slug); + title = first(title, projectId, slug); + version = first(version); + } + + private Set keys() { + return InstallDuplicateWarning.keys(projectId, slug); + } + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/library/LauncherLibraryController.java b/launcher/src/main/java/net/modtale/launcher/ui/library/LauncherLibraryController.java new file mode 100644 index 00000000..99a423bb --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/library/LauncherLibraryController.java @@ -0,0 +1,1300 @@ +package net.modtale.launcher.ui.library; + +import static net.modtale.launcher.ui.common.LauncherUi.emptyState; +import static net.modtale.launcher.ui.common.LauncherUi.value; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.function.Consumer; +import java.util.function.Supplier; +import javafx.application.Platform; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.Parent; +import javafx.scene.control.Label; +import javafx.scene.control.ProgressIndicator; +import javafx.scene.input.Clipboard; +import javafx.scene.input.ClipboardContent; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import net.modtale.launcher.api.ModtaleApiClient; +import net.modtale.launcher.api.ModtaleApiException; +import net.modtale.launcher.hytale.HytaleWorldManager; +import net.modtale.launcher.hytale.HytaleWorldManager.HytaleInstalledMod; +import net.modtale.launcher.hytale.HytaleWorldManager.HytaleWorld; +import net.modtale.launcher.hytale.HytaleWorldManager.HytaleWorldConfig; +import net.modtale.launcher.install.ModInstaller; +import net.modtale.launcher.install.UpdateService; +import net.modtale.launcher.install.WorldModListInstaller; +import net.modtale.launcher.model.install.InstallResult; +import net.modtale.launcher.model.install.InstalledProject; +import net.modtale.launcher.model.install.InstalledProjectReference; +import net.modtale.launcher.model.install.UpdateCandidate; +import net.modtale.launcher.model.project.ProjectDependency; +import net.modtale.launcher.model.project.ProjectDetail; +import net.modtale.launcher.model.project.ProjectMeta; +import net.modtale.launcher.model.project.ProjectSummary; +import net.modtale.launcher.model.project.ProjectVersion; +import net.modtale.launcher.model.worldlist.CreateWorldModListRequest; +import net.modtale.launcher.model.worldlist.WorldModList; +import net.modtale.launcher.model.worldlist.WorldModListInstallResult; +import net.modtale.launcher.settings.LauncherSettings; +import net.modtale.launcher.ui.account.LauncherAccountController; +import net.modtale.launcher.ui.common.CachedImageLoader; +import net.modtale.launcher.ui.common.LauncherExternalLinks; +import net.modtale.launcher.ui.common.LauncherIcons; +import net.modtale.launcher.ui.common.LauncherView; +import net.modtale.launcher.ui.common.StatusModal; +import net.modtale.launcher.ui.feedback.LauncherFeedback; +import net.modtale.launcher.ui.settings.LauncherSettingsController; + +public final class LauncherLibraryController { + + private final ModtaleApiClient apiClient; + private final ModInstaller installer; + private final WorldModListInstaller worldModListInstaller; + private final UpdateService updateService; + private final LauncherSettingsController settingsController; + private final LauncherAccountController accountController; + private final LauncherFeedback feedback; + private final Executor executor; + private final Supplier overlayHost; + private final HytaleWorldManager worldManager = new HytaleWorldManager(); + private final VBox projectList = new VBox(10); + private final VBox projectDetail = new VBox(14); + private final VBox updatesList = new VBox(12); + private final Map projectDetails = new LinkedHashMap<>(); + private final Map projectMetadata = new LinkedHashMap<>(); + private final Map availableUpdates = new LinkedHashMap<>(); + private final Set loadingProjectIds = new LinkedHashSet<>(); + private final Set loadingMetadataIds = new LinkedHashSet<>(); + private final Set expandedModpackContents = new LinkedHashSet<>(); + private final LibraryWorldRenderer worldRenderer; + private final LibraryWorldListRenderer worldListRenderer; + private final LibraryProjectListRenderer listRenderer; + private final PostDownloadWorldModal postDownloadWorldModal; + + private List currentUpdates = List.of(); + private List installedProjects = List.of(); + private List worlds = List.of(); + private List installedMods = List.of(); + private String selectedWorldKey = ""; + private Node libraryView; + private Node updatesView; + private StackPane installLoadingOverlay; + private Label installLoadingTitle; + private Label installLoadingSubtitle; + + public LauncherLibraryController( + ModtaleApiClient apiClient, + ModInstaller installer, + WorldModListInstaller worldModListInstaller, + UpdateService updateService, + LauncherSettingsController settingsController, + LauncherAccountController accountController, + LauncherFeedback feedback, + Executor executor, + CachedImageLoader imageLoader, + Supplier overlayHost + ) { + this.apiClient = apiClient; + this.installer = installer; + this.worldModListInstaller = worldModListInstaller; + this.updateService = updateService; + this.settingsController = settingsController; + this.accountController = accountController; + this.feedback = feedback; + this.executor = executor; + this.overlayHost = overlayHost == null ? () -> null : overlayHost; + this.listRenderer = new LibraryProjectListRenderer(ignored -> { + }, this::updateSelected); + this.postDownloadWorldModal = new PostDownloadWorldModal( + this.overlayHost, + this::applyPostDownloadWorldSelection, + imageLoader + ); + this.worldListRenderer = new LibraryWorldListRenderer(imageLoader, this::selectWorld); + this.worldRenderer = new LibraryWorldRenderer( + imageLoader, + this::updateSelected, + this::ensureProjectDetailLoaded, + this::switchSelectedVersion, + this::uninstallSelected, + this::unlockModpack, + this::toggleModpackContents, + this::setModsEnabled, + this::shareWorldSnapshot, + this::createModpackFromWorld + ); + } + + public Node libraryView() { + if (libraryView == null) { + libraryView = buildLibraryView(); + renderLibrary(); + } + return libraryView; + } + + public Node updatesView() { + if (updatesView == null) { + VBox view = new VBox(12); + view.setUserData(LauncherView.UPDATES); + view.getStyleClass().addAll("view", "library-updates-view"); + view.getChildren().add(updatesList); + updatesView = view; + renderUpdates(); + } + return updatesView; + } + + public void refresh() { + renderLibrary(); + renderUpdates(); + } + + public LauncherSettings settings() { + return settingsController.settings(); + } + + public void installSelectedProject(ProjectSummary summary) { + settingsController.saveFromFields(false); + LauncherSettings settings = settingsController.settings(); + if (!confirmDuplicateInstall(InstallDuplicateWarning.forSummary(settings.getInstalledProjects(), summary))) { + return; + } + String status = "Installing " + summary + "..."; + runInstallWithOverlay(status, () -> { + accountController.ensureSignedIn(); + ProjectDetail project = detailWithVersions(summary.routeKey()); + return installer.installAndRecord(project, settings); + }, this::finishInstall); + } + + public void installSelectedProjectVersion(ProjectDetail project, ProjectVersion version, String gameVersion) { + settingsController.saveFromFields(false); + LauncherSettings settings = settingsController.settings(); + if (!confirmDuplicateInstall(InstallDuplicateWarning.forProject( + settings.getInstalledProjects(), + project, + version, + dependenciesForSettings(version, settings), + settings.isIncludeOptionalDependencies() + ))) { + return; + } + String status = "Installing " + project.title() + " " + version.versionNumber() + "..."; + runInstallWithOverlay(status, () -> { + accountController.ensureSignedIn(); + return installer.installAndRecord(project, version, settings, gameVersion); + }, this::finishInstall); + } + + public void installSelectedProjectVersion( + ProjectDetail project, + ProjectVersion version, + String gameVersion, + List selectedDependencies + ) { + settingsController.saveFromFields(false); + LauncherSettings settings = settingsController.settings(); + List dependencies = selectedDependencies == null ? List.of() : List.copyOf(selectedDependencies); + if (!confirmDuplicateInstall(InstallDuplicateWarning.forProject( + settings.getInstalledProjects(), + project, + version, + dependencies, + true + ))) { + return; + } + String status = "Installing " + project.title() + " " + version.versionNumber() + "..."; + runInstallWithOverlay(status, () -> { + accountController.ensureSignedIn(); + return installer.installAndRecord(project, version, settings, gameVersion, dependencies); + }, this::finishInstall); + } + + public void installWorldModList(String listId) { + String normalizedListId = value(listId, ""); + if (normalizedListId.isBlank()) { + feedback.showToast("Action failed", "The shared list link is missing its id."); + return; + } + + settingsController.saveFromFields(false); + feedback.runAsync("Preparing shared mod list...", () -> apiClient.getWorldModListForInstall(normalizedListId), list -> { + if (!confirmDuplicateInstall(InstallDuplicateWarning.forWorldModList( + settingsController.settings().getInstalledProjects(), + list + ))) { + return; + } + runInstallWithOverlay( + "Installing shared mod list...", + () -> worldModListInstaller.install(list, settingsController.settings()), + this::finishWorldModListInstall + ); + }); + } + + public void checkUpdates() { + settingsController.saveFromFields(false); + feedback.runAsync("Checking updates...", () -> { + accountController.ensureSignedIn(); + return updateService.checkForUpdates(settingsController.settings()); + }, updates -> { + currentUpdates = List.copyOf(updates); + availableUpdates.clear(); + updates.forEach(update -> availableUpdates.put(update.installedProject().projectId(), update)); + renderLibrary(); + renderUpdates(); + feedback.log(updates.isEmpty() ? "No updates available." : updates.size() + " updates available."); + }); + } + + private Node buildLibraryView() { + return new LibraryShellView(projectList, projectDetail, this::renderLibrary, this::checkUpdates) + .build(); + } + + private void runInstallWithOverlay(String status, Supplier work, Consumer onSuccess) { + showInstallLoadingOverlay( + value(status, "Installing...").replaceFirst("\\.\\.\\.$", ""), + "Downloading and installing files. World selection will appear next." + ); + feedback.runAsync(status, work, result -> { + try { + onSuccess.accept(result); + } catch (RuntimeException ex) { + hideInstallLoadingOverlay(); + throw ex; + } + }, ignored -> hideInstallLoadingOverlay()); + } + + private boolean confirmDuplicateInstall(InstallDuplicateWarning.Result warning) { + if (warning == null || !warning.hasDuplicates()) { + return true; + } + StatusModal.Result result = StatusModal.builder(overlayHost) + .type(StatusModal.Type.WARNING) + .title(warning.count() == 1 ? "Mod already installed" : "Mods already installed") + .message("Already installed: " + warning.summary() + + ". Continuing may replace existing files or add another copy if this install comes from a bundle or modpack.") + .actionLabel("Install Anyway") + .actionIcon(LauncherIcons.Glyph.DOWNLOAD) + .secondaryLabel("Cancel") + .showAndWait(); + return result == StatusModal.Result.PRIMARY; + } + + private List dependenciesForSettings(ProjectVersion version, LauncherSettings settings) { + if (version == null || settings == null || !settings.isIncludeDependencies()) { + return List.of(); + } + return version.dependencies(); + } + + private List dependenciesForExistingInstall( + InstalledProject installed, + ProjectVersion version, + LauncherSettings settings + ) { + if (installed != null && !installed.bundledProjects().isEmpty()) { + return installed.bundledProjects().stream() + .map(InstalledProjectReference::toDependency) + .toList(); + } + return dependenciesForSettings(version, settings); + } + + private void showInstallLoadingOverlay(String title, String subtitle) { + if (!Platform.isFxApplicationThread()) { + Platform.runLater(() -> showInstallLoadingOverlay(title, subtitle)); + return; + } + StackPane hostPane = overlayHost.get(); + if (hostPane == null) { + return; + } + if (installLoadingOverlay == null) { + installLoadingOverlay = installLoadingShell(); + hostPane.getChildren().add(installLoadingOverlay); + } else if (installLoadingOverlay.getParent() == null) { + hostPane.getChildren().add(installLoadingOverlay); + } + installLoadingTitle.setText(value(title, "Installing")); + installLoadingSubtitle.setText(value(subtitle, "Preparing the next step.")); + installLoadingOverlay.toFront(); + installLoadingOverlay.requestFocus(); + } + + private StackPane installLoadingShell() { + StackPane shell = new StackPane(); + shell.getStyleClass().add("install-loading-overlay"); + shell.setMaxSize(Double.MAX_VALUE, Double.MAX_VALUE); + shell.setFocusTraversable(true); + shell.setOnMouseClicked(event -> event.consume()); + + ProgressIndicator spinner = new ProgressIndicator(); + spinner.getStyleClass().add("install-loading-spinner"); + spinner.setMaxSize(54, 54); + spinner.setMinSize(54, 54); + + installLoadingTitle = new Label("Installing"); + installLoadingTitle.getStyleClass().add("install-loading-title"); + installLoadingSubtitle = new Label("Downloading and installing files. World selection will appear next."); + installLoadingSubtitle.getStyleClass().add("install-loading-subtitle"); + installLoadingSubtitle.setWrapText(true); + + VBox copy = new VBox(6, installLoadingTitle, installLoadingSubtitle); + copy.getStyleClass().add("install-loading-copy"); + copy.setAlignment(Pos.CENTER); + + VBox card = new VBox(18, spinner, copy); + card.getStyleClass().add("install-loading-card"); + card.setAlignment(Pos.CENTER); + card.setMaxWidth(420); + card.setMaxHeight(Region.USE_PREF_SIZE); + + shell.getChildren().add(card); + return shell; + } + + private void hideInstallLoadingOverlay() { + if (!Platform.isFxApplicationThread()) { + Platform.runLater(this::hideInstallLoadingOverlay); + return; + } + if (installLoadingOverlay == null) { + return; + } + Parent parent = installLoadingOverlay.getParent(); + if (parent instanceof StackPane stack) { + stack.getChildren().remove(installLoadingOverlay); + } + installLoadingOverlay = null; + installLoadingTitle = null; + installLoadingSubtitle = null; + } + + private void renderLibrary() { + try { + worlds = worldManager.loadWorlds(settingsController.settings()); + installedMods = worldManager.loadInstalledMods(settingsController.settings()); + } catch (RuntimeException ex) { + worlds = List.of(); + installedMods = List.of(); + feedback.log(ex.getMessage()); + } + recoverLocalInstalls(); + normalizeBundledDependencyInstalls(); + installedProjects = settingsController.settings().getInstalledProjects().stream() + .sorted(Comparator + .comparing(InstalledProject::updatedAt, Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(project -> value(project.title(), project.projectId()), String.CASE_INSENSITIVE_ORDER)) + .toList(); + + if (selectedWorldKey.isBlank() && !worlds.isEmpty()) { + selectedWorldKey = worldKey(worlds.getFirst()); + } + if (!selectedWorldKey.isBlank() + && worlds.stream().noneMatch(world -> worldKey(world).equals(selectedWorldKey))) { + selectedWorldKey = worlds.isEmpty() ? "" : worldKey(worlds.getFirst()); + } + + renderWorldRows(); + renderWorldDetail(); + ensureProjectMetadataLoaded(); + } + + private void recoverLocalInstalls() { + LibraryLocalInstallRecovery.RecoveryResult recovery = LibraryLocalInstallRecovery.recover( + settingsController.settings(), + settingsController.settings().getInstalledProjects(), + installedMods + ); + if (recovery.recoveredCount() <= 0) { + return; + } + settingsController.settings().setInstalledProjects(recovery.projects()); + settingsController.saveCurrentSettings(); + feedback.log("Recovered " + recovery.recoveredCount() + " installed mod" + + LibraryProjectSupport.plural(recovery.recoveredCount()) + " from the Hytale Mods folder."); + } + + private void normalizeBundledDependencyInstalls() { + LibraryBundledInstallNormalizer.Result result = LibraryBundledInstallNormalizer.normalize( + settingsController.settings().getInstalledProjects(), + installedMods + ); + if (result.addedChildren() <= 0) { + return; + } + settingsController.settings().setInstalledProjects(result.projects()); + settingsController.saveCurrentSettings(); + feedback.log("Promoted " + result.addedChildren() + " bundled dependenc" + + (result.addedChildren() == 1 ? "y" : "ies") + " to installed mod records."); + } + + private void ensureProjectMetadataLoaded() { + if (executor == null || apiClient == null) { + return; + } + List missing = metadataProjectIds().stream() + .filter(id -> !projectMetadata.containsKey(id)) + .filter(loadingMetadataIds::add) + .toList(); + if (missing.isEmpty()) { + return; + } + CompletableFuture.supplyAsync(() -> apiClient.getProjectMetaBatch(missing), executor) + .whenComplete((result, error) -> Platform.runLater(() -> { + Map loaded = error == null && result != null ? result : Map.of(); + for (String id : missing) { + projectMetadata.put(id, loaded.getOrDefault(id, fallbackMeta())); + loadingMetadataIds.remove(id); + } + renderWorldDetail(); + })); + } + + private List metadataProjectIds() { + LinkedHashSet ids = new LinkedHashSet<>(); + for (InstalledProject project : installedProjects) { + if (!project.projectId().isBlank() + && (project.source().isBlank() || InstalledProject.SOURCE_MODTALE.equalsIgnoreCase(project.source()))) { + ids.add(project.projectId()); + } + for (InstalledProjectReference reference : bundledReferences(project)) { + if (reference.isModtaleProject()) { + ids.add(reference.projectId()); + } + } + } + return List.copyOf(ids); + } + + private ProjectMeta fallbackMeta() { + return new ProjectMeta("", "", "", "", "", 0, "", ""); + } + + private void renderWorldRows() { + projectList.getChildren().clear(); + if (worlds.isEmpty()) { + projectList.getChildren().add(emptyState("No worlds found", "Create a Hytale world, then refresh.")); + return; + } + worlds.forEach(world -> projectList.getChildren().add(worldListRenderer.worldRow( + worldListItem(world), + worldKey(world).equals(selectedWorldKey) + ))); + } + + private void selectWorld(HytaleWorld world) { + selectedWorldKey = worldKey(world); + renderWorldRows(); + renderWorldDetail(); + } + + private void renderWorldDetail() { + Optional selected = selectedWorld(); + if (selected.isEmpty()) { + if (worlds.isEmpty()) { + projectDetail.getChildren().setAll(emptyState("No worlds found", "Create a Hytale world, then refresh the launcher.")); + } else { + projectDetail.getChildren().setAll(worldRenderer.worldDetail(null)); + } + return; + } + projectDetail.getChildren().setAll(worldRenderer.worldDetail(worldModel(selected.get()))); + } + + private LibraryWorldModel worldModel(HytaleWorld world) { + HytaleWorldConfig config = worldManager.loadConfig(world.configPath()); + List projects = installedProjects.stream() + .flatMap(project -> worldProjectModels(project, config).stream()) + .toList(); + int enabledProjects = (int) projects.stream() + .filter(project -> project.enabledCount() > 0) + .count(); + return new LibraryWorldModel( + world, + LibraryProjectSupport.worldMeta(world), + enabledProjects, + projects.size(), + projects + ); + } + + private LibraryWorldListItem worldListItem(HytaleWorld world) { + HytaleWorldConfig config = worldManager.loadConfig(world.configPath()); + int enabledProjects = 0; + int totalProjects = 0; + for (InstalledProject project : installedProjects) { + for (LibraryWorldProjectModel model : worldProjectModels(project, config)) { + totalProjects++; + if (model.enabledCount() > 0) { + enabledProjects++; + } + } + } + return new LibraryWorldListItem( + world, + LibraryProjectSupport.worldMeta(world), + enabledProjects, + totalProjects + ); + } + + private List worldProjectModels(InstalledProject installed, HytaleWorldConfig config) { + return List.of(worldProjectModel(installed, config)); + } + + private LibraryWorldProjectModel worldProjectModel(InstalledProject installed, HytaleWorldConfig config) { + List modIds = worldModIds(installed); + return worldProjectModel( + installed, + config, + modIds, + installed.isModpack() ? contentItems(installed, config) : List.of(), + LibraryWorldProjectDisplay.root(installed, projectMetadata.get(installed.projectId())) + ); + } + + private LibraryWorldProjectModel worldProjectModel( + InstalledProject installed, + HytaleWorldConfig config, + List modIds, + List contents, + LibraryWorldProjectDisplay display + ) { + int enabled = enabledCount(config, modIds); + return new LibraryWorldProjectModel( + installed, + projectDetails.get(installed.projectId()), + projectMetadata.get(installed.projectId()), + availableUpdates.get(installed.projectId()), + loadingProjectIds.contains(installed.projectId()), + modIds, + enabled, + modIds.size(), + contents, + display, + isModpackContentsCollapsed(installed) + ); + } + + private List worldModIds(InstalledProject installed) { + Set manifestIds = new LinkedHashSet<>(); + Set files = installed.files().stream() + .map(file -> java.nio.file.Path.of(file).toAbsolutePath().normalize()) + .map(java.nio.file.Path::toString) + .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); + for (HytaleInstalledMod mod : installedMods) { + String path = mod.file().toAbsolutePath().normalize().toString(); + if (files.contains(path) && mod.id() != null && !mod.id().isBlank()) { + manifestIds.add(mod.id()); + } + } + return manifestIds.isEmpty() + ? LibraryProjectSupport.projectWorldModIds(installed) + : List.copyOf(manifestIds); + } + + private Optional selectedWorld() { + if (selectedWorldKey == null || selectedWorldKey.isBlank()) { + return Optional.empty(); + } + return worlds.stream() + .filter(world -> worldKey(world).equals(selectedWorldKey)) + .findFirst(); + } + + private List contentItems(InstalledProject installed, HytaleWorldConfig config) { + if (!hasUnlockableContents(installed)) { + return List.of(); + } + + Map modsByFile = installedModsByFile(installedMods); + List references = bundledReferences(installed); + List items = new ArrayList<>(); + Set seenModIds = new LinkedHashSet<>(); + for (String file : installed.files()) { + HytaleInstalledMod mod = modsByFile.get(normalizedFileKey(file)); + if (mod == null || mod.id() == null || mod.id().isBlank() || !seenModIds.add(mod.id())) { + continue; + } + items.add(manifestContentItem(mod, matchingReference(mod, file, references), config)); + } + if (!items.isEmpty()) { + return items; + } + + for (InstalledProjectReference reference : references) { + LibraryWorldContentItem item = referenceContentItem(reference, config); + if (item != null) { + items.add(item); + } + } + return items; + } + + private LibraryWorldContentItem manifestContentItem( + HytaleInstalledMod mod, + InstalledProjectReference reference, + HytaleWorldConfig config + ) { + ProjectMeta meta = reference == null ? null : projectMetadata.get(reference.projectId()); + String author = value(meta == null ? "" : meta.author(), ""); + String title = first( + meta == null ? "" : meta.title(), + reference == null ? "" : reference.displayName(), + mod.name(), + mod.id() + ); + String classification = first( + meta == null ? "" : meta.classification(), + reference == null ? "" : reference.classification(), + "PLUGIN" + ); + String icon = first(meta == null ? "" : meta.icon(), reference == null ? "" : reference.icon()); + List modIds = List.of(mod.id()); + return new LibraryWorldContentItem( + mod.id(), + title, + contentMeta(author, value(mod.version(), ""), mod.id()), + classification, + icon, + author, + modIds, + enabledCount(config, modIds), + modIds.size(), + true + ); + } + + private LibraryWorldContentItem referenceContentItem(InstalledProjectReference reference, HytaleWorldConfig config) { + if (reference == null) { + return null; + } + ProjectMeta meta = projectMetadata.get(reference.projectId()); + String author = value(meta == null ? "" : meta.author(), ""); + String title = first(meta == null ? "" : meta.title(), reference.displayName()); + String classification = first(meta == null ? "" : meta.classification(), reference.classification(), "PLUGIN"); + String icon = first(meta == null ? "" : meta.icon(), reference.icon()); + List modIds = referenceModIds(reference, config); + return new LibraryWorldContentItem( + first(reference.projectId(), reference.externalId(), reference.id(), title), + title, + contentMeta(author, LibraryProjectSupport.childMeta(reference), modIds.isEmpty() ? "Included in pack" : ""), + classification, + icon, + author, + modIds, + enabledCount(config, modIds), + modIds.size(), + !modIds.isEmpty() + ); + } + + private List referenceModIds(InstalledProjectReference reference, HytaleWorldConfig config) { + if (reference.projectId() != null && !reference.projectId().isBlank()) { + Optional installedChild = installedProjects.stream() + .filter(project -> reference.projectId().equals(project.projectId())) + .findFirst(); + if (installedChild.isPresent()) { + return worldModIds(installedChild.get()); + } + } + String candidate = LibraryProjectSupport.referenceWorldModId(reference); + if (candidate.isBlank()) { + return List.of(); + } + boolean installed = installedMods.stream() + .anyMatch(mod -> candidate.equals(mod.id())); + if (installed || config.enabledByMod().containsKey(candidate)) { + return List.of(candidate); + } + return List.of(); + } + + private InstalledProjectReference matchingReference( + HytaleInstalledMod mod, + String file, + List references + ) { + if (mod == null || references == null || references.isEmpty()) { + return null; + } + Set modKeys = new LinkedHashSet<>(); + addNormalized(modKeys, mod.id()); + addNormalized(modKeys, mod.name()); + addNormalized(modKeys, fileName(file)); + for (InstalledProjectReference reference : references) { + Set referenceKeys = new LinkedHashSet<>(); + addNormalized(referenceKeys, LibraryProjectSupport.referenceWorldModId(reference)); + addNormalized(referenceKeys, reference.title()); + addNormalized(referenceKeys, reference.slug()); + addNormalized(referenceKeys, reference.projectId()); + addNormalized(referenceKeys, reference.externalId()); + addNormalized(referenceKeys, reference.externalFileName()); + addNormalized(referenceKeys, fileName(reference.externalFileUrl())); + addNormalized(referenceKeys, fileName(reference.cachedFileUrl())); + for (String key : modKeys) { + if (referenceKeys.contains(key)) { + return reference; + } + } + } + return null; + } + + private List bundledReferences(InstalledProject installed) { + if (!installed.bundledProjects().isEmpty()) { + return installed.bundledProjects(); + } + List references = new ArrayList<>(); + installed.dependencyProjectIds().forEach(id -> references.add(new InstalledProjectReference( + id, id, "", id, "", "", "", "MODTALE", "", "", "", "", "", "", null, null + ))); + installed.externalDependencies().forEach(id -> references.add(new InstalledProjectReference( + id, "", "", id, "", "", "", "EXTERNAL", id, "", "", "", "", "", null, null + ))); + return references; + } + + private int enabledCount(HytaleWorldConfig config, Collection modIds) { + if (config == null || modIds == null || modIds.isEmpty()) { + return 0; + } + return (int) modIds.stream() + .filter(id -> id != null && !id.isBlank()) + .filter(id -> config.enabledByMod().getOrDefault(id, false)) + .count(); + } + + private Map installedModsByFile(List mods) { + Map byFile = new LinkedHashMap<>(); + for (HytaleInstalledMod mod : mods) { + String key = normalizedFileKey(mod.file()); + if (!key.isBlank()) { + byFile.putIfAbsent(key, mod); + } + } + return byFile; + } + + private boolean hasUnlockableContents(InstalledProject installed) { + return installed.isModpack(); + } + + private void updateSelected(UpdateCandidate update) { + settingsController.saveFromFields(false); + LauncherSettings settings = settingsController.settings(); + if (!confirmDuplicateInstall(InstallDuplicateWarning.forProject( + installedProjectsExcept(settings.getInstalledProjects(), update.installedProject()), + update.project(), + update.newestVersion(), + dependenciesForExistingInstall(update.installedProject(), update.newestVersion(), settings), + true + ))) { + return; + } + runInstallWithOverlay("Updating " + update.title() + "...", () -> { + accountController.ensureSignedIn(); + return installer.switchVersionAndRecord( + update.installedProject(), + update.project(), + update.newestVersion(), + settings + ); + }, result -> { + availableUpdates.remove(update.installedProject().projectId()); + currentUpdates = List.copyOf(availableUpdates.values()); + finishInstall(result); + }); + } + + private void switchSelectedVersion(InstalledProject installed, ProjectDetail detail, ProjectVersion version) { + settingsController.saveFromFields(false); + LauncherSettings settings = settingsController.settings(); + String gameVersion = LibraryProjectSupport.installGameVersion(version, installed, settings.getGameVersion()); + if (!confirmDuplicateInstall(InstallDuplicateWarning.forProject( + installedProjectsExcept(settings.getInstalledProjects(), installed), + detail, + version, + dependenciesForExistingInstall(installed, version, settings), + true + ))) { + return; + } + runInstallWithOverlay("Switching " + installed.title() + " to " + version.versionNumber() + "...", () -> { + accountController.ensureSignedIn(); + return installer.switchVersionAndRecord(installed, detail, version, settings, gameVersion); + }, result -> { + availableUpdates.remove(installed.projectId()); + currentUpdates = List.copyOf(availableUpdates.values()); + finishInstall(result); + }); + } + + private void uninstallSelected(InstalledProject installed) { + StatusModal.Result result = StatusModal.builder(overlayHost) + .type(StatusModal.Type.WARNING) + .title("Remove Project") + .message("Remove " + value(installed.title(), "this project") + + " from the library and delete its recorded files?") + .actionLabel("Remove") + .actionIcon(LauncherIcons.Glyph.TRASH) + .secondaryLabel("Cancel") + .showAndWait(); + if (result != StatusModal.Result.PRIMARY) { + return; + } + feedback.runAsync("Removing " + installed.title() + "...", () -> { + installer.uninstallAndRecord(installed, settingsController.settings()); + return installed; + }, removed -> { + availableUpdates.remove(removed.projectId()); + projectDetails.remove(removed.projectId()); + projectMetadata.remove(removed.projectId()); + expandedModpackContents.remove(installedProjectKey(removed)); + settingsController.reloadFromStore(); + renderLibrary(); + renderUpdates(); + accountController.syncLocalSettings(); + feedback.showToast("Removed", removed.title() + " was removed from your library."); + }); + } + + private void unlockModpack(InstalledProject installed) { + if (installed == null || !installed.isModpack()) { + return; + } + int contentCount = Math.max(1, LibraryProjectSupport.contentCount(installed)); + StatusModal.Result confirmation = StatusModal.builder(overlayHost) + .type(StatusModal.Type.WARNING) + .title("Unlock Modpack") + .message("Split " + value(installed.title(), "this modpack") + " into " + + contentCount + " individually installed mod" + + LibraryProjectSupport.plural(contentCount) + + "? Updates and version changes will be per mod, not the pack.") + .actionLabel("Unlock") + .actionIcon(LauncherIcons.Glyph.EDIT) + .secondaryLabel("Cancel") + .showAndWait(); + if (confirmation != StatusModal.Result.PRIMARY) { + return; + } + + LibraryModpackUnlockConverter.Result result = LibraryModpackUnlockConverter.convert( + settingsController.settings().getInstalledProjects(), + installed, + installedMods + ); + if (result.convertedCount() <= 0) { + StatusModal.builder(overlayHost) + .type(StatusModal.Type.ERROR) + .title("Could Not Unlock") + .message("No installed mod files or modpack contents were found for " + + value(installed.title(), "this modpack") + ".") + .actionLabel("Close") + .showAndWait(); + return; + } + + settingsController.settings().setInstalledProjects(result.projects()); + settingsController.removeInstalledProjectRecord(installed.projectId()); + settingsController.saveCurrentSettings(); + availableUpdates.remove(installed.projectId()); + currentUpdates = List.copyOf(availableUpdates.values()); + projectDetails.remove(installed.projectId()); + projectMetadata.remove(installed.projectId()); + expandedModpackContents.remove(installedProjectKey(installed)); + accountController.syncLocalSettings(); + feedback.log("Unlocked " + installed.title() + " into " + result.convertedCount() + + " individual installed mod" + LibraryProjectSupport.plural(result.convertedCount()) + "."); + feedback.showToast("Modpack unlocked", "Converted " + value(installed.title(), "the modpack") + + " into individual installed mods."); + renderLibrary(); + renderUpdates(); + } + + private void toggleModpackContents(InstalledProject installed) { + if (installed == null || !installed.isModpack()) { + return; + } + String key = installedProjectKey(installed); + if (key.isBlank()) { + return; + } + if (!expandedModpackContents.add(key)) { + expandedModpackContents.remove(key); + } + renderWorldDetail(); + } + + private boolean isModpackContentsCollapsed(InstalledProject installed) { + return installed != null && !expandedModpackContents.contains(installedProjectKey(installed)); + } + + private void shareWorldSnapshot(HytaleWorld world) { + settingsController.saveFromFields(false); + feedback.runAsync("Creating " + world.name() + " share link...", () -> { + accountController.ensureSignedIn(); + return apiClient.createWorldModList(snapshotRequest(world)); + }, list -> { + copyShareUrl(list.shareUrl()); + String message = "Copied share link for " + list.title() + "."; + feedback.log(message + " " + list.shareUrl()); + feedback.showToast("Share link copied", message); + }); + } + + private void createModpackFromWorld(HytaleWorld world) { + settingsController.saveFromFields(false); + feedback.runAsync("Preparing " + world.name() + " modpack starter...", () -> { + accountController.ensureSignedIn(); + return apiClient.createWorldModList(snapshotRequest(world)); + }, list -> { + String target = "/upload?type=MODPACK&fromList=" + encodeQuery(list.id()); + LauncherExternalLinks.open(target, feedback::showToast); + feedback.log("Opening Modtale to start a modpack from " + world.name() + "."); + }); + } + + private void finishInstall(InstallResult result) { + settingsController.reloadFromStore(); + String message = "Installed " + result.installedProject().title() + " " + + result.installedProject().installedVersion() + " (" + result.installedFiles().size() + " files)."; + feedback.log(message); + feedback.showToast("Installed", result.warnings().isEmpty() + ? message + : message + " Warnings: " + String.join(" ", result.warnings())); + renderLibrary(); + renderUpdates(); + accountController.syncLocalSettings(); + showPostDownloadWorldModal( + value(result.installedProject().title(), "Installed project"), + modIdsForFiles(result.installedFiles()) + ); + } + + private void finishWorldModListInstall(WorldModListInstallResult result) { + WorldModList list = result.list(); + renderLibrary(); + String title = list.title().isBlank() ? "shared mod list" : list.title(); + String message = "Installed " + result.installedFiles().size() + " file" + + LibraryProjectSupport.plural(result.installedFiles().size()) + " from " + title + "."; + feedback.log(message); + feedback.showToast("Installed", message); + showPostDownloadWorldModal(title, modIdsForFiles(result.installedFiles())); + } + + private boolean showPostDownloadWorldModal(String title, List modIds) { + List ids = modIds == null + ? List.of() + : modIds.stream() + .filter(id -> id != null && !id.isBlank()) + .map(String::trim) + .distinct() + .toList(); + if (ids.isEmpty() || worlds.isEmpty()) { + hideInstallLoadingOverlay(); + return false; + } + hideInstallLoadingOverlay(); + return postDownloadWorldModal.show(title, ids, postDownloadWorldOptions(ids)); + } + + private List postDownloadWorldOptions(List modIds) { + return worlds.stream() + .map(world -> { + HytaleWorldConfig config = worldManager.loadConfig(world.configPath()); + int enabled = enabledCount(config, modIds); + return new PostDownloadWorldModal.WorldOption( + world, + LibraryProjectSupport.worldMeta(world), + enabled, + modIds.size(), + enabled > 0, + enabled > 0 && enabled < modIds.size() + ); + }) + .toList(); + } + + private void applyPostDownloadWorldSelection(PostDownloadWorldModal.Selection selection) { + if (selection == null || selection.worlds().isEmpty() || selection.modIds().isEmpty()) { + return; + } + feedback.runAsync("Enabling install in selected worlds...", () -> { + for (HytaleWorld world : selection.worlds()) { + worldManager.setModsEnabled(world.configPath(), selection.modIds(), true); + } + return worldManager.loadWorlds(settingsController.settings()); + }, loadedWorlds -> { + worlds = loadedWorlds; + renderWorldRows(); + renderWorldDetail(); + feedback.log("Enabled " + selection.modIds().size() + " mod" + LibraryProjectSupport.plural(selection.modIds().size()) + + " in " + selection.worlds().size() + " world" + LibraryProjectSupport.plural(selection.worlds().size()) + "."); + feedback.showToast("Worlds updated", "Enabled the install in selected worlds."); + }); + } + + private List modIdsForFiles(List files) { + if (files == null || files.isEmpty()) { + return List.of(); + } + Set fileKeys = files.stream() + .map(LauncherLibraryController::normalizedFileKey) + .filter(key -> !key.isBlank()) + .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); + if (fileKeys.isEmpty()) { + return List.of(); + } + List mods = installedMods.isEmpty() + ? worldManager.loadInstalledMods(settingsController.settings()) + : installedMods; + return mods.stream() + .filter(mod -> fileKeys.contains(normalizedFileKey(mod.file()))) + .map(HytaleInstalledMod::id) + .filter(id -> id != null && !id.isBlank()) + .distinct() + .toList(); + } + + private void ensureProjectDetailLoaded(InstalledProject installed) { + if (installed == null + || !LibraryProjectSupport.isModtaleProject(installed) + || projectDetails.containsKey(installed.projectId()) + || loadingProjectIds.contains(installed.projectId())) { + return; + } + loadingProjectIds.add(installed.projectId()); + renderWorldDetail(); + feedback.runAsync("Loading " + installed.title() + " releases...", () -> { + accountController.ensureSignedIn(); + return detailWithVersions(LibraryProjectSupport.routeKey(installed)); + }, detail -> { + loadingProjectIds.remove(installed.projectId()); + projectDetails.put(installed.projectId(), detail); + renderWorldDetail(); + }, error -> { + loadingProjectIds.remove(installed.projectId()); + renderWorldDetail(); + }); + } + + private ProjectDetail detailWithVersions(String routeKey) { + ProjectDetail project = apiClient.getProject(routeKey); + try { + List versions = apiClient.getProjectVersions(project.routeKey()); + if (!versions.isEmpty() || project.versions().isEmpty()) { + project = project.withVersions(versions); + } + } catch (RuntimeException error) { + if (project.versions().isEmpty()) { + throw error; + } + } + return project; + } + + private void setModsEnabled(HytaleWorld world, Collection modIds, boolean enabled) { + List ids = modIds.stream() + .filter(id -> id != null && !id.isBlank()) + .distinct() + .toList(); + if (ids.isEmpty()) { + return; + } + feedback.runAsync("Updating " + world.name() + "...", () -> { + worldManager.setModsEnabled(world.configPath(), ids, enabled); + return worldManager.loadWorlds(settingsController.settings()); + }, loadedWorlds -> { + worlds = loadedWorlds; + renderWorldRows(); + renderWorldDetail(); + feedback.log((enabled ? "Enabled " : "Disabled ") + ids.size() + " mod" + LibraryProjectSupport.plural(ids.size()) + + " for " + world.name() + "."); + }); + } + + private CreateWorldModListRequest snapshotRequest(HytaleWorld world) { + HytaleWorldConfig config = worldManager.loadConfig(world.configPath()); + Set enabledModIds = config.enabledByMod().entrySet().stream() + .filter(Map.Entry::getValue) + .map(Map.Entry::getKey) + .filter(id -> id != null && !id.isBlank()) + .map(String::trim) + .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); + if (enabledModIds.isEmpty()) { + throw new ModtaleApiException(world.name() + " does not have any enabled mods to share."); + } + + List availableMods = installedMods.isEmpty() + ? worldManager.loadInstalledMods(settingsController.settings()) + : installedMods; + List items = LibraryWorldSnapshotMapper.itemsFor( + enabledModIds, + installedProjects, + availableMods + ); + + return new CreateWorldModListRequest( + world.name() + " mod list", + world.name(), + settingsController.settings().getGameVersion(), + items + ); + } + + private void copyShareUrl(String shareUrl) { + if (shareUrl == null || shareUrl.isBlank()) { + return; + } + ClipboardContent content = new ClipboardContent(); + content.putString(shareUrl); + Clipboard.getSystemClipboard().setContent(content); + } + + private static String worldKey(HytaleWorld world) { + return world == null || world.directory() == null + ? "" + : world.directory().toAbsolutePath().normalize().toString(); + } + + private static String installedProjectKey(InstalledProject installed) { + return installed == null + ? "" + : first(installed.projectId(), installed.slug(), installed.title()); + } + + static List installedProjectsExcept( + List projects, + InstalledProject excluded + ) { + if (projects == null || projects.isEmpty() || excluded == null) { + return projects == null ? List.of() : projects; + } + return projects.stream() + .filter(project -> !sameInstalledProject(project, excluded)) + .toList(); + } + + private static boolean sameInstalledProject(InstalledProject left, InstalledProject right) { + if (left == null || right == null) { + return false; + } + if (!left.projectId().isBlank() && left.projectId().equals(right.projectId())) { + return true; + } + if (!left.slug().isBlank() && left.slug().equals(right.slug())) { + return true; + } + return left.equals(right); + } + + private static String contentMeta(String author, String... values) { + List parts = new ArrayList<>(); + if (author != null && !author.isBlank()) { + parts.add("by " + author.trim()); + } + if (values != null) { + for (String value : values) { + if (value != null && !value.isBlank()) { + String normalized = value.trim(); + if (!parts.contains(normalized)) { + parts.add(normalized); + } + } + } + } + return parts.isEmpty() ? "Included in pack" : String.join(" - ", parts); + } + + private static String first(String... values) { + if (values == null) { + return ""; + } + for (String value : values) { + if (value != null && !value.isBlank()) { + return value.trim(); + } + } + return ""; + } + + private static void addNormalized(Set keys, String value) { + String normalized = normalizedNameKey(value); + if (!normalized.isBlank()) { + keys.add(normalized); + } + } + + private static String fileName(String value) { + if (value == null || value.isBlank()) { + return ""; + } + String normalized = value.trim().replace('\\', '/'); + int query = normalized.indexOf('?'); + if (query >= 0) { + normalized = normalized.substring(0, query); + } + int slash = normalized.lastIndexOf('/'); + return slash >= 0 ? normalized.substring(slash + 1) : normalized; + } + + private static String normalizedNameKey(String value) { + if (value == null || value.isBlank()) { + return ""; + } + String normalized = value.trim().toLowerCase(java.util.Locale.ROOT) + .replaceFirst("(?i)\\.(jar|zip|hytale)$", ""); + return normalized.replaceAll("[^a-z0-9]+", ""); + } + + private static String normalizedFileKey(String file) { + if (file == null || file.isBlank()) { + return ""; + } + return normalizedFileKey(Path.of(file)); + } + + private static String normalizedFileKey(Path file) { + if (file == null) { + return ""; + } + return file.toAbsolutePath().normalize().toString(); + } + + private static String encodeQuery(String value) { + return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8).replace("+", "%20"); + } + + private void renderUpdates() { + updatesList.getChildren().clear(); + if (currentUpdates.isEmpty()) { + updatesList.getChildren().add(emptyState("No updates queued", "Run an update check to compare your library with the catalog.")); + return; + } + currentUpdates.forEach(update -> updatesList.getChildren().add(listRenderer.updateRow(update))); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryBundledInstallNormalizer.java b/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryBundledInstallNormalizer.java new file mode 100644 index 00000000..3520d7bb --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryBundledInstallNormalizer.java @@ -0,0 +1,297 @@ +package net.modtale.launcher.ui.library; + +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import net.modtale.launcher.hytale.HytaleWorldManager.HytaleInstalledMod; +import net.modtale.launcher.model.install.InstalledProject; +import net.modtale.launcher.model.install.InstalledProjectReference; + +final class LibraryBundledInstallNormalizer { + + private LibraryBundledInstallNormalizer() { + } + + static Result normalize(List projects, List installedMods) { + if (projects == null || projects.isEmpty()) { + return new Result(List.of(), 0); + } + Map modsByFile = installedModsByFile(installedMods); + List normalized = new ArrayList<>(); + int addedChildren = 0; + for (InstalledProject project : projects) { + if (!shouldSplit(project)) { + normalized.add(project); + continue; + } + SplitResult split = split(project, modsByFile); + normalized.add(split.parent()); + normalized.addAll(split.children()); + addedChildren += split.children().size(); + } + return new Result(dedupe(normalized), addedChildren); + } + + private static boolean shouldSplit(InstalledProject project) { + return project != null + && !project.isModpack() + && (!project.bundledProjects().isEmpty() + || !project.dependencyProjectIds().isEmpty() + || !project.externalDependencies().isEmpty()); + } + + private static SplitResult split(InstalledProject parent, Map modsByFile) { + List references = references(parent); + Map> filesByReference = new LinkedHashMap<>(); + Set dependencyFiles = new LinkedHashSet<>(); + + for (String file : parent.files()) { + HytaleInstalledMod mod = modsByFile.get(normalizedFileKey(file)); + InstalledProjectReference reference = matchingReference(mod, file, references); + if (reference == null) { + continue; + } + String key = referenceKey(reference); + filesByReference.computeIfAbsent(key, ignored -> new ArrayList<>()).add(file); + dependencyFiles.add(normalizedFileKey(file)); + } + + if (filesByReference.isEmpty()) { + return new SplitResult(parent, List.of()); + } + + List parentFiles = parent.files().stream() + .filter(file -> !dependencyFiles.contains(normalizedFileKey(file))) + .toList(); + InstalledProject strippedParent = new InstalledProject( + parent.projectId(), + parent.slug(), + parent.title(), + parent.classification(), + parent.installedVersion(), + parent.installedVersionId(), + parent.gameVersion(), + parent.installedAt(), + parent.updatedAt(), + parentFiles.isEmpty() ? parent.files() : parentFiles, + List.of(), + List.of(), + parent.source(), + InstalledProject.INSTALL_DIRECT, + false, + List.of() + ); + + List children = new ArrayList<>(); + for (InstalledProjectReference reference : references) { + List files = filesByReference.get(referenceKey(reference)); + if (files == null || files.isEmpty()) { + continue; + } + children.add(childProject(parent, reference, files)); + } + return new SplitResult(strippedParent, children); + } + + private static InstalledProject childProject( + InstalledProject parent, + InstalledProjectReference reference, + List files + ) { + String projectId = reference.isModtaleProject() + ? reference.projectId() + : externalProjectId(reference); + return new InstalledProject( + projectId, + first(reference.slug(), projectId), + reference.displayName(), + first(reference.classification(), "PLUGIN"), + first(reference.versionNumber(), parent.installedVersion()), + "", + parent.gameVersion(), + safeInstant(parent.installedAt()), + safeInstant(parent.updatedAt()), + files, + List.of(), + List.of(), + reference.isModtaleProject() ? InstalledProject.SOURCE_MODTALE : first(reference.source(), InstalledProject.SOURCE_LOCAL), + InstalledProject.INSTALL_DIRECT, + false, + List.of() + ); + } + + private static String externalProjectId(InstalledProjectReference reference) { + return "external:" + sanitize(first( + reference.externalId(), + reference.id(), + reference.externalFileName(), + reference.displayName() + )); + } + + private static List dedupe(List projects) { + Map byProjectId = new LinkedHashMap<>(); + for (InstalledProject project : projects) { + if (project != null && !project.projectId().isBlank()) { + byProjectId.putIfAbsent(project.projectId(), project); + } + } + return List.copyOf(byProjectId.values()); + } + + private static List references(InstalledProject installed) { + if (!installed.bundledProjects().isEmpty()) { + return installed.bundledProjects(); + } + List references = new ArrayList<>(); + installed.dependencyProjectIds().forEach(id -> references.add(new InstalledProjectReference( + id, id, "", id, "", "", "", "MODTALE", "", "", "", "", "", "", null, null + ))); + installed.externalDependencies().forEach(id -> references.add(new InstalledProjectReference( + id, "", "", id, "", "", "", "EXTERNAL", id, "", "", "", "", "", null, null + ))); + return references; + } + + private static InstalledProjectReference matchingReference( + HytaleInstalledMod mod, + String file, + List references + ) { + if (mod == null || references == null || references.isEmpty()) { + return null; + } + Set modKeys = new LinkedHashSet<>(); + addNormalized(modKeys, mod.id()); + addNormalized(modKeys, mod.name()); + addNormalized(modKeys, fileName(file)); + for (InstalledProjectReference reference : references) { + Set referenceKeys = new LinkedHashSet<>(); + addNormalized(referenceKeys, LibraryProjectSupport.referenceWorldModId(reference)); + addNormalized(referenceKeys, reference.title()); + addNormalized(referenceKeys, reference.slug()); + addNormalized(referenceKeys, reference.projectId()); + addNormalized(referenceKeys, reference.externalId()); + addNormalized(referenceKeys, reference.externalFileName()); + addNormalized(referenceKeys, fileName(reference.externalFileUrl())); + addNormalized(referenceKeys, fileName(reference.cachedFileUrl())); + for (String key : modKeys) { + if (referenceKeys.contains(key)) { + return reference; + } + } + } + return null; + } + + private static Map installedModsByFile(List mods) { + Map byFile = new LinkedHashMap<>(); + if (mods == null) { + return byFile; + } + for (HytaleInstalledMod mod : mods) { + String key = normalizedFileKey(mod.file()); + if (!key.isBlank()) { + byFile.putIfAbsent(key, mod); + } + } + return byFile; + } + + private static void addNormalized(Set keys, String value) { + String normalized = normalizedNameKey(value); + if (!normalized.isBlank()) { + keys.add(normalized); + } + } + + private static String referenceKey(InstalledProjectReference reference) { + return first( + reference.projectId(), + reference.externalId(), + reference.id(), + reference.slug(), + reference.displayName() + ); + } + + private static String fileName(String value) { + if (value == null || value.isBlank()) { + return ""; + } + String normalized = value.trim().replace('\\', '/'); + int query = normalized.indexOf('?'); + if (query >= 0) { + normalized = normalized.substring(0, query); + } + int slash = normalized.lastIndexOf('/'); + return slash >= 0 ? normalized.substring(slash + 1) : normalized; + } + + private static String normalizedNameKey(String value) { + if (value == null || value.isBlank()) { + return ""; + } + String normalized = value.trim().toLowerCase(Locale.ROOT) + .replaceFirst("(?i)\\.(jar|zip|hytale)$", ""); + return normalized.replaceAll("[^a-z0-9]+", ""); + } + + private static String normalizedFileKey(String file) { + if (file == null || file.isBlank()) { + return ""; + } + return normalizedFileKey(Path.of(file)); + } + + private static String normalizedFileKey(Path file) { + if (file == null) { + return ""; + } + return file.toAbsolutePath().normalize().toString(); + } + + private static String sanitize(String value) { + String normalized = value == null ? "" : value.trim(); + if (normalized.isBlank()) { + return "dependency"; + } + return normalized.replaceAll("[^A-Za-z0-9._:-]+", "-").toLowerCase(Locale.ROOT); + } + + private static Instant safeInstant(Instant instant) { + return instant == null ? Instant.EPOCH : instant; + } + + private static String first(String... values) { + if (values == null) { + return ""; + } + for (String value : values) { + if (value != null && !value.isBlank()) { + return value.trim(); + } + } + return ""; + } + + record Result(List projects, int addedChildren) { + Result { + projects = projects == null ? List.of() : List.copyOf(projects); + addedChildren = Math.max(0, addedChildren); + } + } + + private record SplitResult(InstalledProject parent, List children) { + private SplitResult { + children = children == null ? List.of() : List.copyOf(children); + } + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryDetailModel.java b/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryDetailModel.java new file mode 100644 index 00000000..db4406f9 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryDetailModel.java @@ -0,0 +1,22 @@ +package net.modtale.launcher.ui.library; + +import java.util.List; +import net.modtale.launcher.model.install.InstalledProject; +import net.modtale.launcher.model.install.UpdateCandidate; +import net.modtale.launcher.model.project.ProjectDetail; +import net.modtale.launcher.settings.LauncherSettings; + +record LibraryDetailModel( + InstalledProject installed, + ProjectDetail detail, + UpdateCandidate update, + boolean loading, + LauncherSettings settings, + List installedProjects, + List worldToggles +) { + LibraryDetailModel { + installedProjects = installedProjects == null ? List.of() : List.copyOf(installedProjects); + worldToggles = worldToggles == null ? List.of() : List.copyOf(worldToggles); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryLocalInstallRecovery.java b/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryLocalInstallRecovery.java new file mode 100644 index 00000000..0441bd42 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryLocalInstallRecovery.java @@ -0,0 +1,215 @@ +package net.modtale.launcher.ui.library; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import net.modtale.launcher.hytale.HytaleWorldManager.HytaleInstalledMod; +import net.modtale.launcher.model.install.InstalledProject; +import net.modtale.launcher.settings.LauncherSettings; + +final class LibraryLocalInstallRecovery { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private LibraryLocalInstallRecovery() { + } + + static RecoveryResult recover( + LauncherSettings settings, + List recordedProjects, + List installedMods + ) { + List recorded = recordedProjects == null ? List.of() : List.copyOf(recordedProjects); + if (installedMods == null || installedMods.isEmpty()) { + return new RecoveryResult(recorded, 0); + } + + Map byProjectId = new LinkedHashMap<>(); + Set recordedFiles = new LinkedHashSet<>(); + for (InstalledProject project : recorded) { + if (project == null || project.projectId().isBlank()) { + continue; + } + byProjectId.put(project.projectId(), project); + project.files().stream() + .map(LibraryLocalInstallRecovery::normalizedPath) + .filter(path -> !path.isBlank()) + .forEach(recordedFiles::add); + } + + int recovered = 0; + for (HytaleInstalledMod mod : installedMods) { + String fileKey = normalizedPath(mod.file()); + if (fileKey.isBlank() || recordedFiles.contains(fileKey)) { + continue; + } + InstalledProject project = fromInstalledMod(settings, mod, byProjectId.keySet()); + byProjectId.put(project.projectId(), project); + recordedFiles.add(fileKey); + recovered++; + } + return new RecoveryResult(List.copyOf(byProjectId.values()), recovered); + } + + private static InstalledProject fromInstalledMod( + LauncherSettings settings, + HytaleInstalledMod mod, + Set existingProjectIds + ) { + String title = first(mod.name(), fileBaseName(mod.file()), "Local Mod"); + String manifestId = first(mod.id(), title); + ManifestMetadata manifest = manifestMetadata(mod.file()); + String modtaleSlug = modtaleSlug(manifest.website()); + boolean modtaleProject = !modtaleSlug.isBlank(); + String projectId = uniqueProjectId(modtaleProject ? modtaleSlug : "local:" + sanitize(manifestId), existingProjectIds); + Instant modifiedAt = lastModified(mod.file()); + return new InstalledProject( + projectId, + modtaleProject ? modtaleSlug : manifestId, + title, + "PLUGIN", + first(mod.version(), "installed"), + "", + settings == null ? "" : settings.getGameVersion(), + modifiedAt, + modifiedAt, + List.of(mod.file().toString()), + List.of(), + List.of(), + modtaleProject ? InstalledProject.SOURCE_MODTALE : InstalledProject.SOURCE_LOCAL, + InstalledProject.INSTALL_DIRECT, + false, + List.of() + ); + } + + private static String uniqueProjectId(String base, Set existingProjectIds) { + String id = base == null || base.isBlank() ? "local:mod" : base; + if (!existingProjectIds.contains(id)) { + return id; + } + int suffix = 2; + while (existingProjectIds.contains(id + "-" + suffix)) { + suffix++; + } + return id + "-" + suffix; + } + + private static Instant lastModified(Path path) { + try { + return Files.getLastModifiedTime(path).toInstant(); + } catch (IOException ignored) { + return Instant.now(); + } + } + + private static ManifestMetadata manifestMetadata(Path jar) { + if (jar == null || !Files.isRegularFile(jar)) { + return new ManifestMetadata("", ""); + } + try (ZipFile zip = new ZipFile(jar.toFile())) { + ZipEntry manifest = zip.getEntry("manifest.json"); + if (manifest == null) { + return new ManifestMetadata("", ""); + } + try (InputStream input = zip.getInputStream(manifest)) { + JsonNode root = MAPPER.readTree(input); + String author = ""; + JsonNode authors = root.path("Authors"); + if (authors.isArray() && !authors.isEmpty()) { + author = authors.get(0).path("Name").asText(""); + } + return new ManifestMetadata(root.path("Website").asText(""), author); + } + } catch (IOException ignored) { + return new ManifestMetadata("", ""); + } + } + + private static String modtaleSlug(String website) { + if (website == null || website.isBlank()) { + return ""; + } + String value = website.trim(); + int marker = value.toLowerCase(Locale.ROOT).indexOf("modtale.net/mod/"); + if (marker < 0) { + return ""; + } + String slug = value.substring(marker + "modtale.net/mod/".length()); + for (String delimiter : new String[]{"/", "?", "#"}) { + int index = slug.indexOf(delimiter); + if (index >= 0) { + slug = slug.substring(0, index); + } + } + return sanitize(slug); + } + + private static String fileBaseName(Path path) { + if (path == null || path.getFileName() == null) { + return ""; + } + return path.getFileName().toString().replaceFirst("(?i)\\.jar$", ""); + } + + private static String normalizedPath(Path path) { + if (path == null) { + return ""; + } + return path.toAbsolutePath().normalize().toString(); + } + + private static String normalizedPath(String path) { + if (path == null || path.isBlank()) { + return ""; + } + return Path.of(path).toAbsolutePath().normalize().toString(); + } + + private static String sanitize(String value) { + String normalized = value == null ? "" : value.trim(); + if (normalized.isBlank()) { + return "mod"; + } + return normalized.replaceAll("[^A-Za-z0-9._:-]+", "-").toLowerCase(Locale.ROOT); + } + + private static String first(String... values) { + if (values == null) { + return ""; + } + for (String value : values) { + if (value != null && !value.isBlank()) { + return value.trim(); + } + } + return ""; + } + + record RecoveryResult(List projects, int recoveredCount) { + RecoveryResult { + projects = projects == null ? List.of() : List.copyOf(projects); + recoveredCount = Math.max(0, recoveredCount); + } + } + + private record ManifestMetadata(String website, String author) { + private ManifestMetadata { + website = website == null ? "" : website.trim(); + author = author == null ? "" : author.trim(); + } + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryModpackUnlockConverter.java b/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryModpackUnlockConverter.java new file mode 100644 index 00000000..ee986a9f --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryModpackUnlockConverter.java @@ -0,0 +1,363 @@ +package net.modtale.launcher.ui.library; + +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import net.modtale.launcher.hytale.HytaleWorldManager.HytaleInstalledMod; +import net.modtale.launcher.model.install.InstalledProject; +import net.modtale.launcher.model.install.InstalledProjectReference; + +final class LibraryModpackUnlockConverter { + + private LibraryModpackUnlockConverter() { + } + + static Result convert( + List projects, + InstalledProject modpack, + List installedMods + ) { + if (modpack == null || !modpack.isModpack()) { + return new Result(projects == null ? List.of() : projects, 0); + } + + List children = childProjects(modpack, installedMods); + if (children.isEmpty()) { + return new Result(projects == null ? List.of() : projects, 0); + } + + Map converted = new LinkedHashMap<>(); + if (projects != null) { + for (InstalledProject project : projects) { + if (project == null || sameProject(project, modpack)) { + continue; + } + converted.put(project.projectId(), project); + } + } + for (InstalledProject child : children) { + converted.merge(child.projectId(), child, LibraryModpackUnlockConverter::mergeProject); + } + return new Result(List.copyOf(converted.values()), children.size()); + } + + private static List childProjects(InstalledProject modpack, List installedMods) { + Map modsByFile = installedModsByFile(installedMods); + List references = references(modpack); + Map> filesByReference = new LinkedHashMap<>(); + Set matchedFiles = new LinkedHashSet<>(); + + for (String file : modpack.files()) { + HytaleInstalledMod mod = modsByFile.get(normalizedFileKey(file)); + InstalledProjectReference reference = matchingReference(mod, file, references); + if (reference == null) { + continue; + } + filesByReference.computeIfAbsent(referenceKey(reference), ignored -> new ArrayList<>()).add(file); + matchedFiles.add(normalizedFileKey(file)); + } + + List children = new ArrayList<>(); + for (InstalledProjectReference reference : references) { + List files = filesByReference.get(referenceKey(reference)); + if (files != null && !files.isEmpty()) { + children.add(referenceProject(modpack, reference, files)); + } + } + + for (String file : modpack.files()) { + if (matchedFiles.contains(normalizedFileKey(file))) { + continue; + } + HytaleInstalledMod mod = modsByFile.get(normalizedFileKey(file)); + children.add(mod == null + ? fileProject(modpack, file) + : manifestProject(modpack, mod, file)); + } + + if (children.isEmpty()) { + for (InstalledProjectReference reference : references) { + children.add(referenceProject(modpack, reference, List.of())); + } + } + return dedupe(children); + } + + private static InstalledProject referenceProject( + InstalledProject parent, + InstalledProjectReference reference, + List files + ) { + String projectId = reference.isModtaleProject() + ? reference.projectId() + : externalProjectId(reference); + return new InstalledProject( + projectId, + first(reference.slug(), projectId), + reference.displayName(), + first(reference.classification(), "PLUGIN"), + first(reference.versionNumber(), parent.installedVersion()), + "", + parent.gameVersion(), + safeInstant(parent.installedAt()), + safeInstant(parent.updatedAt()), + files, + List.of(), + List.of(), + reference.isModtaleProject() ? InstalledProject.SOURCE_MODTALE : first(reference.source(), InstalledProject.SOURCE_LOCAL), + InstalledProject.INSTALL_DIRECT, + false, + List.of() + ); + } + + private static InstalledProject manifestProject(InstalledProject parent, HytaleInstalledMod mod, String file) { + String id = first(mod.id(), fileProjectId(file)); + return new InstalledProject( + id, + id, + first(mod.name(), id), + "PLUGIN", + first(mod.version(), parent.installedVersion()), + "", + parent.gameVersion(), + safeInstant(parent.installedAt()), + safeInstant(parent.updatedAt()), + List.of(file), + List.of(), + List.of(), + InstalledProject.SOURCE_LOCAL, + InstalledProject.INSTALL_DIRECT, + false, + List.of() + ); + } + + private static InstalledProject fileProject(InstalledProject parent, String file) { + String fileName = fileName(file); + String id = fileProjectId(file); + return new InstalledProject( + id, + id, + first(fileName.replaceFirst("(?i)\\.jar$", ""), "Installed mod"), + "PLUGIN", + parent.installedVersion(), + "", + parent.gameVersion(), + safeInstant(parent.installedAt()), + safeInstant(parent.updatedAt()), + List.of(file), + List.of(), + List.of(), + InstalledProject.SOURCE_LOCAL, + InstalledProject.INSTALL_DIRECT, + false, + List.of() + ); + } + + private static InstalledProject mergeProject(InstalledProject existing, InstalledProject child) { + LinkedHashSet files = new LinkedHashSet<>(existing.files()); + files.addAll(child.files()); + return new InstalledProject( + existing.projectId(), + first(existing.slug(), child.slug()), + first(existing.title(), child.title()), + first(existing.classification(), child.classification()), + first(existing.installedVersion(), child.installedVersion()), + first(existing.installedVersionId(), child.installedVersionId()), + first(existing.gameVersion(), child.gameVersion()), + safeInstant(existing.installedAt()), + later(existing.updatedAt(), child.updatedAt()), + List.copyOf(files), + List.of(), + List.of(), + first(existing.source(), child.source(), InstalledProject.SOURCE_MODTALE), + InstalledProject.INSTALL_DIRECT, + false, + List.of() + ); + } + + private static List dedupe(List projects) { + Map byProjectId = new LinkedHashMap<>(); + for (InstalledProject project : projects) { + if (project != null && !project.projectId().isBlank()) { + byProjectId.merge(project.projectId(), project, LibraryModpackUnlockConverter::mergeProject); + } + } + return List.copyOf(byProjectId.values()); + } + + private static List references(InstalledProject installed) { + if (!installed.bundledProjects().isEmpty()) { + return installed.bundledProjects(); + } + List references = new ArrayList<>(); + installed.dependencyProjectIds().forEach(id -> references.add(new InstalledProjectReference( + id, id, "", id, "", "", "", "MODTALE", "", "", "", "", "", "", null, null + ))); + installed.externalDependencies().forEach(id -> references.add(new InstalledProjectReference( + id, "", "", id, "", "", "", "EXTERNAL", id, "", "", "", "", "", null, null + ))); + return references; + } + + private static InstalledProjectReference matchingReference( + HytaleInstalledMod mod, + String file, + List references + ) { + if (mod == null || references == null || references.isEmpty()) { + return null; + } + Set modKeys = new LinkedHashSet<>(); + addNormalized(modKeys, mod.id()); + addNormalized(modKeys, mod.name()); + addNormalized(modKeys, fileName(file)); + for (InstalledProjectReference reference : references) { + Set referenceKeys = new LinkedHashSet<>(); + addNormalized(referenceKeys, LibraryProjectSupport.referenceWorldModId(reference)); + addNormalized(referenceKeys, reference.title()); + addNormalized(referenceKeys, reference.slug()); + addNormalized(referenceKeys, reference.projectId()); + addNormalized(referenceKeys, reference.externalId()); + addNormalized(referenceKeys, reference.externalFileName()); + addNormalized(referenceKeys, fileName(reference.externalFileUrl())); + addNormalized(referenceKeys, fileName(reference.cachedFileUrl())); + for (String key : modKeys) { + if (referenceKeys.contains(key)) { + return reference; + } + } + } + return null; + } + + private static Map installedModsByFile(List mods) { + Map byFile = new LinkedHashMap<>(); + if (mods == null) { + return byFile; + } + for (HytaleInstalledMod mod : mods) { + String key = normalizedFileKey(mod.file()); + if (!key.isBlank()) { + byFile.putIfAbsent(key, mod); + } + } + return byFile; + } + + private static boolean sameProject(InstalledProject left, InstalledProject right) { + return left != null && right != null && left.projectId().equals(right.projectId()); + } + + private static String externalProjectId(InstalledProjectReference reference) { + return "external:" + sanitize(first( + reference.externalId(), + reference.id(), + reference.externalFileName(), + reference.displayName() + )); + } + + private static String fileProjectId(String file) { + return "local:" + sanitize(fileName(file).replaceFirst("(?i)\\.jar$", "")); + } + + private static void addNormalized(Set keys, String value) { + String normalized = normalizedNameKey(value); + if (!normalized.isBlank()) { + keys.add(normalized); + } + } + + private static String referenceKey(InstalledProjectReference reference) { + return first( + reference.projectId(), + reference.externalId(), + reference.id(), + reference.slug(), + reference.displayName() + ); + } + + private static String fileName(String value) { + if (value == null || value.isBlank()) { + return ""; + } + String normalized = value.trim().replace('\\', '/'); + int query = normalized.indexOf('?'); + if (query >= 0) { + normalized = normalized.substring(0, query); + } + int slash = normalized.lastIndexOf('/'); + return slash >= 0 ? normalized.substring(slash + 1) : normalized; + } + + private static String normalizedNameKey(String value) { + if (value == null || value.isBlank()) { + return ""; + } + String normalized = value.trim().toLowerCase(Locale.ROOT) + .replaceFirst("(?i)\\.(jar|zip|hytale)$", ""); + return normalized.replaceAll("[^a-z0-9]+", ""); + } + + private static String normalizedFileKey(String file) { + if (file == null || file.isBlank()) { + return ""; + } + return normalizedFileKey(Path.of(file)); + } + + private static String normalizedFileKey(Path file) { + if (file == null) { + return ""; + } + return file.toAbsolutePath().normalize().toString(); + } + + private static String sanitize(String value) { + String normalized = value == null ? "" : value.trim(); + if (normalized.isBlank()) { + return "mod"; + } + return normalized.replaceAll("[^A-Za-z0-9._:-]+", "-").toLowerCase(Locale.ROOT); + } + + private static Instant safeInstant(Instant instant) { + return instant == null ? Instant.EPOCH : instant; + } + + private static Instant later(Instant left, Instant right) { + Instant safeLeft = safeInstant(left); + Instant safeRight = safeInstant(right); + return safeRight.isAfter(safeLeft) ? safeRight : safeLeft; + } + + private static String first(String... values) { + if (values == null) { + return ""; + } + for (String value : values) { + if (value != null && !value.isBlank()) { + return value.trim(); + } + } + return ""; + } + + record Result(List projects, int convertedCount) { + Result { + projects = projects == null ? List.of() : List.copyOf(projects); + convertedCount = Math.max(0, convertedCount); + } + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryProjectListRenderer.java b/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryProjectListRenderer.java new file mode 100644 index 00000000..4c1230ec --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryProjectListRenderer.java @@ -0,0 +1,87 @@ +package net.modtale.launcher.ui.library; + +import static net.modtale.launcher.ui.common.LauncherUi.classificationLabel; +import static net.modtale.launcher.ui.common.LauncherUi.primaryButton; +import static net.modtale.launcher.ui.common.LauncherUi.pseudo; +import static net.modtale.launcher.ui.common.LauncherUi.setVisibleManaged; +import static net.modtale.launcher.ui.common.LauncherUi.value; + +import java.util.function.Consumer; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.ContentDisplay; +import javafx.scene.control.Label; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import net.modtale.launcher.model.install.InstalledProject; +import net.modtale.launcher.model.install.UpdateCandidate; +import net.modtale.launcher.model.project.ProjectClassification; +import net.modtale.launcher.ui.common.LauncherIcons; + +final class LibraryProjectListRenderer { + + private final Consumer selectProject; + private final Consumer updateProject; + + LibraryProjectListRenderer(Consumer selectProject, Consumer updateProject) { + this.selectProject = selectProject; + this.updateProject = updateProject; + } + + Button projectRow(InstalledProject project, boolean selected, boolean hasUpdate) { + Button button = new Button(); + button.getStyleClass().add("library-project-row"); + button.setMaxWidth(Double.MAX_VALUE); + button.setContentDisplay(ContentDisplay.GRAPHIC_ONLY); + button.setGraphic(projectRowContent(project, hasUpdate)); + button.setOnAction(event -> selectProject.accept(project.projectId())); + pseudo(button, "selected", selected); + pseudo(button, "update", hasUpdate); + return button; + } + + Node updateRow(UpdateCandidate update) { + HBox row = net.modtale.launcher.ui.common.LauncherUi.rowCard(update.title(), + classificationLabel(update.installedProject().classification()) + " - " + + value(update.currentVersion(), "current") + " -> " + value(update.newestVersionNumber(), "latest")); + Button updateButton = primaryButton("Update"); + updateButton.setGraphic(LauncherIcons.icon(LauncherIcons.Glyph.DOWNLOAD, 14)); + updateButton.setOnAction(event -> updateProject.accept(update)); + row.getChildren().add(updateButton); + return row; + } + + private Node projectRowContent(InstalledProject project, boolean hasUpdate) { + HBox row = new HBox(12); + row.getStyleClass().add("library-project-row-content"); + StackPane icon = projectIcon(project.classification(), 18); + icon.getStyleClass().add("library-project-icon"); + VBox copy = new VBox(4); + Label title = new Label(value(project.title(), "Untitled Project")); + title.getStyleClass().add("library-project-title"); + Label meta = new Label(LibraryProjectSupport.projectRowMeta(project)); + meta.getStyleClass().add("library-project-meta"); + copy.getChildren().addAll(title, meta); + HBox.setHgrow(copy, Priority.ALWAYS); + VBox status = new VBox(5); + status.setAlignment(Pos.CENTER_RIGHT); + Label version = new Label(value(project.installedVersion(), "installed")); + version.getStyleClass().add("library-version-pill"); + Label update = new Label(hasUpdate ? "Update" : ""); + update.getStyleClass().add("library-update-pill"); + setVisibleManaged(update, hasUpdate); + status.getChildren().addAll(version, update); + row.getChildren().addAll(icon, copy, status); + return row; + } + + private StackPane projectIcon(String classification, double size) { + LauncherIcons.Glyph glyph = ProjectClassification.isModpack(classification) + ? LauncherIcons.Glyph.LAYERS + : LauncherIcons.Glyph.BOX; + return new StackPane(LauncherIcons.icon(glyph, size)); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryProjectRenderer.java b/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryProjectRenderer.java new file mode 100644 index 00000000..16c891ce --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryProjectRenderer.java @@ -0,0 +1,469 @@ +package net.modtale.launcher.ui.library; + +import static net.modtale.launcher.ui.common.LauncherUi.classificationLabel; +import static net.modtale.launcher.ui.common.LauncherUi.dangerButton; +import static net.modtale.launcher.ui.common.LauncherUi.emptyState; +import static net.modtale.launcher.ui.common.LauncherUi.primaryButton; +import static net.modtale.launcher.ui.common.LauncherUi.secondaryButton; +import static net.modtale.launcher.ui.common.LauncherUi.styleCombo; +import static net.modtale.launcher.ui.common.LauncherUi.value; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import javafx.collections.FXCollections; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.Tooltip; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import javafx.geometry.Pos; +import net.modtale.launcher.hytale.HytaleWorldManager.HytaleWorld; +import net.modtale.launcher.model.install.InstalledProject; +import net.modtale.launcher.model.install.InstalledProjectReference; +import net.modtale.launcher.model.install.UpdateCandidate; +import net.modtale.launcher.model.project.ProjectClassification; +import net.modtale.launcher.model.project.ProjectDetail; +import net.modtale.launcher.model.project.ProjectVersion; +import net.modtale.launcher.ui.common.LauncherExternalLinks; +import net.modtale.launcher.ui.common.LauncherIcons; + +final class LibraryProjectRenderer { + + private final Consumer selectProject; + private final Consumer updateProject; + private final VersionSwitchHandler switchVersion; + private final Consumer uninstallProject; + private final UnlockHandler unlockProject; + private final WorldToggleHandler toggleWorldMods; + private final Consumer shareWorldSnapshot; + private final Consumer createModpackFromWorld; + private final BiConsumer toast; + + LibraryProjectRenderer( + Consumer selectProject, + Consumer updateProject, + VersionSwitchHandler switchVersion, + Consumer uninstallProject, + UnlockHandler unlockProject, + WorldToggleHandler toggleWorldMods, + Consumer shareWorldSnapshot, + Consumer createModpackFromWorld, + BiConsumer toast + ) { + this.selectProject = selectProject; + this.updateProject = updateProject; + this.switchVersion = switchVersion; + this.uninstallProject = uninstallProject; + this.unlockProject = unlockProject; + this.toggleWorldMods = toggleWorldMods; + this.shareWorldSnapshot = shareWorldSnapshot; + this.createModpackFromWorld = createModpackFromWorld; + this.toast = toast; + } + + List projectDetail(LibraryDetailModel model) { + if (model == null || model.installed() == null) { + return List.of(emptyState("No project selected", "Install a project from Browse.")); + } + InstalledProject installed = model.installed(); + List sections = new ArrayList<>(); + sections.add(detailHeader(installed, model.update())); + sections.add(versionSection(model)); + Node contents = bundledProjectsSection(model); + if (contents != null) { + sections.add(contents); + } + sections.add(worldsSection(model)); + sections.add(filesSection(installed)); + return sections; + } + + private Node detailHeader(InstalledProject installed, UpdateCandidate update) { + VBox section = new VBox(12); + section.getStyleClass().add("library-detail-hero"); + + HBox row = new HBox(14); + row.getStyleClass().add("library-detail-heading"); + StackPane icon = projectIcon(installed.classification(), 22); + icon.getStyleClass().add("library-detail-icon"); + VBox copy = new VBox(7); + Label title = new Label(value(installed.title(), "Untitled Project")); + title.getStyleClass().add("library-detail-title"); + HBox badges = new HBox(7); + badges.getStyleClass().add("library-badge-row"); + badges.getChildren().add(badge(classificationLabel(installed.classification()), "type")); + badges.getChildren().add(badge(value(installed.installedVersion(), "installed"), "version")); + if (!installed.gameVersion().isBlank()) { + badges.getChildren().add(badge(installed.gameVersion(), "game")); + } + if (installed.isModpack()) { + badges.getChildren().add(badge("Grouped", "locked")); + } + copy.getChildren().addAll(title, badges); + HBox.setHgrow(copy, Priority.ALWAYS); + + VBox actions = new VBox(8); + actions.getStyleClass().add("library-actions"); + HBox mainActions = new HBox(8); + if (update != null) { + Button updateButton = primaryButton("Update"); + updateButton.setGraphic(LauncherIcons.icon(LauncherIcons.Glyph.DOWNLOAD, 14)); + updateButton.setTooltip(new Tooltip("Install " + update.newestVersionNumber())); + updateButton.setOnAction(event -> updateProject.accept(update)); + mainActions.getChildren().add(updateButton); + } + Button uninstall = dangerButton("Remove"); + uninstall.setGraphic(LauncherIcons.icon(LauncherIcons.Glyph.TRASH, 14)); + uninstall.setMaxWidth(Double.MAX_VALUE); + uninstall.setOnAction(event -> uninstallProject.accept(installed)); + if (!mainActions.getChildren().isEmpty()) { + actions.getChildren().add(mainActions); + } + actions.getChildren().add(uninstall); + row.getChildren().addAll(icon, copy, actions); + + HBox facts = new HBox(10); + facts.getStyleClass().add("library-facts"); + facts.getChildren().addAll( + fact("Installed", LibraryProjectSupport.dateLabel(installed.installedAt())), + fact("Updated", LibraryProjectSupport.dateLabel(installed.updatedAt())), + fact("Files", Integer.toString(installed.files().size())), + fact("Contents", Integer.toString(LibraryProjectSupport.contentCount(installed))) + ); + section.getChildren().addAll(row, facts); + return section; + } + + private Node versionSection(LibraryDetailModel model) { + InstalledProject installed = model.installed(); + ProjectDetail detail = model.detail(); + VBox section = detailSection("Versions"); + if (detail == null) { + HBox loading = new HBox(10); + loading.getStyleClass().add("library-inline-panel"); + loading.setAlignment(Pos.CENTER_LEFT); + Label meta = new Label(model.loading() ? "Loading release metadata" : "Release metadata not loaded"); + meta.getStyleClass().add("library-muted-text"); + Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + Button load = secondaryButton(model.loading() ? "Loading" : "Load"); + load.setDisable(true); + loading.getChildren().addAll(meta, spacer, load); + section.getChildren().add(loading); + return section; + } + + List choices = LibraryProjectSupport.versionChoices( + detail.versions(), + installed, + model.settings().getGameVersion() + ); + ComboBox versions = new ComboBox<>(FXCollections.observableArrayList(choices)); + styleCombo(versions); + choices.stream() + .filter(choice -> LibraryProjectSupport.sameVersion(installed, choice.version())) + .findFirst() + .ifPresentOrElse(versions::setValue, () -> { + if (!choices.isEmpty()) { + versions.setValue(choices.getFirst()); + } + }); + + Button switchButton = primaryButton("Switch"); + switchButton.setGraphic(LauncherIcons.icon(LauncherIcons.Glyph.DOWNLOAD, 14)); + switchButton.setOnAction(event -> { + LibraryVersionChoice choice = versions.getValue(); + if (choice != null) { + switchVersion.switchVersion(installed, detail, choice.version()); + } + }); + Runnable updateSwitchState = () -> { + LibraryVersionChoice choice = versions.getValue(); + switchButton.setDisable(choice == null || LibraryProjectSupport.sameVersion(installed, choice.version())); + }; + versions.valueProperty().addListener((observable, previous, next) -> updateSwitchState.run()); + updateSwitchState.run(); + + HBox row = new HBox(10, versions, switchButton); + row.getStyleClass().add("library-version-control"); + HBox.setHgrow(versions, Priority.ALWAYS); + section.getChildren().add(row); + return section; + } + + private Node bundledProjectsSection(LibraryDetailModel model) { + InstalledProject installed = model.installed(); + if (!installed.isModpack() + && installed.bundledProjects().isEmpty() + && installed.dependencyProjectIds().isEmpty() + && installed.externalDependencies().isEmpty()) { + return null; + } + VBox section = detailSection(installed.isModpack() ? "Modpack Contents" : "Bundled Dependencies"); + HBox header = new HBox(10); + header.getStyleClass().add("library-inline-panel"); + header.setAlignment(Pos.CENTER_LEFT); + Label status = new Label("Grouped"); + status.getStyleClass().add("library-status-locked"); + int contentCount = LibraryProjectSupport.contentCount(installed); + Label count = new Label(contentCount + " item" + LibraryProjectSupport.plural(contentCount)); + count.getStyleClass().add("library-muted-text"); + Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + Button lock = secondaryButton("Unlock"); + lock.setGraphic(LauncherIcons.icon(LauncherIcons.Glyph.EDIT, 14)); + lock.setOnAction(event -> unlockProject.unlock(installed)); + header.getChildren().addAll(status, count, spacer, lock); + section.getChildren().add(header); + + VBox rows = new VBox(8); + rows.getStyleClass().add("library-bundled-list"); + if (!installed.bundledProjects().isEmpty()) { + installed.bundledProjects().forEach(reference -> + rows.getChildren().add(bundledProjectRow(model, reference))); + } else if (!installed.dependencyProjectIds().isEmpty() || !installed.externalDependencies().isEmpty()) { + installed.dependencyProjectIds().forEach(id -> rows.getChildren().add(legacyBundledRow(model, id, true))); + installed.externalDependencies().forEach(id -> rows.getChildren().add(legacyBundledRow(model, id, false))); + } else { + installed.files().forEach(file -> rows.getChildren().add(fileContentRow(installed, file))); + } + section.getChildren().add(rows); + return section; + } + + private Node bundledProjectRow(LibraryDetailModel model, InstalledProjectReference reference) { + InstalledProject parent = model.installed(); + HBox row = new HBox(10); + row.getStyleClass().add("library-bundled-row-locked"); + row.setAlignment(Pos.CENTER_LEFT); + StackPane icon = new StackPane(LauncherIcons.icon(reference.isModtaleProject() + ? LauncherIcons.Glyph.BOX + : LauncherIcons.Glyph.EXTERNAL_LINK, 15)); + icon.getStyleClass().add("library-child-icon"); + VBox copy = new VBox(3); + Label title = new Label(reference.displayName()); + title.getStyleClass().add("library-child-title"); + Label meta = new Label(LibraryProjectSupport.childMeta(reference)); + meta.getStyleClass().add("library-child-meta"); + copy.getChildren().addAll(title, meta); + HBox.setHgrow(copy, Priority.ALWAYS); + + InstalledProject installedChild = reference.projectId().isBlank() + ? null + : installedByProjectId(model.installedProjects(), reference.projectId()).orElse(null); + Button action = childActionButton(parent, reference, installedChild); + row.getChildren().addAll(icon, copy, action); + return row; + } + + private Node legacyBundledRow(LibraryDetailModel model, String id, boolean modtale) { + InstalledProjectReference reference = new InstalledProjectReference( + id, + modtale ? id : "", + "", + id, + "", + "", + "", + modtale ? "MODTALE" : "EXTERNAL", + modtale ? "" : id, + "", + "", + "", + "", + "", + null, + null + ); + return bundledProjectRow(model, reference); + } + + private Node fileContentRow(InstalledProject parent, String file) { + HBox row = new HBox(10); + row.getStyleClass().add("library-bundled-row-locked"); + row.setAlignment(Pos.CENTER_LEFT); + StackPane icon = new StackPane(LauncherIcons.icon(LauncherIcons.Glyph.FILE_CODE, 15)); + icon.getStyleClass().add("library-child-icon"); + VBox copy = new VBox(3); + Label title = new Label(Path.of(file).getFileName().toString()); + title.getStyleClass().add("library-child-title"); + Label meta = new Label("Installed file"); + meta.getStyleClass().add("library-child-meta"); + copy.getChildren().addAll(title, meta); + HBox.setHgrow(copy, Priority.ALWAYS); + Button action = secondaryButton("Grouped"); + action.setDisable(true); + row.getChildren().addAll(icon, copy, action); + return row; + } + + private Button childActionButton( + InstalledProject parent, + InstalledProjectReference reference, + InstalledProject installedChild + ) { + Button action; + if (installedChild != null) { + action = secondaryButton("Select"); + action.setOnAction(event -> selectProject.accept(installedChild.projectId())); + return action; + } + if (reference.isModtaleProject()) { + action = secondaryButton("Included"); + action.setDisable(true); + return action; + } + action = secondaryButton("Open"); + action.setOnAction(event -> LauncherExternalLinks.open( + value(reference.externalUrl(), reference.cachedFileUrl()), + toast + )); + action.setDisable(reference.externalUrl().isBlank() && reference.cachedFileUrl().isBlank()); + return action; + } + + private Node worldsSection(LibraryDetailModel model) { + VBox section = detailSection("Worlds"); + if (model.worldToggles().isEmpty()) { + section.getChildren().add(emptyState("No world controls", "Create a Hytale world or refresh this install record.")); + return section; + } + VBox rows = new VBox(8); + rows.getStyleClass().add("library-world-list"); + for (LibraryWorldToggle toggle : model.worldToggles()) { + rows.getChildren().add(worldToggleRow(toggle)); + } + section.getChildren().add(rows); + return section; + } + + private Node worldToggleRow(LibraryWorldToggle worldToggle) { + LibraryToggleBox toggle = new LibraryToggleBox(); + toggle.setSelected(worldToggle.selected()); + toggle.setIndeterminate(worldToggle.indeterminate()); + toggle.setOnAction(() -> toggleWorldMods.setEnabled( + worldToggle.world(), + worldToggle.modIds(), + toggle.isSelected() + )); + + VBox copy = new VBox(3); + Label title = new Label(worldToggle.world().name()); + title.getStyleClass().add("library-world-title"); + Label meta = new Label(worldToggle.enabledCount() + "/" + worldToggle.totalCount() + " enabled - " + worldToggle.meta()); + meta.getStyleClass().add("library-world-meta"); + copy.getChildren().addAll(title, meta); + HBox.setHgrow(copy, Priority.ALWAYS); + + Button share = secondaryButton("Share"); + share.getStyleClass().add("library-world-share"); + share.setGraphic(LauncherIcons.icon(LauncherIcons.Glyph.SHARE_2, 13)); + share.setTooltip(new Tooltip("Copy a share link for this world's enabled mods")); + share.setOnAction(event -> shareWorldSnapshot.accept(worldToggle.world())); + + Button pack = secondaryButton("Make Pack"); + pack.getStyleClass().add("library-world-pack"); + pack.setGraphic(LauncherIcons.icon(LauncherIcons.Glyph.PACKAGE_PLUS, 13)); + pack.setTooltip(new Tooltip("Start a Modtale modpack from this world's enabled mods")); + pack.setOnAction(event -> createModpackFromWorld.accept(worldToggle.world())); + + HBox row = new HBox(10, toggle, copy, share, pack); + row.getStyleClass().add("library-world-row"); + row.setAlignment(Pos.CENTER_LEFT); + return row; + } + + private Node filesSection(InstalledProject installed) { + VBox section = detailSection("Files"); + if (installed.files().isEmpty()) { + section.getChildren().add(emptyState("No files recorded", "Switching versions will refresh this install record.")); + return section; + } + VBox files = new VBox(6); + files.getStyleClass().add("library-file-list"); + installed.files().stream() + .limit(8) + .forEach(file -> files.getChildren().add(fileRow(file))); + if (installed.files().size() > 8) { + Label remaining = new Label("+" + (installed.files().size() - 8) + " more"); + remaining.getStyleClass().add("library-muted-text"); + files.getChildren().add(remaining); + } + section.getChildren().add(files); + return section; + } + + private Node fileRow(String file) { + HBox row = new HBox(8); + row.getStyleClass().add("library-file-row"); + row.setAlignment(Pos.CENTER_LEFT); + Label name = new Label(Path.of(file).getFileName().toString()); + name.getStyleClass().add("library-file-name"); + Label path = new Label(file); + path.getStyleClass().add("library-file-path"); + HBox.setHgrow(path, Priority.ALWAYS); + row.getChildren().addAll(LauncherIcons.icon(LauncherIcons.Glyph.FILE_CODE, 14), name, path); + return row; + } + + private VBox detailSection(String title) { + VBox section = new VBox(10); + section.getStyleClass().add("library-detail-section"); + Label label = new Label(title); + label.getStyleClass().add("library-section-title"); + section.getChildren().add(label); + return section; + } + + private Node fact(String label, String value) { + VBox fact = new VBox(3); + fact.getStyleClass().add("library-fact"); + Label title = new Label(label); + title.getStyleClass().add("library-fact-label"); + Label body = new Label(value); + body.getStyleClass().add("library-fact-value"); + fact.getChildren().addAll(title, body); + HBox.setHgrow(fact, Priority.ALWAYS); + return fact; + } + + private Node badge(String text, String tone) { + Label label = new Label(text); + label.getStyleClass().addAll("library-badge", "library-badge-" + tone); + return label; + } + + private StackPane projectIcon(String classification, double size) { + LauncherIcons.Glyph glyph = ProjectClassification.isModpack(classification) + ? LauncherIcons.Glyph.LAYERS + : LauncherIcons.Glyph.BOX; + return new StackPane(LauncherIcons.icon(glyph, size)); + } + + private Optional installedByProjectId(List installedProjects, String projectId) { + return installedProjects.stream() + .filter(project -> project.projectId().equals(projectId)) + .findFirst(); + } + + interface VersionSwitchHandler { + void switchVersion(InstalledProject installed, ProjectDetail detail, ProjectVersion version); + } + + interface UnlockHandler { + void unlock(InstalledProject installed); + } + + interface WorldToggleHandler { + void setEnabled(HytaleWorld world, List modIds, boolean enabled); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryProjectSupport.java b/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryProjectSupport.java new file mode 100644 index 00000000..b1fbc9cb --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryProjectSupport.java @@ -0,0 +1,221 @@ +package net.modtale.launcher.ui.library; + +import static net.modtale.launcher.ui.common.LauncherUi.classificationLabel; +import static net.modtale.launcher.ui.common.LauncherUi.value; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import net.modtale.launcher.hytale.HytaleWorldManager.HytaleWorld; +import net.modtale.launcher.install.UpdateService; +import net.modtale.launcher.model.install.InstalledProject; +import net.modtale.launcher.model.install.InstalledProjectReference; +import net.modtale.launcher.model.project.ProjectVersion; + +final class LibraryProjectSupport { + + private static final DateTimeFormatter SHORT_TIME = DateTimeFormatter.ofPattern("MMM d, yyyy") + .withZone(ZoneId.systemDefault()); + + private LibraryProjectSupport() { + } + + static String projectRowMeta(InstalledProject project) { + List parts = new ArrayList<>(); + parts.add(classificationLabel(project.classification())); + if (!project.gameVersion().isBlank()) { + parts.add(project.gameVersion()); + } + if (project.isModpack()) { + parts.add("Grouped"); + } + return String.join(" - ", parts); + } + + static String childMeta(InstalledProjectReference reference) { + List parts = new ArrayList<>(); + parts.add(reference.isModtaleProject() ? "Modtale" : value(reference.source(), "External")); + if (!reference.versionNumber().isBlank()) { + parts.add(reference.versionNumber()); + } + if (Boolean.TRUE.equals(reference.optional())) { + parts.add("optional"); + } + return String.join(" - ", parts); + } + + static int contentCount(InstalledProject installed) { + if (!installed.bundledProjects().isEmpty()) { + return installed.bundledProjects().size(); + } + int metadataCount = installed.dependencyProjectIds().size() + installed.externalDependencies().size(); + if (metadataCount > 0) { + return metadataCount; + } + return installed.isModpack() ? installed.files().size() : 0; + } + + static List projectWorldModIds(InstalledProject installed) { + if (installed.isModpack()) { + return modpackChildren(installed); + } + String id = projectModId(installed); + return id.isBlank() ? List.of() : List.of(id); + } + + static List modpackChildren(InstalledProject modpack) { + LinkedHashSet children = new LinkedHashSet<>(); + if (!modpack.bundledProjects().isEmpty()) { + for (InstalledProjectReference reference : modpack.bundledProjects()) { + String id = referenceWorldModId(reference); + if (!id.isBlank()) { + children.add(id); + } + } + } else { + children.addAll(modpack.dependencyProjectIds()); + children.addAll(modpack.externalDependencies()); + } + if (children.isEmpty()) { + String fallback = projectModId(modpack); + if (!fallback.isBlank()) { + children.add(fallback); + } + } + return List.copyOf(children); + } + + static String routeKey(InstalledProject installed) { + return installed.slug() == null || installed.slug().isBlank() ? installed.projectId() : installed.slug(); + } + + static boolean isModtaleProject(InstalledProject installed) { + if (installed == null) { + return false; + } + String source = installed.source() == null ? "" : installed.source().trim(); + return source.isBlank() || InstalledProject.SOURCE_MODTALE.equalsIgnoreCase(source); + } + + static String dateLabel(Instant instant) { + if (instant == null || Instant.EPOCH.equals(instant)) { + return "Unknown"; + } + return SHORT_TIME.format(instant); + } + + static String worldMeta(HytaleWorld world) { + List parts = new ArrayList<>(); + if (!world.patchline().isBlank()) { + parts.add(world.patchline()); + } + if (!world.updatedAt().equals(Instant.EPOCH)) { + parts.add(SHORT_TIME.format(world.updatedAt())); + } + return parts.isEmpty() ? "World save" : String.join(" - ", parts); + } + + static List versionChoices( + List versions, + InstalledProject installed, + String fallbackGameVersion + ) { + String gameVersion = value(installed == null ? "" : installed.gameVersion(), fallbackGameVersion); + return versions.stream() + .sorted(Comparator + .comparing(LibraryProjectSupport::releaseInstant, Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(ProjectVersion::versionNumber, Comparator.nullsLast(Comparator.reverseOrder()))) + .map(version -> new LibraryVersionChoice(version, versionLabel(version, installed, gameVersion))) + .toList(); + } + + static String installGameVersion(ProjectVersion version, InstalledProject installed, String fallbackGameVersion) { + String preferred = value(installed == null ? "" : installed.gameVersion(), fallbackGameVersion); + if (version == null) { + return preferred; + } + if (!preferred.isBlank() && version.supportsGameVersion(preferred)) { + return preferred; + } + return version.gameVersions().stream() + .filter(gameVersion -> gameVersion != null && !gameVersion.isBlank()) + .map(String::trim) + .findFirst() + .orElse(preferred); + } + + static boolean sameVersion(InstalledProject installed, ProjectVersion version) { + return installed != null && version != null && UpdateService.sameVersion(installed, version); + } + + static String plural(int count) { + return count == 1 ? "" : "s"; + } + + static String referenceWorldModId(InstalledProjectReference reference) { + if (reference.slug() != null && reference.slug().contains(":")) { + return reference.slug(); + } + if (reference.projectId() != null && reference.projectId().contains(":")) { + return reference.projectId(); + } + if (reference.externalId() != null && !reference.externalId().isBlank()) { + return reference.externalId(); + } + return value(reference.slug(), value(reference.projectId(), "")).trim(); + } + + private static String projectModId(InstalledProject project) { + if (project.slug() != null && project.slug().contains(":")) { + return project.slug(); + } + if (project.projectId() != null && project.projectId().contains(":")) { + return project.projectId(); + } + return value(project.slug(), value(project.projectId(), "")).trim(); + } + + private static String versionLabel(ProjectVersion version, InstalledProject installed, String gameVersion) { + List parts = new ArrayList<>(); + parts.add(value(version.versionNumber(), "Untitled")); + if (sameVersion(installed, version)) { + parts.add("installed"); + } + if (version.channel() != null && !version.channel().isBlank()) { + parts.add(version.channel().toLowerCase(java.util.Locale.ROOT)); + } + String supportedGameVersions = supportedGameVersions(version, gameVersion); + if (!supportedGameVersions.isBlank()) { + parts.add(supportedGameVersions); + } + return String.join(" - ", parts); + } + + private static String supportedGameVersions(ProjectVersion version, String preferredGameVersion) { + if (version == null || version.gameVersions().isEmpty()) { + return ""; + } + if (preferredGameVersion != null + && !preferredGameVersion.isBlank() + && version.supportsGameVersion(preferredGameVersion)) { + return preferredGameVersion; + } + return String.join(", ", version.gameVersions()); + } + + private static Instant releaseInstant(ProjectVersion version) { + if (version.releaseDate() == null || version.releaseDate().isBlank()) { + return null; + } + try { + return Instant.parse(version.releaseDate()); + } catch (DateTimeParseException ignored) { + return null; + } + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryShellView.java b/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryShellView.java new file mode 100644 index 00000000..7e28591d --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryShellView.java @@ -0,0 +1,76 @@ +package net.modtale.launcher.ui.library; + +import static net.modtale.launcher.ui.common.LauncherUi.primaryButton; +import static net.modtale.launcher.ui.common.LauncherUi.secondaryButton; + +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.VBox; +import net.modtale.launcher.ui.common.LauncherIcons; +import net.modtale.launcher.ui.common.LauncherView; + +final class LibraryShellView { + + private final VBox projectList; + private final VBox projectDetail; + private final Runnable refresh; + private final Runnable checkUpdates; + + LibraryShellView( + VBox projectList, + VBox projectDetail, + Runnable refresh, + Runnable checkUpdates + ) { + this.projectList = projectList; + this.projectDetail = projectDetail; + this.refresh = refresh; + this.checkUpdates = checkUpdates; + } + + Node build() { + VBox root = new VBox(18); + root.setUserData(LauncherView.LIBRARY); + root.getStyleClass().addAll("view", "library-view"); + root.getChildren().addAll(header(), content()); + return root; + } + + private Node header() { + HBox header = new HBox(16); + header.getStyleClass().add("library-header"); + VBox copy = new VBox(5); + Label title = new Label("World Libraries"); + title.getStyleClass().add("library-title"); + copy.getChildren().add(title); + Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + + Button refreshButton = secondaryButton("Refresh"); + refreshButton.setGraphic(LauncherIcons.icon(LauncherIcons.Glyph.REFRESH_CW, 15)); + refreshButton.setOnAction(event -> refresh.run()); + Button updatesButton = primaryButton("Check Updates"); + updatesButton.setGraphic(LauncherIcons.icon(LauncherIcons.Glyph.REFRESH_CW, 15)); + updatesButton.setOnAction(event -> checkUpdates.run()); + header.getChildren().addAll(copy, spacer, refreshButton, updatesButton); + return header; + } + + private Node content() { + HBox content = new HBox(18); + content.getStyleClass().add("library-content"); + VBox projectsPane = new VBox(14); + projectsPane.getStyleClass().add("library-projects-pane"); + projectsPane.getChildren().add(projectList); + VBox.setVgrow(projectList, Priority.ALWAYS); + + projectDetail.getStyleClass().add("library-detail-pane"); + HBox.setHgrow(projectDetail, Priority.ALWAYS); + content.getChildren().addAll(projectsPane, projectDetail); + return content; + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryToggleBox.java b/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryToggleBox.java new file mode 100644 index 00000000..63d6f127 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryToggleBox.java @@ -0,0 +1,80 @@ +package net.modtale.launcher.ui.library; + +import javafx.css.PseudoClass; +import javafx.scene.control.Tooltip; +import javafx.scene.input.KeyCode; +import javafx.scene.layout.StackPane; +import net.modtale.launcher.ui.common.LauncherIcons; + +final class LibraryToggleBox extends StackPane { + + private static final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected"); + private static final PseudoClass INDETERMINATE = PseudoClass.getPseudoClass("indeterminate"); + + private boolean selected; + private boolean indeterminate; + private Runnable action = () -> { + }; + + LibraryToggleBox() { + getStyleClass().add("library-toggle-box"); + setFocusTraversable(true); + setOnMouseClicked(event -> { + activate(); + event.consume(); + }); + setOnKeyPressed(event -> { + if (event.getCode() == KeyCode.SPACE || event.getCode() == KeyCode.ENTER) { + activate(); + event.consume(); + } + }); + updateState(); + } + + boolean isSelected() { + return selected; + } + + void setSelected(boolean selected) { + this.selected = selected; + updateState(); + } + + void setIndeterminate(boolean indeterminate) { + this.indeterminate = indeterminate; + updateState(); + } + + void setTooltip(Tooltip tooltip) { + if (tooltip != null) { + Tooltip.install(this, tooltip); + } + } + + void setOnAction(Runnable action) { + this.action = action == null ? () -> { + } : action; + } + + private void activate() { + if (isDisabled()) { + return; + } + selected = !selected; + indeterminate = false; + updateState(); + action.run(); + } + + private void updateState() { + pseudoClassStateChanged(SELECTED, selected); + pseudoClassStateChanged(INDETERMINATE, indeterminate); + getChildren().clear(); + if (indeterminate) { + getChildren().add(LauncherIcons.icon(LauncherIcons.Glyph.MINUS, 13)); + } else if (selected) { + getChildren().add(LauncherIcons.icon(LauncherIcons.Glyph.CHECK, 13)); + } + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryVersionChoice.java b/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryVersionChoice.java new file mode 100644 index 00000000..9ebc1893 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryVersionChoice.java @@ -0,0 +1,10 @@ +package net.modtale.launcher.ui.library; + +import net.modtale.launcher.model.project.ProjectVersion; + +record LibraryVersionChoice(ProjectVersion version, String label) { + @Override + public String toString() { + return label; + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryWorldContentItem.java b/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryWorldContentItem.java new file mode 100644 index 00000000..755183f7 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryWorldContentItem.java @@ -0,0 +1,52 @@ +package net.modtale.launcher.ui.library; + +import java.util.List; + +record LibraryWorldContentItem( + String id, + String title, + String meta, + String classification, + String icon, + String author, + List modIds, + int enabledCount, + int totalCount, + boolean toggleable +) { + LibraryWorldContentItem { + id = value(id); + title = value(title, "Installed content"); + meta = value(meta); + classification = value(classification); + icon = value(icon); + author = value(author); + modIds = modIds == null + ? List.of() + : modIds.stream() + .filter(modId -> modId != null && !modId.isBlank()) + .map(String::trim) + .distinct() + .toList(); + totalCount = Math.max(totalCount, modIds.size()); + enabledCount = Math.max(0, Math.min(enabledCount, totalCount)); + toggleable = toggleable && !modIds.isEmpty(); + } + + boolean selected() { + return toggleable && totalCount > 0 && enabledCount == totalCount; + } + + boolean indeterminate() { + return toggleable && enabledCount > 0 && enabledCount < totalCount; + } + + private static String value(String value) { + return value == null ? "" : value.trim(); + } + + private static String value(String value, String fallback) { + String normalized = value(value); + return normalized.isBlank() ? fallback : normalized; + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryWorldListItem.java b/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryWorldListItem.java new file mode 100644 index 00000000..5282657f --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryWorldListItem.java @@ -0,0 +1,16 @@ +package net.modtale.launcher.ui.library; + +import net.modtale.launcher.hytale.HytaleWorldManager.HytaleWorld; + +record LibraryWorldListItem( + HytaleWorld world, + String meta, + int enabledProjectCount, + int totalProjectCount +) { + LibraryWorldListItem { + meta = meta == null ? "" : meta.trim(); + totalProjectCount = Math.max(0, totalProjectCount); + enabledProjectCount = Math.max(0, Math.min(enabledProjectCount, totalProjectCount)); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryWorldListRenderer.java b/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryWorldListRenderer.java new file mode 100644 index 00000000..9060cd72 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryWorldListRenderer.java @@ -0,0 +1,100 @@ +package net.modtale.launcher.ui.library; + +import static net.modtale.launcher.ui.common.LauncherUi.pseudo; + +import java.util.function.Consumer; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.ContentDisplay; +import javafx.scene.control.Label; +import javafx.scene.image.ImageView; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import javafx.scene.shape.Rectangle; +import net.modtale.launcher.hytale.HytaleWorldManager.HytaleWorld; +import net.modtale.launcher.ui.common.CachedImageLoader; +import net.modtale.launcher.ui.common.LauncherIcons; + +final class LibraryWorldListRenderer { + + private static final double WORLD_ICON_SIZE = 42; + + private final CachedImageLoader imageLoader; + private final Consumer selectWorld; + + LibraryWorldListRenderer(CachedImageLoader imageLoader, Consumer selectWorld) { + this.imageLoader = imageLoader; + this.selectWorld = selectWorld; + } + + Button worldRow(LibraryWorldListItem item, boolean selected) { + Button button = new Button(); + button.getStyleClass().addAll("library-project-row", "library-world-tab"); + button.setMaxWidth(Double.MAX_VALUE); + button.setContentDisplay(ContentDisplay.GRAPHIC_ONLY); + button.setGraphic(worldRowContent(item)); + button.setOnAction(event -> selectWorld.accept(item.world())); + pseudo(button, "selected", selected); + return button; + } + + private Node worldRowContent(LibraryWorldListItem item) { + HytaleWorld world = item.world(); + HBox row = new HBox(12); + row.getStyleClass().add("library-project-row-content"); + StackPane icon = worldIcon(world); + + VBox copy = new VBox(4); + Label title = new Label(world.name()); + title.getStyleClass().add("library-project-title"); + Label meta = new Label(item.meta()); + meta.getStyleClass().add("library-project-meta"); + copy.getChildren().addAll(title, meta); + HBox.setHgrow(copy, Priority.ALWAYS); + + VBox status = new VBox(5); + status.setAlignment(Pos.CENTER_RIGHT); + Label enabled = new Label(item.enabledProjectCount() + "/" + item.totalProjectCount()); + enabled.getStyleClass().add("library-version-pill"); + Label label = new Label("Enabled"); + label.getStyleClass().add("library-world-tab-caption"); + status.getChildren().addAll(enabled, label); + + row.getChildren().addAll(icon, copy, status); + return row; + } + + private StackPane worldIcon(HytaleWorld world) { + StackPane shell = new StackPane(); + shell.getStyleClass().add("library-project-icon"); + shell.setMinSize(WORLD_ICON_SIZE, WORLD_ICON_SIZE); + shell.setPrefSize(WORLD_ICON_SIZE, WORLD_ICON_SIZE); + shell.setMaxSize(WORLD_ICON_SIZE, WORLD_ICON_SIZE); + + String preview = world.previewImage(); + if (!preview.isBlank() && imageLoader != null) { + ImageView image = new ImageView(); + image.setFitWidth(WORLD_ICON_SIZE); + image.setFitHeight(WORLD_ICON_SIZE); + image.setPreserveRatio(false); + image.setSmooth(true); + image.setMouseTransparent(true); + image.setClip(roundedClip(WORLD_ICON_SIZE, 8)); + imageLoader.loadInto(image, preview, WORLD_ICON_SIZE, WORLD_ICON_SIZE); + shell.getChildren().add(image); + } else { + shell.getChildren().add(LauncherIcons.icon(LauncherIcons.Glyph.GLOBE, 18)); + } + return shell; + } + + private Rectangle roundedClip(double size, double radius) { + Rectangle clip = new Rectangle(size, size); + clip.setArcWidth(radius * 2); + clip.setArcHeight(radius * 2); + return clip; + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryWorldModel.java b/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryWorldModel.java new file mode 100644 index 00000000..1ebedf0a --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryWorldModel.java @@ -0,0 +1,19 @@ +package net.modtale.launcher.ui.library; + +import java.util.List; +import net.modtale.launcher.hytale.HytaleWorldManager.HytaleWorld; + +record LibraryWorldModel( + HytaleWorld world, + String meta, + int enabledProjectCount, + int totalProjectCount, + List projects +) { + LibraryWorldModel { + meta = meta == null ? "" : meta.trim(); + projects = projects == null ? List.of() : List.copyOf(projects); + totalProjectCount = Math.max(totalProjectCount, projects.size()); + enabledProjectCount = Math.max(0, Math.min(enabledProjectCount, totalProjectCount)); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryWorldProjectDisplay.java b/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryWorldProjectDisplay.java new file mode 100644 index 00000000..bf612323 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryWorldProjectDisplay.java @@ -0,0 +1,60 @@ +package net.modtale.launcher.ui.library; + +import net.modtale.launcher.model.install.InstalledProject; +import net.modtale.launcher.model.project.ProjectMeta; + +record LibraryWorldProjectDisplay( + String title, + String author, + String classification, + String icon, + String version, + String metaNote, + boolean localFile, + boolean unlockVisible, + boolean contentsVisible +) { + LibraryWorldProjectDisplay { + title = value(title, "Untitled Project"); + author = value(author); + classification = value(classification, "PLUGIN"); + icon = value(icon); + version = value(version); + metaNote = value(metaNote); + } + + static LibraryWorldProjectDisplay root(InstalledProject installed, ProjectMeta meta) { + return new LibraryWorldProjectDisplay( + first(meta == null ? "" : meta.title(), installed == null ? "" : installed.title()), + meta == null ? "" : meta.author(), + first(meta == null ? "" : meta.classification(), installed == null ? "" : installed.classification()), + meta == null ? "" : meta.icon(), + installed == null ? "" : installed.installedVersion(), + "", + installed != null && !LibraryProjectSupport.isModtaleProject(installed), + installed != null && installed.isModpack(), + installed != null && installed.isModpack() + ); + } + + private static String first(String... values) { + if (values == null) { + return ""; + } + for (String value : values) { + if (value != null && !value.isBlank()) { + return value.trim(); + } + } + return ""; + } + + private static String value(String value) { + return value == null ? "" : value.trim(); + } + + private static String value(String value, String fallback) { + String normalized = value(value); + return normalized.isBlank() ? fallback : normalized; + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryWorldProjectModel.java b/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryWorldProjectModel.java new file mode 100644 index 00000000..72b64c15 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryWorldProjectModel.java @@ -0,0 +1,81 @@ +package net.modtale.launcher.ui.library; + +import java.util.List; +import net.modtale.launcher.model.install.InstalledProject; +import net.modtale.launcher.model.install.UpdateCandidate; +import net.modtale.launcher.model.project.ProjectDetail; +import net.modtale.launcher.model.project.ProjectMeta; + +record LibraryWorldProjectModel( + InstalledProject installed, + ProjectDetail detail, + ProjectMeta meta, + UpdateCandidate update, + boolean loading, + List modIds, + int enabledCount, + int totalCount, + List contents, + LibraryWorldProjectDisplay display, + boolean contentsCollapsed +) { + LibraryWorldProjectModel( + InstalledProject installed, + ProjectDetail detail, + ProjectMeta meta, + UpdateCandidate update, + boolean loading, + List modIds, + int enabledCount, + int totalCount, + List contents + ) { + this(installed, detail, meta, update, loading, modIds, enabledCount, totalCount, contents, null, false); + } + + LibraryWorldProjectModel( + InstalledProject installed, + ProjectDetail detail, + ProjectMeta meta, + UpdateCandidate update, + boolean loading, + List modIds, + int enabledCount, + int totalCount, + List contents, + LibraryWorldProjectDisplay display + ) { + this(installed, detail, meta, update, loading, modIds, enabledCount, totalCount, contents, display, false); + } + + LibraryWorldProjectModel { + modIds = modIds == null + ? List.of() + : modIds.stream() + .filter(modId -> modId != null && !modId.isBlank()) + .map(String::trim) + .distinct() + .toList(); + totalCount = Math.max(totalCount, modIds.size()); + enabledCount = Math.max(0, Math.min(enabledCount, totalCount)); + contents = contents == null ? List.of() : List.copyOf(contents); + display = display == null ? LibraryWorldProjectDisplay.root(installed, meta) : display; + contentsCollapsed = contentsCollapsed && display.contentsVisible(); + } + + boolean toggleable() { + return !modIds.isEmpty(); + } + + boolean selected() { + return toggleable() && totalCount > 0 && enabledCount == totalCount; + } + + boolean indeterminate() { + return toggleable() && enabledCount > 0 && enabledCount < totalCount; + } + + boolean contentsExpanded() { + return display.contentsVisible() && !contentsCollapsed; + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryWorldRenderer.java b/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryWorldRenderer.java new file mode 100644 index 00000000..f05c9f81 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryWorldRenderer.java @@ -0,0 +1,507 @@ +package net.modtale.launcher.ui.library; + +import static net.modtale.launcher.ui.common.LauncherUi.emptyState; +import static net.modtale.launcher.ui.common.LauncherUi.primaryButton; +import static net.modtale.launcher.ui.common.LauncherUi.secondaryButton; +import static net.modtale.launcher.ui.common.LauncherUi.styleCombo; +import static net.modtale.launcher.ui.common.LauncherUi.value; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.function.Consumer; +import javafx.collections.FXCollections; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.Tooltip; +import javafx.scene.image.ImageView; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import javafx.scene.shape.Rectangle; +import net.modtale.launcher.hytale.HytaleWorldManager.HytaleWorld; +import net.modtale.launcher.model.install.InstalledProject; +import net.modtale.launcher.model.install.UpdateCandidate; +import net.modtale.launcher.model.project.ProjectClassification; +import net.modtale.launcher.model.project.ProjectDetail; +import net.modtale.launcher.model.project.ProjectMeta; +import net.modtale.launcher.model.project.ProjectVersion; +import net.modtale.launcher.ui.common.CachedImageLoader; +import net.modtale.launcher.ui.common.LauncherIcons; + +final class LibraryWorldRenderer { + + private static final double PROJECT_ICON_SIZE = 46; + private static final double CONTENT_ICON_SIZE = 34; + + private final CachedImageLoader imageLoader; + private final Consumer updateProject; + private final Consumer loadVersions; + private final LibraryProjectRenderer.VersionSwitchHandler switchVersion; + private final Consumer uninstallProject; + private final LibraryProjectRenderer.UnlockHandler unlockProject; + private final Consumer toggleModpackContents; + private final LibraryProjectRenderer.WorldToggleHandler toggleWorldMods; + private final Consumer shareWorldSnapshot; + private final Consumer createModpackFromWorld; + + LibraryWorldRenderer( + CachedImageLoader imageLoader, + Consumer updateProject, + Consumer loadVersions, + LibraryProjectRenderer.VersionSwitchHandler switchVersion, + Consumer uninstallProject, + LibraryProjectRenderer.UnlockHandler unlockProject, + Consumer toggleModpackContents, + LibraryProjectRenderer.WorldToggleHandler toggleWorldMods, + Consumer shareWorldSnapshot, + Consumer createModpackFromWorld + ) { + this.imageLoader = imageLoader; + this.updateProject = updateProject; + this.loadVersions = loadVersions; + this.switchVersion = switchVersion; + this.uninstallProject = uninstallProject; + this.unlockProject = unlockProject; + this.toggleModpackContents = toggleModpackContents; + this.toggleWorldMods = toggleWorldMods; + this.shareWorldSnapshot = shareWorldSnapshot; + this.createModpackFromWorld = createModpackFromWorld; + } + + List worldDetail(LibraryWorldModel model) { + if (model == null || model.world() == null) { + return List.of(emptyState("No world selected", "Create a Hytale world, then refresh the launcher.")); + } + List sections = new ArrayList<>(); + sections.add(worldHeader(model)); + sections.add(installedProjectsSection(model)); + return sections; + } + + private Node worldHeader(LibraryWorldModel model) { + VBox section = new VBox(12); + section.getStyleClass().addAll("library-detail-hero", "library-world-detail-hero"); + + HBox row = new HBox(12); + row.getStyleClass().add("library-detail-heading"); + StackPane icon = imageIcon( + model.world().previewImage(), + model.world().name(), + LauncherIcons.Glyph.GLOBE, + 44, + "library-detail-icon" + ); + icon.getStyleClass().add("library-world-detail-icon"); + + VBox copy = new VBox(); + copy.setAlignment(Pos.CENTER_LEFT); + Label title = new Label(model.world().name()); + title.getStyleClass().addAll("library-detail-title", "library-world-detail-title"); + title.setMaxWidth(Double.MAX_VALUE); + copy.getChildren().add(title); + HBox.setHgrow(copy, Priority.ALWAYS); + + HBox actions = new HBox(8); + actions.getStyleClass().add("library-actions"); + actions.setAlignment(Pos.CENTER_RIGHT); + Button share = secondaryButton("Share"); + share.setGraphic(LauncherIcons.icon(LauncherIcons.Glyph.SHARE_2, 14)); + share.setTooltip(new Tooltip("Copy a share link for this world's enabled mods")); + share.setOnAction(event -> shareWorldSnapshot.accept(model.world())); + Button pack = primaryButton("Make Pack"); + pack.setGraphic(LauncherIcons.icon(LauncherIcons.Glyph.PACKAGE_PLUS, 14)); + pack.setTooltip(new Tooltip("Start a Modtale modpack from this world's enabled mods")); + pack.setOnAction(event -> createModpackFromWorld.accept(model.world())); + actions.getChildren().addAll(share, pack); + + row.getChildren().addAll(icon, copy, actions); + section.getChildren().add(row); + return section; + } + + private Node installedProjectsSection(LibraryWorldModel model) { + return installedProjectsSection( + model.world(), + model.projects(), + "No installed projects", + "Install mods from Browse to manage them per world." + ); + } + + private Node installedProjectsSection( + HytaleWorld world, + List projects, + String emptyTitle, + String emptySubtitle + ) { + VBox section = detailSection("Installed Mods and Modpacks"); + if (projects.isEmpty()) { + section.getChildren().add(emptyState(emptyTitle, emptySubtitle)); + return section; + } + VBox rows = new VBox(10); + rows.getStyleClass().add("library-world-project-list"); + for (LibraryWorldProjectModel project : projects) { + rows.getChildren().add(projectRow(world, project)); + } + section.getChildren().add(rows); + return section; + } + + private Node projectRow(HytaleWorld world, LibraryWorldProjectModel model) { + VBox shell = new VBox(10); + shell.getStyleClass().add("library-world-project-row"); + + HBox row = new HBox(12); + row.getStyleClass().add("library-world-project-main"); + row.setAlignment(Pos.CENTER_LEFT); + + boolean hasWorld = world != null; + LibraryToggleBox toggle = new LibraryToggleBox(); + toggle.setSelected(hasWorld && model.selected()); + toggle.setIndeterminate(hasWorld && model.indeterminate()); + toggle.setDisable(!hasWorld || !model.toggleable()); + toggle.setTooltip(new Tooltip(!hasWorld + ? "Select or create a world to enable this install" + : model.toggleable() + ? "Enable or disable this installed project for " + world.name() + : "No Hytale manifest id was found for this install record")); + if (hasWorld) { + toggle.setOnAction(() -> toggleWorldMods.setEnabled(world, model.modIds(), toggle.isSelected())); + } + + StackPane icon = projectIcon(model, PROJECT_ICON_SIZE); + VBox copy = projectCopy(model); + HBox.setHgrow(copy, Priority.ALWAYS); + + HBox actions = projectActions(model); + row.getChildren().addAll(toggle, icon, copy, actions); + shell.getChildren().add(row); + + Node versionControls = versionControls(model); + if (versionControls != null) { + shell.getChildren().add(versionControls); + } + + Node contentsCard = contentsCard(model); + if (contentsCard != null) { + shell.getChildren().add(contentsCard); + } + return shell; + } + + private VBox projectCopy(LibraryWorldProjectModel model) { + InstalledProject installed = model.installed(); + LibraryWorldProjectDisplay display = model.display(); + VBox copy = new VBox(5); + Label title = new Label(display.title()); + title.getStyleClass().add("library-world-project-title"); + + String subtitleText = projectMetaLine(model); + Label subtitle = new Label(subtitleText); + subtitle.getStyleClass().add("library-world-project-meta"); + + HBox badges = new HBox(7); + badges.getStyleClass().add("library-badge-row"); + if (!display.version().isBlank()) { + badges.getChildren().add(badge(display.version(), "version")); + } + if (!installed.gameVersion().isBlank()) { + badges.getChildren().add(badge(installed.gameVersion(), "game")); + } + if (model.update() != null) { + badges.getChildren().add(badge("Update ready", "game")); + } + if (display.localFile()) { + badges.getChildren().add(badge("Local file", "locked")); + } + if (installed.isModpack()) { + badges.getChildren().add(badge("Modpack", "modpack")); + } + copy.getChildren().add(title); + if (!subtitleText.isBlank()) { + copy.getChildren().add(subtitle); + } + copy.getChildren().add(badges); + return copy; + } + + private HBox projectActions(LibraryWorldProjectModel model) { + InstalledProject installed = model.installed(); + boolean modtaleProject = LibraryProjectSupport.isModtaleProject(installed); + HBox actions = new HBox(6); + actions.getStyleClass().add("library-world-project-actions"); + actions.setAlignment(Pos.CENTER_RIGHT); + + if (modtaleProject && model.update() != null) { + Button update = primaryButton("Update"); + update.getStyleClass().add("small"); + update.setGraphic(LauncherIcons.icon(LauncherIcons.Glyph.DOWNLOAD, 13)); + update.setTooltip(new Tooltip("Install " + model.update().newestVersionNumber())); + update.setOnAction(event -> updateProject.accept(model.update())); + actions.getChildren().add(update); + } + + Button versions = iconAction( + LauncherIcons.Glyph.LAYERS, + !modtaleProject + ? "Local files do not have Modtale version history" + : model.loading() + ? "Loading release metadata" + : model.detail() == null ? "Load available versions" : "Version controls are ready below", + "neutral", + () -> { + if (modtaleProject) { + loadVersions.accept(installed); + } + } + ); + versions.setDisable(!modtaleProject || model.detail() != null || model.loading()); + + actions.getChildren().add(versions); + if (model.display().unlockVisible()) { + Button unlock = iconAction( + LauncherIcons.Glyph.EDIT, + "Unlock this pack into individual installed mods", + "neutral", + () -> unlockProject.unlock(installed) + ); + actions.getChildren().add(unlock); + } + Button remove = iconAction( + LauncherIcons.Glyph.TRASH, + "Remove this project", + "danger", + () -> uninstallProject.accept(installed) + ); + actions.getChildren().add(remove); + return actions; + } + + private Node versionControls(LibraryWorldProjectModel model) { + InstalledProject installed = model.installed(); + ProjectDetail detail = model.detail(); + if (detail == null) { + if (!model.loading()) { + return null; + } + HBox loading = new HBox(10); + loading.getStyleClass().add("library-world-version-row"); + loading.setAlignment(Pos.CENTER_LEFT); + Label label = new Label("Loading release metadata..."); + label.getStyleClass().add("library-muted-text"); + loading.getChildren().add(label); + return loading; + } + + List choices = LibraryProjectSupport.versionChoices( + detail.versions(), + installed, + installed.gameVersion() + ); + if (choices.isEmpty()) { + return null; + } + ComboBox versions = new ComboBox<>(FXCollections.observableArrayList(choices)); + styleCombo(versions); + choices.stream() + .filter(choice -> LibraryProjectSupport.sameVersion(installed, choice.version())) + .findFirst() + .ifPresentOrElse(versions::setValue, () -> versions.setValue(choices.getFirst())); + + Button switchButton = primaryButton("Switch"); + switchButton.setGraphic(LauncherIcons.icon(LauncherIcons.Glyph.DOWNLOAD, 13)); + switchButton.setOnAction(event -> { + LibraryVersionChoice choice = versions.getValue(); + if (choice != null) { + switchVersion.switchVersion(installed, detail, choice.version()); + } + }); + Runnable updateSwitchState = () -> { + LibraryVersionChoice choice = versions.getValue(); + switchButton.setDisable(choice == null || LibraryProjectSupport.sameVersion(installed, choice.version())); + }; + versions.valueProperty().addListener((observable, previous, next) -> updateSwitchState.run()); + updateSwitchState.run(); + + HBox controls = new HBox(10, versions, switchButton); + controls.getStyleClass().add("library-world-version-row"); + HBox.setHgrow(versions, Priority.ALWAYS); + return controls; + } + + private Node contentsCard(LibraryWorldProjectModel model) { + if (!model.display().contentsVisible()) { + return null; + } + int contentCount = model.contents().isEmpty() + ? LibraryProjectSupport.contentCount(model.installed()) + : model.contents().size(); + Button toggle = new Button(null, LauncherIcons.icon( + model.contentsCollapsed() ? LauncherIcons.Glyph.CHEVRON_DOWN : LauncherIcons.Glyph.CHEVRON_UP, + 13 + )); + toggle.getStyleClass().addAll("library-icon-action", "library-icon-action-neutral"); + toggle.setTooltip(new Tooltip(model.contentsCollapsed() ? "Show included mods" : "Collapse included mods")); + toggle.setAccessibleText(model.contentsCollapsed() ? "Show included mods" : "Collapse included mods"); + toggle.setOnAction(event -> toggleModpackContents.accept(model.installed())); + + Label title = new Label("Included mods"); + title.getStyleClass().add("library-child-title"); + Label count = new Label(contentCount + " item" + LibraryProjectSupport.plural(contentCount)); + count.getStyleClass().add("library-muted-text"); + Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + + HBox header = new HBox(8, title, count, spacer, toggle); + header.getStyleClass().add("library-world-content-header"); + header.setAlignment(Pos.CENTER_LEFT); + + VBox card = new VBox(8); + card.getStyleClass().add("library-world-content-card"); + card.getChildren().add(header); + if (!model.contentsExpanded()) { + return card; + } + + VBox contents = new VBox(8); + contents.getStyleClass().add("library-world-content-list"); + if (model.contents().isEmpty()) { + contents.getChildren().add(emptyState("No individual manifests found", "This pack is installed, but its files did not expose separate mod ids.")); + card.getChildren().add(contents); + return card; + } + for (LibraryWorldContentItem item : model.contents()) { + contents.getChildren().add(compactContentRow(item)); + } + card.getChildren().add(contents); + return card; + } + + private Node compactContentRow(LibraryWorldContentItem item) { + HBox row = new HBox(9); + row.getStyleClass().addAll("library-world-content-row", "library-world-content-row-compact"); + row.setAlignment(Pos.CENTER_LEFT); + + StackPane icon = contentIcon(item); + VBox copy = new VBox(2); + Label title = new Label(item.title()); + title.getStyleClass().add("library-child-title"); + Label meta = new Label(item.meta().isBlank() ? "Included in modpack" : item.meta()); + meta.getStyleClass().add("library-child-meta"); + copy.getChildren().addAll(title, meta); + HBox.setHgrow(copy, Priority.ALWAYS); + + Label status = new Label("Included"); + status.getStyleClass().add("library-version-pill"); + row.getChildren().addAll(icon, copy, status); + return row; + } + + private StackPane projectIcon(LibraryWorldProjectModel model, double size) { + ProjectMeta meta = model.meta(); + InstalledProject installed = model.installed(); + LibraryWorldProjectDisplay display = model.display(); + String iconUrl = display.icon(); + String title = first(display.title(), meta == null ? "" : meta.title(), installed.title(), "M"); + LauncherIcons.Glyph glyph = ProjectClassification.isModpack(display.classification()) + ? LauncherIcons.Glyph.LAYERS + : LauncherIcons.Glyph.BOX; + return imageIcon(iconUrl, title, glyph, size, "library-project-icon"); + } + + private StackPane contentIcon(LibraryWorldContentItem item) { + LauncherIcons.Glyph glyph = ProjectClassification.isModpack(item.classification()) + ? LauncherIcons.Glyph.LAYERS + : LauncherIcons.Glyph.FILE_CODE; + return imageIcon(item.icon(), item.title(), glyph, CONTENT_ICON_SIZE, "library-child-icon"); + } + + private StackPane imageIcon(String iconUrl, String title, LauncherIcons.Glyph fallbackGlyph, double size, String styleClass) { + StackPane shell = new StackPane(); + shell.getStyleClass().add(styleClass); + shell.setMinSize(size, size); + shell.setPrefSize(size, size); + shell.setMaxSize(size, size); + + if (iconUrl != null && !iconUrl.isBlank() && imageLoader != null) { + ImageView image = new ImageView(); + image.setFitWidth(size); + image.setFitHeight(size); + image.setSmooth(true); + image.setMouseTransparent(true); + image.setClip(roundedClip(size, size <= CONTENT_ICON_SIZE ? 8 : 10)); + imageLoader.loadInto(image, iconUrl, size, size); + shell.getChildren().add(image); + return shell; + } + + Node fallback = LauncherIcons.icon(fallbackGlyph, Math.max(15, size * 0.42)); + shell.getChildren().add(fallback); + if (fallbackGlyph == LauncherIcons.Glyph.BOX && title != null && !title.isBlank()) { + shell.setAccessibleText(title.substring(0, 1).toUpperCase(Locale.ROOT)); + } + return shell; + } + + private Rectangle roundedClip(double size, double radius) { + Rectangle clip = new Rectangle(size, size); + clip.setArcWidth(radius * 2); + clip.setArcHeight(radius * 2); + return clip; + } + + private String projectMetaLine(LibraryWorldProjectModel model) { + String author = model.display().author(); + return author.isBlank() ? "" : "by " + author; + } + + private boolean hasUnlockableContents(InstalledProject installed) { + return installed.isModpack() + || !installed.bundledProjects().isEmpty() + || !installed.dependencyProjectIds().isEmpty() + || !installed.externalDependencies().isEmpty(); + } + + private VBox detailSection(String title) { + VBox section = new VBox(10); + section.getStyleClass().add("library-detail-section"); + Label label = new Label(title); + label.getStyleClass().add("library-section-title"); + section.getChildren().add(label); + return section; + } + + private Node badge(String text, String tone) { + Label label = new Label(text); + label.getStyleClass().addAll("library-badge", "library-badge-" + tone); + return label; + } + + private Button iconAction(LauncherIcons.Glyph glyph, String tooltip, String tone, Runnable action) { + Button button = new Button(null, LauncherIcons.icon(glyph, 14)); + button.getStyleClass().addAll("library-icon-action", "library-icon-action-" + tone); + button.setTooltip(new Tooltip(tooltip)); + button.setAccessibleText(tooltip); + button.setOnAction(event -> action.run()); + return button; + } + + private static String first(String... values) { + if (values == null) { + return ""; + } + for (String candidate : values) { + if (candidate != null && !candidate.isBlank()) { + return candidate.trim(); + } + } + return ""; + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryWorldSnapshotMapper.java b/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryWorldSnapshotMapper.java new file mode 100644 index 00000000..42c86473 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryWorldSnapshotMapper.java @@ -0,0 +1,330 @@ +package net.modtale.launcher.ui.library; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import net.modtale.launcher.hytale.HytaleWorldManager.HytaleInstalledMod; +import net.modtale.launcher.model.install.InstalledProject; +import net.modtale.launcher.model.install.InstalledProjectReference; +import net.modtale.launcher.model.worldlist.CreateWorldModListRequest; + +final class LibraryWorldSnapshotMapper { + + private final List installedProjects; + private final Map modsById; + private final Map projectsByFile; + + private LibraryWorldSnapshotMapper( + List installedProjects, + List installedMods + ) { + this.installedProjects = installedProjects == null ? List.of() : List.copyOf(installedProjects); + this.modsById = installedModsById(installedMods); + this.projectsByFile = installedProjectsByFile(this.installedProjects); + } + + static List itemsFor( + Set enabledModIds, + List installedProjects, + List installedMods + ) { + LibraryWorldSnapshotMapper mapper = new LibraryWorldSnapshotMapper(installedProjects, installedMods); + List items = new ArrayList<>(); + if (enabledModIds == null) { + return items; + } + for (String modId : enabledModIds) { + if (modId != null && !modId.isBlank()) { + items.add(mapper.itemFor(modId.trim())); + } + } + return items; + } + + private CreateWorldModListRequest.Item itemFor(String modId) { + HytaleInstalledMod localMod = modsById.get(modId); + SnapshotMatch match = matchFor(modId, localMod); + return match == null ? externalSnapshotItem(modId, localMod) : matchedSnapshotItem(modId, localMod, match); + } + + private SnapshotMatch matchFor(String modId, HytaleInstalledMod localMod) { + if (localMod != null) { + InstalledProject byFile = projectsByFile.get(normalizedFileKey(localMod.file())); + if (byFile != null) { + InstalledProjectReference reference = matchingReference(modId, localMod, byFile); + if (reference != null) { + return SnapshotMatch.fromReference(reference, byFile.installedVersion()); + } + return SnapshotMatch.fromProject(byFile); + } + } + + for (InstalledProject project : installedProjects) { + InstalledProjectReference reference = matchingReference(modId, localMod, project); + if (reference != null) { + return SnapshotMatch.fromReference(reference, project.installedVersion()); + } + } + + return installedProjects.stream() + .filter(project -> LibraryProjectSupport.projectWorldModIds(project).contains(modId)) + .findFirst() + .map(SnapshotMatch::fromProject) + .orElse(null); + } + + private InstalledProjectReference matchingReference( + String modId, + HytaleInstalledMod localMod, + InstalledProject project + ) { + if (project == null) { + return null; + } + List references = bundledReferences(project); + if (references.isEmpty()) { + return null; + } + Set modKeys = new LinkedHashSet<>(); + addNormalized(modKeys, modId); + if (localMod != null) { + addNormalized(modKeys, localMod.id()); + addNormalized(modKeys, localMod.name()); + addNormalized(modKeys, fileName(localMod.file())); + } + if (modKeys.isEmpty()) { + return null; + } + + for (InstalledProjectReference reference : references) { + Set referenceKeys = new LinkedHashSet<>(); + addNormalized(referenceKeys, LibraryProjectSupport.referenceWorldModId(reference)); + addNormalized(referenceKeys, reference.title()); + addNormalized(referenceKeys, reference.slug()); + addNormalized(referenceKeys, reference.projectId()); + addNormalized(referenceKeys, reference.externalId()); + addNormalized(referenceKeys, reference.externalFileName()); + addNormalized(referenceKeys, fileName(reference.externalFileUrl())); + addNormalized(referenceKeys, fileName(reference.cachedFileUrl())); + for (String key : modKeys) { + if (referenceKeys.contains(key)) { + return reference; + } + } + } + return null; + } + + private CreateWorldModListRequest.Item matchedSnapshotItem( + String modId, + HytaleInstalledMod localMod, + SnapshotMatch match + ) { + String localTitle = localMod == null ? modId : localMod.name(); + String localVersion = localMod == null ? "" : localMod.version(); + return new CreateWorldModListRequest.Item( + modId, + match.projectId(), + match.slug(), + first(match.title(), localTitle, modId), + first(match.versionNumber(), localVersion), + first(match.classification(), "PLUGIN"), + worldListSource(first(match.source(), match.projectId().isBlank() ? "OTHER" : InstalledProject.SOURCE_MODTALE)), + match.externalId(), + match.externalUrl(), + match.icon() + ); + } + + private CreateWorldModListRequest.Item externalSnapshotItem(String modId, HytaleInstalledMod localMod) { + String title = localMod == null ? modId : first(localMod.name(), modId); + String version = localMod == null ? "" : localMod.version(); + return new CreateWorldModListRequest.Item( + modId, + "", + "", + title, + version, + "PLUGIN", + "OTHER", + modId, + "", + "" + ); + } + + private static Map installedModsById(List mods) { + Map byId = new LinkedHashMap<>(); + if (mods == null) { + return byId; + } + for (HytaleInstalledMod mod : mods) { + if (mod != null && mod.id() != null && !mod.id().isBlank()) { + byId.put(mod.id(), mod); + } + } + return byId; + } + + private static Map installedProjectsByFile(List projects) { + Map byFile = new LinkedHashMap<>(); + if (projects == null) { + return byFile; + } + for (InstalledProject project : projects) { + if (project == null) { + continue; + } + for (String file : project.files()) { + String key = normalizedFileKey(file); + if (!key.isBlank()) { + byFile.putIfAbsent(key, project); + } + } + } + return byFile; + } + + private static List bundledReferences(InstalledProject installed) { + if (installed == null) { + return List.of(); + } + if (!installed.bundledProjects().isEmpty()) { + return installed.bundledProjects(); + } + List references = new ArrayList<>(); + installed.dependencyProjectIds().forEach(id -> references.add(new InstalledProjectReference( + id, id, "", id, "", "", "", InstalledProject.SOURCE_MODTALE, "", "", "", "", "", "", null, null + ))); + installed.externalDependencies().forEach(id -> references.add(new InstalledProjectReference( + id, "", "", id, "", "", "", "EXTERNAL", id, "", "", "", "", "", null, null + ))); + return references; + } + + private static void addNormalized(Set keys, String value) { + String normalized = normalizedNameKey(value); + if (!normalized.isBlank()) { + keys.add(normalized); + } + } + + private static String fileName(Path value) { + return value == null ? "" : value.getFileName().toString(); + } + + private static String fileName(String value) { + if (value == null || value.isBlank()) { + return ""; + } + String normalized = value.trim().replace('\\', '/'); + int query = normalized.indexOf('?'); + if (query >= 0) { + normalized = normalized.substring(0, query); + } + int slash = normalized.lastIndexOf('/'); + return slash >= 0 ? normalized.substring(slash + 1) : normalized; + } + + private static String normalizedNameKey(String value) { + if (value == null || value.isBlank()) { + return ""; + } + String normalized = value.trim().toLowerCase(Locale.ROOT) + .replaceFirst("(?i)\\.(jar|zip|hytale)$", ""); + return normalized.replaceAll("[^a-z0-9]+", ""); + } + + private static String normalizedFileKey(String file) { + if (file == null || file.isBlank()) { + return ""; + } + return normalizedFileKey(Path.of(file)); + } + + private static String normalizedFileKey(Path file) { + if (file == null) { + return ""; + } + return file.toAbsolutePath().normalize().toString(); + } + + private static String worldListSource(String source) { + String normalized = first(source).toUpperCase(Locale.ROOT); + return switch (normalized) { + case "MODTALE", "CURSEFORGE", "GITHUB", "WEBSITE", "OTHER" -> normalized; + default -> "OTHER"; + }; + } + + private static String first(String... values) { + if (values == null) { + return ""; + } + for (String value : values) { + if (value != null && !value.isBlank()) { + return value.trim(); + } + } + return ""; + } + + private record SnapshotMatch( + String projectId, + String slug, + String title, + String versionNumber, + String classification, + String source, + String externalId, + String externalUrl, + String icon + ) { + private SnapshotMatch { + projectId = first(projectId); + slug = first(slug); + title = first(title); + versionNumber = first(versionNumber); + classification = first(classification); + source = first(source); + externalId = first(externalId); + externalUrl = first(externalUrl); + icon = first(icon); + } + + private static SnapshotMatch fromProject(InstalledProject project) { + boolean modtaleProject = LibraryProjectSupport.isModtaleProject(project); + return new SnapshotMatch( + modtaleProject ? project.projectId() : "", + modtaleProject ? project.slug() : "", + project.title(), + project.installedVersion(), + project.classification(), + modtaleProject ? InstalledProject.SOURCE_MODTALE : first(project.source(), "OTHER"), + modtaleProject ? "" : first(project.projectId(), project.slug()), + "", + "" + ); + } + + private static SnapshotMatch fromReference(InstalledProjectReference reference, String fallbackVersion) { + boolean modtaleProject = reference.isModtaleProject(); + return new SnapshotMatch( + modtaleProject ? reference.projectId() : "", + modtaleProject ? reference.slug() : "", + reference.displayName(), + first(reference.versionNumber(), fallbackVersion), + first(reference.classification(), "PLUGIN"), + modtaleProject ? InstalledProject.SOURCE_MODTALE : first(reference.source(), "OTHER"), + modtaleProject ? "" : first(reference.externalId(), reference.id(), LibraryProjectSupport.referenceWorldModId(reference)), + reference.externalUrl(), + reference.icon() + ); + } + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryWorldToggle.java b/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryWorldToggle.java new file mode 100644 index 00000000..768a2bb3 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/library/LibraryWorldToggle.java @@ -0,0 +1,18 @@ +package net.modtale.launcher.ui.library; + +import java.util.List; +import net.modtale.launcher.hytale.HytaleWorldManager.HytaleWorld; + +record LibraryWorldToggle( + HytaleWorld world, + String meta, + int enabledCount, + int totalCount, + boolean selected, + boolean indeterminate, + List modIds +) { + LibraryWorldToggle { + modIds = modIds == null ? List.of() : List.copyOf(modIds); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/library/PostDownloadWorldModal.java b/launcher/src/main/java/net/modtale/launcher/ui/library/PostDownloadWorldModal.java new file mode 100644 index 00000000..8cfaffc7 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/library/PostDownloadWorldModal.java @@ -0,0 +1,414 @@ +package net.modtale.launcher.ui.library; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Supplier; +import javafx.application.Platform; +import javafx.css.PseudoClass; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.Parent; +import javafx.scene.control.Button; +import javafx.scene.control.ContentDisplay; +import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; +import javafx.scene.image.ImageView; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import javafx.scene.shape.Rectangle; +import net.modtale.launcher.hytale.HytaleWorldManager.HytaleWorld; +import net.modtale.launcher.ui.common.CachedImageLoader; +import net.modtale.launcher.ui.common.LauncherIcons; + +final class PostDownloadWorldModal { + + private static final double MODAL_WIDTH = 512; + private static final double MODAL_MAX_HEIGHT = 720; + private static final double WORLD_ICON_IMAGE_SIZE = 44; + private static final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected"); + private static final PseudoClass INDETERMINATE = PseudoClass.getPseudoClass("indeterminate"); + + private final Supplier host; + private final Consumer apply; + private final CachedImageLoader imageLoader; + private final Map worldChecks = new LinkedHashMap<>(); + private final Map worldRows = new LinkedHashMap<>(); + private final Set selectedWorldKeys = new LinkedHashSet<>(); + + private StackPane overlay; + private String title = ""; + private List modIds = List.of(); + private List worlds = List.of(); + private Button applyButton; + private Button toggleAllButton; + private Label selectedCount; + + PostDownloadWorldModal(Supplier host, Consumer apply, CachedImageLoader imageLoader) { + this.host = host == null ? () -> null : host; + this.apply = apply == null ? ignored -> { + } : apply; + this.imageLoader = imageLoader; + } + + boolean show(String title, List modIds, List worlds) { + if (modIds == null || modIds.isEmpty() || worlds == null || worlds.isEmpty()) { + return false; + } + hide(); + StackPane hostPane = host.get(); + if (hostPane == null) { + return false; + } + this.title = value(title, "Installed project"); + this.modIds = modIds.stream() + .filter(id -> id != null && !id.isBlank()) + .map(String::trim) + .distinct() + .toList(); + this.worlds = List.copyOf(worlds); + selectedWorldKeys.clear(); + this.worlds.stream() + .filter(WorldOption::selected) + .map(PostDownloadWorldModal::worldKey) + .forEach(selectedWorldKeys::add); + + overlay = overlayShell(); + hostPane.getChildren().add(overlay); + rebuildOverlay(); + return true; + } + + private void rebuildOverlay() { + if (overlay == null) { + return; + } + worldChecks.clear(); + worldRows.clear(); + overlay.getChildren().setAll(modal()); + updateActionState(); + Platform.runLater(overlay::requestFocus); + } + + private StackPane overlayShell() { + StackPane shell = new StackPane(); + shell.getStyleClass().add("post-download-modal-overlay"); + shell.setMaxSize(Double.MAX_VALUE, Double.MAX_VALUE); + shell.setFocusTraversable(true); + shell.addEventHandler(KeyEvent.KEY_PRESSED, event -> { + if (event.getCode() == KeyCode.ESCAPE) { + hide(); + event.consume(); + } + }); + shell.setOnMouseClicked(event -> { + if (event.getTarget() == shell) { + hide(); + } + }); + return shell; + } + + private VBox modal() { + VBox modal = new VBox(0); + modal.getStyleClass().add("post-download-modal"); + modal.setMaxWidth(MODAL_WIDTH); + modal.setPrefWidth(MODAL_WIDTH); + modal.setMaxHeight(MODAL_MAX_HEIGHT); + modal.setOnMouseClicked(event -> event.consume()); + + ScrollPane scroll = new ScrollPane(body()); + scroll.getStyleClass().add("post-download-modal-scroll"); + scroll.setFitToWidth(true); + scroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + scroll.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED); + VBox.setVgrow(scroll, Priority.ALWAYS); + + modal.getChildren().addAll(header(), scroll, footer()); + return modal; + } + + private HBox header() { + HBox header = new HBox(16); + header.getStyleClass().add("post-download-modal-header"); + header.setAlignment(Pos.CENTER_LEFT); + + HBox titleRow = new HBox(8, LauncherIcons.icon(LauncherIcons.Glyph.GLOBE, 20), new Label("Enable in Worlds")); + titleRow.getStyleClass().add("post-download-modal-title"); + titleRow.setAlignment(Pos.CENTER_LEFT); + HBox.setHgrow(titleRow, Priority.ALWAYS); + + Button close = new Button(null, LauncherIcons.icon(LauncherIcons.Glyph.X, 18)); + close.getStyleClass().add("post-download-modal-close"); + close.setOnAction(event -> hide()); + header.getChildren().addAll(titleRow, close); + return header; + } + + private VBox body() { + VBox body = new VBox(16); + body.getStyleClass().add("post-download-modal-body"); + body.getChildren().add(summaryRow()); + + VBox list = new VBox(8); + list.getStyleClass().add("post-download-modal-world-list"); + for (WorldOption world : worlds) { + list.getChildren().add(worldRow(world)); + } + body.getChildren().add(list); + return body; + } + + private HBox summaryRow() { + HBox row = new HBox(12); + row.getStyleClass().add("post-download-modal-summary"); + row.setAlignment(Pos.TOP_LEFT); + + VBox copy = new VBox(3); + copy.setMinWidth(0); + HBox.setHgrow(copy, Priority.ALWAYS); + Label description = new Label(title + " installed. Choose where it should be enabled."); + description.getStyleClass().add("post-download-modal-description"); + description.setWrapText(true); + selectedCount = new Label(); + selectedCount.getStyleClass().add("post-download-modal-muted"); + copy.getChildren().addAll(description, selectedCount); + + toggleAllButton = new Button(); + toggleAllButton.getStyleClass().add("post-download-modal-toggle-all"); + toggleAllButton.setMinWidth(Region.USE_PREF_SIZE); + toggleAllButton.setMaxWidth(Region.USE_PREF_SIZE); + toggleAllButton.setOnAction(event -> toggleAll()); + row.getChildren().addAll(copy, toggleAllButton); + return row; + } + + private HBox worldRow(WorldOption option) { + HBox row = new HBox(10); + row.getStyleClass().add("post-download-modal-world-row"); + row.setAlignment(Pos.CENTER_LEFT); + + String key = worldKey(option); + row.pseudoClassStateChanged(SELECTED, selectedWorldKeys.contains(key)); + row.pseudoClassStateChanged(INDETERMINATE, option.indeterminate()); + worldRows.put(key, row); + + LibraryToggleBox check = new LibraryToggleBox(); + check.getStyleClass().add("post-download-modal-check"); + check.setSelected(selectedWorldKeys.contains(key)); + check.setIndeterminate(option.indeterminate()); + check.setOnAction(() -> setWorldSelected(key, check.isSelected())); + worldChecks.put(key, check); + + StackPane icon = worldIcon(option.world()); + + VBox copy = new VBox(3); + Label name = new Label(option.world().name()); + name.getStyleClass().add("post-download-modal-world-title"); + Label meta = new Label(option.enabledCount() + "/" + option.totalCount() + " already enabled - " + option.meta()); + meta.getStyleClass().add("post-download-modal-world-meta"); + copy.getChildren().addAll(name, meta); + HBox.setHgrow(copy, Priority.ALWAYS); + + row.setOnMouseClicked(event -> { + if (event.getTarget() != check) { + check.setIndeterminate(false); + check.setSelected(!check.isSelected()); + setWorldSelected(key, check.isSelected()); + } + }); + row.getChildren().addAll(check, icon, copy); + return row; + } + + private StackPane worldIcon(HytaleWorld world) { + StackPane shell = new StackPane(); + shell.getStyleClass().add("post-download-modal-world-icon"); + + String preview = world == null ? "" : world.previewImage(); + if (!preview.isBlank() && imageLoader != null) { + ImageView image = new ImageView(); + image.setFitWidth(WORLD_ICON_IMAGE_SIZE); + image.setFitHeight(WORLD_ICON_IMAGE_SIZE); + image.setPreserveRatio(false); + image.setSmooth(true); + image.setMouseTransparent(true); + image.setClip(roundedClip(WORLD_ICON_IMAGE_SIZE, 10)); + imageLoader.loadInto(image, preview, WORLD_ICON_IMAGE_SIZE, WORLD_ICON_IMAGE_SIZE); + shell.getChildren().add(image); + } else { + shell.getChildren().add(LauncherIcons.icon(LauncherIcons.Glyph.GLOBE, 16)); + } + return shell; + } + + private HBox footer() { + HBox footer = new HBox(10); + footer.getStyleClass().add("post-download-modal-footer"); + footer.setAlignment(Pos.CENTER); + + Button skip = new Button("Not Now"); + skip.getStyleClass().add("post-download-modal-secondary"); + skip.setOnAction(event -> hide()); + + applyButton = new Button(); + applyButton.getStyleClass().add("post-download-modal-primary"); + applyButton.setMaxWidth(Double.MAX_VALUE); + applyButton.setAlignment(Pos.CENTER); + applyButton.setContentDisplay(ContentDisplay.GRAPHIC_ONLY); + applyButton.setGraphic(applyButtonContent()); + applyButton.setOnAction(event -> { + List selected = selectedWorlds(); + hide(); + apply.accept(new Selection(selected, modIds)); + }); + HBox.setHgrow(applyButton, Priority.ALWAYS); + footer.getChildren().addAll(skip, applyButton); + return footer; + } + + private VBox applyButtonContent() { + VBox content = new VBox(0); + content.setAlignment(Pos.CENTER); + content.setMaxWidth(Double.MAX_VALUE); + + HBox title = new HBox(8, LauncherIcons.icon(LauncherIcons.Glyph.CHECK, 18), new Label("Enable Selected")); + title.getStyleClass().add("post-download-modal-primary-title"); + title.setAlignment(Pos.CENTER); + title.setMaxWidth(Double.MAX_VALUE); + content.getChildren().add(title); + return content; + } + + private void toggleAll() { + selectAll(selectedWorldKeys.size() != worlds.size()); + } + + private void selectAll(boolean selected) { + selectedWorldKeys.clear(); + for (WorldOption option : worlds) { + String key = worldKey(option); + LibraryToggleBox check = worldChecks.get(key); + HBox row = worldRows.get(key); + if (selected) { + selectedWorldKeys.add(key); + } + if (check != null) { + check.setIndeterminate(false); + check.setSelected(selected); + } + if (row != null) { + row.pseudoClassStateChanged(SELECTED, selected); + row.pseudoClassStateChanged(INDETERMINATE, false); + } + } + rebuildOverlay(); + } + + private void setWorldSelected(String key, boolean selected) { + if (selected) { + selectedWorldKeys.add(key); + } else { + selectedWorldKeys.remove(key); + } + HBox row = worldRows.get(key); + if (row != null) { + row.pseudoClassStateChanged(SELECTED, selected); + row.pseudoClassStateChanged(INDETERMINATE, false); + } + updateActionState(); + } + + private List selectedWorlds() { + List selected = new ArrayList<>(); + for (WorldOption option : worlds) { + if (selectedWorldKeys.contains(worldKey(option))) { + selected.add(option.world()); + } + } + return selected; + } + + private void updateActionState() { + if (applyButton != null) { + applyButton.setDisable(selectedWorldKeys.isEmpty()); + applyButton.setGraphic(applyButtonContent()); + } + if (selectedCount != null) { + int count = selectedWorldKeys.size(); + selectedCount.setText(count + " world" + LibraryProjectSupport.plural(count) + " selected"); + } + if (toggleAllButton != null) { + toggleAllButton.setText(selectedWorldKeys.size() == worlds.size() ? "Deselect All" : "Select All"); + } + } + + private void hide() { + if (overlay == null) { + return; + } + Parent parent = overlay.getParent(); + if (parent instanceof StackPane stack) { + stack.getChildren().remove(overlay); + } + overlay = null; + } + + private static String worldKey(WorldOption option) { + return option == null ? "" : worldKey(option.world()); + } + + private static String worldKey(HytaleWorld world) { + return world == null || world.directory() == null + ? "" + : world.directory().toAbsolutePath().normalize().toString(); + } + + private static String value(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value; + } + + private static Rectangle roundedClip(double size, double radius) { + Rectangle clip = new Rectangle(size, size); + clip.setArcWidth(radius * 2); + clip.setArcHeight(radius * 2); + return clip; + } + + record WorldOption( + HytaleWorld world, + String meta, + int enabledCount, + int totalCount, + boolean selected, + boolean indeterminate + ) { + WorldOption { + meta = value(meta, "World save"); + totalCount = Math.max(0, totalCount); + enabledCount = Math.max(0, Math.min(enabledCount, totalCount)); + } + } + + record Selection(List worlds, List modIds) { + Selection { + worlds = worlds == null ? List.of() : List.copyOf(worlds); + modIds = modIds == null + ? List.of() + : modIds.stream() + .filter(id -> id != null && !id.isBlank()) + .map(String::trim) + .distinct() + .toList(); + } + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/play/LauncherPlayController.java b/launcher/src/main/java/net/modtale/launcher/ui/play/LauncherPlayController.java new file mode 100644 index 00000000..aba207d4 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/play/LauncherPlayController.java @@ -0,0 +1,1986 @@ +package net.modtale.launcher.ui.play; + +import static net.modtale.launcher.ui.common.LauncherUi.dangerButton; +import static net.modtale.launcher.ui.common.LauncherUi.setVisibleManaged; +import static net.modtale.launcher.ui.common.LauncherUi.value; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.Year; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; +import java.util.function.BooleanSupplier; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; +import javafx.application.Platform; +import javafx.beans.binding.BooleanBinding; +import javafx.beans.binding.Bindings; +import javafx.collections.FXCollections; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.geometry.Side; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.ContentDisplay; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.CustomMenuItem; +import javafx.scene.control.Label; +import javafx.scene.control.Menu; +import javafx.scene.control.MenuItem; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.Tooltip; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.layout.FlowPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import javafx.scene.shape.Rectangle; +import javafx.scene.text.Font; +import javafx.scene.text.FontWeight; +import javafx.util.StringConverter; +import net.modtale.launcher.api.ModtaleApiClient; +import net.modtale.launcher.api.ProjectSearchQuery; +import net.modtale.launcher.discord.DiscordRichPresenceService; +import net.modtale.launcher.hytale.HytaleApiClient; +import net.modtale.launcher.hytale.HytaleBlogPost; +import net.modtale.launcher.hytale.HytaleApiException; +import net.modtale.launcher.hytale.HytaleAuthService; +import net.modtale.launcher.hytale.HytaleAuthSession; +import net.modtale.launcher.hytale.HytaleFriend; +import net.modtale.launcher.hytale.HytaleGameVersionResolver; +import net.modtale.launcher.hytale.HytaleGameLauncher; +import net.modtale.launcher.hytale.HytaleLaunchResult; +import net.modtale.launcher.hytale.HytalePlatform; +import net.modtale.launcher.hytale.HytaleProfile; +import net.modtale.launcher.hytale.HytaleVersion; +import net.modtale.launcher.model.project.ProjectSummary; +import net.modtale.launcher.settings.LauncherSettings; +import net.modtale.launcher.ui.browse.card.ProjectCardFactory; +import net.modtale.launcher.ui.browse.card.ProjectCardViewStyle; +import net.modtale.launcher.ui.browse.controls.ProjectBrowseSort; +import net.modtale.launcher.ui.common.LauncherExternalLinks; +import net.modtale.launcher.ui.common.LauncherIcons; +import net.modtale.launcher.ui.common.LauncherView; +import net.modtale.launcher.ui.feedback.LauncherFeedback; +import net.modtale.launcher.ui.settings.LauncherSettingsController; +import net.modtale.launcher.ui.settings.LauncherSettingsForm; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +public final class LauncherPlayController { + + private static final Logger LOG = LogManager.getLogger(LauncherPlayController.class); + + private static final int BLOG_POST_PAGE_SIZE = 4; + private static final int FRIEND_LIMIT = 6; + private static final double SIDEBAR_WIDTH = 336; + private static final double SIDEBAR_PREF_HEIGHT = 672; + private static final double BLOG_POST_LOAD_THRESHOLD = 0.82; + private static final double BLOG_POST_FILL_PADDING = 96; + private static final double NEWS_THUMBNAIL_WIDTH = 264; + private static final double NEWS_THUMBNAIL_HEIGHT = 149; + private static final double FRIEND_AVATAR_SIZE = 34; + private static final double IDENTITY_AVATAR_SIZE = 40; + private static final double IDENTITY_MENU_AVATAR_SIZE = 28; + private static final double PROFILE_AVATAR_RADIUS = 8; + private static final double PLAY_BUTTON_FONT_SIZE = 22; + private static final int CATALOG_SHELF_LIMIT = 6; + private static final double CATALOG_CARD_WIDTH = 336; + private static final double CATALOG_GRID_GAP = 18; + private static final double CATALOG_GRID_CARD_BODY_HEIGHT = 178; + private static final double CATALOG_CARD_HEIGHT = Math.round(CATALOG_CARD_WIDTH / 3.0) + CATALOG_GRID_CARD_BODY_HEIGHT; + private static final double CATALOG_SCROLL_HEIGHT = CATALOG_CARD_HEIGHT + 18; + private static final double CATALOG_STAGE_TOP_MARGIN = 8; + private static final double CATALOG_STAGE_LEFT_OFFSET = -12; + private static final double CATALOG_STAGE_GAP = 14; + private static final double CATALOG_SECTION_GAP = 8; + private static final double CATALOG_HEADER_HEIGHT = 30; + private static final double PLAY_DOCK_BOTTOM_MARGIN = 96; + private static final double PLAY_DOCK_CLEARANCE = 24; + private static final int HYVATAR_RENDER_SIZE = 256; + private static final DateTimeFormatter BLOG_DATE = DateTimeFormatter.ofPattern("MMM d").withZone(ZoneId.systemDefault()); + private static final DateTimeFormatter BLOG_DATE_WITH_YEAR = DateTimeFormatter.ofPattern("MMM d, yyyy").withZone(ZoneId.systemDefault()); + + private final ModtaleApiClient apiClient; + private final ProjectCardFactory projectCardFactory; + private final HytaleAuthService hytaleAuthService; + private final HytaleGameLauncher hytaleGameLauncher; + private final DiscordRichPresenceService discordRichPresence; + private final LauncherSettingsController settingsController; + private final LauncherFeedback feedback; + private final Executor executor; + private final BooleanSupplier modtaleApiAvailable; + private final Function favoriteResolver; + private final Supplier gameVersion; + private final Consumer onInstall; + private final Consumer onOpenPage; + private final Consumer onOpenCreator; + private final Consumer onToggleFavorite; + private final Label hytaleStatus = new Label("Hytale signed out"); + private final Label buildMetric = new Label("Unset"); + private final Label patchlineMetric = new Label("Latest release"); + private final Label playtimeMetric = new Label("No playtime yet"); + private final Button identityButton = new Button(); + private final StackPane identityAvatar = new StackPane(); + private final Label identityTitle = new Label("Signed out"); + private final Label identitySubtitle = new Label("Add Hytale account"); + private final VBox friendsList = new VBox(9); + private final VBox newsList = new VBox(12); + private final CatalogShelf newReleasesShelf = new CatalogShelf(ProjectBrowseSort.NEWEST); + private final CatalogShelf trendingShelf = new CatalogShelf(ProjectBrowseSort.TRENDING); + private final Map imageCache = new ConcurrentHashMap<>(); + + private volatile Process hytaleProcess; + private boolean versionsLoading; + private boolean suppressBranchVersionLoad; + private boolean friendsLoading; + private boolean playtimeLoading; + private boolean blogPostsLoading; + private boolean blogPostsLoaded; + private boolean blogPostsComplete; + private boolean blogFillCheckScheduled; + private List blogPosts = List.of(); + private int renderedBlogPosts; + private long versionLoadRetryAfterMillis; + private long versionRetryScheduledAtMillis; + private String loadedVersionsKey = ""; + private String loadedFriendsKey = ""; + private String loadedPlaytimeKey = ""; + private Node view; + private Node newReleasesSection; + private VBox setupPopover; + private ScrollPane sidebarScroll; + private ContextMenu identityMenu; + private long identityMenuHiddenAtMillis; + private Runnable onHytaleAccountsChanged = () -> { + }; + private Consumer onBrowseCatalog = sort -> { + }; + + public LauncherPlayController( + ModtaleApiClient apiClient, + ProjectCardFactory projectCardFactory, + HytaleAuthService hytaleAuthService, + HytaleGameLauncher hytaleGameLauncher, + DiscordRichPresenceService discordRichPresence, + LauncherSettingsController settingsController, + LauncherFeedback feedback, + Executor executor, + BooleanSupplier modtaleApiAvailable, + Function favoriteResolver, + Supplier gameVersion, + Consumer onInstall, + Consumer onOpenPage, + Consumer onOpenCreator, + Consumer onToggleFavorite + ) { + this.apiClient = apiClient; + this.projectCardFactory = projectCardFactory; + this.hytaleAuthService = hytaleAuthService; + this.hytaleGameLauncher = hytaleGameLauncher; + this.discordRichPresence = discordRichPresence; + this.settingsController = settingsController; + this.feedback = feedback; + this.executor = executor; + this.modtaleApiAvailable = modtaleApiAvailable == null ? () -> true : modtaleApiAvailable; + this.favoriteResolver = favoriteResolver == null ? id -> false : favoriteResolver; + this.gameVersion = gameVersion == null ? () -> "" : gameVersion; + this.onInstall = onInstall == null ? project -> { + } : onInstall; + this.onOpenPage = onOpenPage == null ? project -> { + } : onOpenPage; + this.onOpenCreator = onOpenCreator == null ? project -> { + } : onOpenCreator; + this.onToggleFavorite = onToggleFavorite == null ? project -> { + } : onToggleFavorite; + } + + public Node view() { + if (view == null) { + view = buildView(); + } + return view; + } + + public void setOnHytaleAccountsChanged(Runnable onHytaleAccountsChanged) { + this.onHytaleAccountsChanged = onHytaleAccountsChanged == null ? () -> { + } : onHytaleAccountsChanged; + } + + public void setOnBrowseCatalog(Consumer onBrowseCatalog) { + this.onBrowseCatalog = onBrowseCatalog == null ? sort -> { + } : onBrowseCatalog; + } + + public void syncMetrics() { + LauncherSettings settings = settingsController.settings(); + HytaleAuthSession session = settings.getHytaleAuthSession(); + syncIdentitySummary(settings, session); + buildMetric.setText(selectedVersionLabel(settings)); + syncPatchlineMetric(settings.getHytaleBranch()); + syncPlaytimeMetric(session); + syncSidebarData(settings, session); + syncCatalogShelves(false); + maybeLoadHytaleVersions(); + maybeLoadHytalePlaytime(); + } + + public void refreshCatalogShelves() { + resetCatalogShelf(newReleasesShelf); + resetCatalogShelf(trendingShelf); + syncCatalogShelves(true); + } + + public void resetCatalogShelves() { + resetCatalogShelf(newReleasesShelf); + resetCatalogShelf(trendingShelf); + showCatalogSignInMessage(); + } + + public void loadHytaleVersions() { + loadHytaleVersions(false); + } + + private void loadHytaleVersions(boolean force) { + LauncherSettings settings = settingsController.settings(); + HytaleAuthSession session = settings.getHytaleAuthSession(); + if (session == null || !session.hasRefreshToken()) { + return; + } + String versionKey = versionsKey(settings, session); + LauncherSettingsForm form = settingsController.form(); + applyCachedHytaleVersions(settings, session, form); + if (versionsLoading || (!force && versionKey.equals(loadedVersionsKey) && !form.hytaleVersionCombo().getItems().isEmpty())) { + return; + } + long now = System.currentTimeMillis(); + if (versionLoadRetryAfterMillis > now) { + return; + } + + versionsLoading = true; + form.hytaleVersionCombo().setPromptText("Loading builds"); + settingsController.saveFromFields(false); + feedback.log("Loading Hytale builds..."); + CompletableFuture.supplyAsync(() -> { + LauncherSettings currentSettings = settingsController.settings(); + return loadAllHytaleVersions(currentSettings, session); + }, executor).whenComplete((payload, error) -> Platform.runLater(() -> { + versionsLoading = false; + if (error != null) { + long retryDelay = versionLoadRetryDelayMillis(error); + versionLoadRetryAfterMillis = System.currentTimeMillis() + retryDelay; + boolean cached = applyCachedHytaleVersions(settingsController.settings(), session, form); + if (!cached) { + form.hytaleVersionCombo().setPromptText("Build unavailable"); + } + scheduleHytaleVersionRetry(retryDelay); + feedback.log(hytaleBuildLoadErrorMessage(error) + (cached ? " Using cached builds." : "")); + return; + } + cacheHytaleVersions(settingsController.settings(), session, payload); + form.setHytalePatchlines(payload.patchlines(), payload.selectedPatchline()); + List versions = payload.versionsForSelectedPatchline(); + if (versions.isEmpty() && payload.rateLimit() != null) { + versions = settingsController.settings().cachedHytaleVersions( + LauncherSettings.hytaleAccountId(session), + hytaleVersionCachePlatform(), + payload.selectedPatchline() + ); + } + if (payload.rateLimit() == null) { + loadedVersionsKey = versionsKey(settingsController.settings(), session); + } else { + loadedVersionsKey = ""; + } + form.hytaleVersionCombo().setPromptText("Choose build"); + form.hytaleVersionCombo().setItems(FXCollections.observableArrayList(versions)); + settingsController.selectConfiguredHytaleBuild(); + settingsController.saveFromFields(false); + long labeledVersions = versions.stream().filter(version -> !version.gameVersion().isBlank()).count(); + feedback.log(hytaleVersionLoadMessage(payload, versions.size(), labeledVersions)); + if (payload.rateLimit() == null) { + versionLoadRetryAfterMillis = 0; + versionRetryScheduledAtMillis = 0; + } else { + long retryDelay = versionLoadRetryDelayMillis(payload.rateLimit()); + versionLoadRetryAfterMillis = System.currentTimeMillis() + retryDelay; + scheduleHytaleVersionRetry(retryDelay); + } + syncMetrics(); + })); + } + + private HytaleVersionsPayload loadAllHytaleVersions(LauncherSettings settings, HytaleAuthSession session) { + String accountId = LauncherSettings.hytaleAccountId(session); + String platform = hytaleVersionCachePlatform(); + List cachedPatchlines = settings.cachedHytalePatchlines(accountId, platform); + List patchlines; + try { + patchlines = hytaleAuthService.getAvailablePatchlines(settings); + } catch (HytaleApiException ex) { + if (ex.statusCode() != 429 || cachedPatchlines.isEmpty()) { + throw ex; + } + String selectedPatchline = selectedPatchline(settings.getHytaleBranch(), cachedPatchlines); + List pending = refreshPatchlineOrder(settings, accountId, platform, cachedPatchlines, selectedPatchline); + return new HytaleVersionsPayload(cachedPatchlines, selectedPatchline, Map.of(), pending, ex); + } + + String selectedPatchline = selectedPatchline(settings.getHytaleBranch(), patchlines); + List refreshOrder = refreshPatchlineOrder(settings, accountId, platform, patchlines, selectedPatchline); + Map> versionsByPatchline = new LinkedHashMap<>(); + for (int index = 0; index < refreshOrder.size(); index++) { + String patchline = refreshOrder.get(index); + try { + versionsByPatchline.put(patchline, hytaleAuthService.getAvailableVersions(settings, patchline)); + } catch (HytaleApiException ex) { + if (ex.statusCode() != 429) { + throw ex; + } + return new HytaleVersionsPayload( + patchlines, + selectedPatchline, + versionsByPatchline, + refreshOrder.subList(index, refreshOrder.size()), + ex + ); + } + } + return new HytaleVersionsPayload(patchlines, selectedPatchline, versionsByPatchline, List.of(), null); + } + + private List refreshPatchlineOrder( + LauncherSettings settings, + String accountId, + String platform, + List patchlines, + String selectedPatchline + ) { + LinkedHashSet available = new LinkedHashSet<>(orderedPatchlines(patchlines)); + LinkedHashSet ordered = new LinkedHashSet<>(); + settings.pendingHytalePatchlines(accountId, platform).stream() + .map(HytaleApiClient::normalizeBranch) + .filter(available::contains) + .forEach(ordered::add); + String selected = HytaleApiClient.normalizeBranch(selectedPatchline); + if (available.contains(selected)) { + ordered.add(selected); + } + ordered.addAll(available); + return List.copyOf(ordered); + } + + private boolean applyCachedHytaleVersions(LauncherSettings settings, HytaleAuthSession session, LauncherSettingsForm form) { + String accountId = LauncherSettings.hytaleAccountId(session); + if (accountId.isBlank()) { + return false; + } + String platform = hytaleVersionCachePlatform(); + List cachedPatchlines = settings.cachedHytalePatchlines(accountId, platform); + String selectedPatchline = cachedPatchlines.isEmpty() + ? HytaleApiClient.normalizeBranch(settings.getHytaleBranch()) + : selectedPatchline(settings.getHytaleBranch(), cachedPatchlines); + if (!cachedPatchlines.isEmpty()) { + suppressBranchVersionLoad = true; + try { + form.setHytalePatchlines(cachedPatchlines, selectedPatchline); + } finally { + suppressBranchVersionLoad = false; + } + settings.setHytaleBranch(form.hytaleBranchCombo().getValue()); + } + List cachedVersions = settings.cachedHytaleVersions(accountId, platform, selectedPatchline); + if (cachedVersions.isEmpty()) { + return false; + } + form.hytaleVersionCombo().setPromptText("Choose build"); + form.hytaleVersionCombo().setItems(FXCollections.observableArrayList(cachedVersions)); + settingsController.selectConfiguredHytaleBuild(); + settingsController.applyFromFields(); + buildMetric.setText(selectedVersionLabel(settings)); + return true; + } + + private void cacheHytaleVersions(LauncherSettings settings, HytaleAuthSession session, HytaleVersionsPayload payload) { + String accountId = LauncherSettings.hytaleAccountId(session); + if (accountId.isBlank() || payload == null) { + return; + } + String platform = hytaleVersionCachePlatform(); + settings.cacheHytalePatchlines(accountId, platform, payload.patchlines()); + settings.cachePendingHytalePatchlines(accountId, platform, payload.pendingPatchlines()); + payload.versionsByPatchline().forEach((patchline, versions) -> + settings.cacheHytaleVersions(accountId, platform, patchline, versions)); + settingsController.saveCurrentSettings(); + } + + private static String hytaleVersionCachePlatform() { + return HytalePlatform.os() + "/" + HytalePlatform.arch(); + } + + private String hytaleVersionLoadMessage(HytaleVersionsPayload payload, int selectedBuildCount, long labeledVersions) { + int loadedPatchlines = payload.versionsByPatchline().size(); + int totalPatchlines = payload.patchlines().size(); + String selectedLabel = LauncherSettingsForm.hytalePatchlineLabel(payload.selectedPatchline()); + if (payload.rateLimit() != null) { + String next = payload.pendingPatchlines().isEmpty() + ? "" + : " Next refresh will start with " + LauncherSettingsForm.hytalePatchlineLabel(payload.pendingPatchlines().getFirst()) + "."; + return "Cached Hytale builds for " + loadedPatchlines + "/" + totalPatchlines + + " patchlines before rate limiting." + next; + } + if (selectedBuildCount == 0) { + return "Hytale did not expose any " + selectedLabel + " builds for this platform."; + } + return "Cached Hytale builds for " + loadedPatchlines + "/" + totalPatchlines + + " patchlines. " + selectedLabel + " has " + selectedBuildCount + " build" + plural(selectedBuildCount) + + (labeledVersions == 0 ? "." : " with " + labeledVersions + " official version label" + plural((int) labeledVersions) + "."); + } + + private void scheduleHytaleVersionRetry(long delayMillis) { + if (delayMillis <= 0) { + return; + } + long scheduledAt = System.currentTimeMillis() + delayMillis; + if (versionRetryScheduledAtMillis >= scheduledAt - 500) { + return; + } + versionRetryScheduledAtMillis = scheduledAt; + CompletableFuture.delayedExecutor(delayMillis, java.util.concurrent.TimeUnit.MILLISECONDS, executor) + .execute(() -> Platform.runLater(() -> { + if (System.currentTimeMillis() + 500 >= versionLoadRetryAfterMillis) { + loadHytaleVersions(false); + } + })); + } + + public void launchHytale() { + if (isHytaleRunning()) { + feedback.showToast("Already running", "Hytale is already running."); + return; + } + settingsController.saveFromFields(false); + feedback.runAsync("Launching Hytale...", () -> hytaleGameLauncher.launch(settingsController.settings()), result -> { + hytaleProcess = result.process(); + long startedAtMillis = System.currentTimeMillis(); + discordRichPresence.showPlayingHytale(selectedVersionLabel(settingsController.settings()), startedAtMillis); + monitorHytaleProcess(result, startedAtMillis); + String build = settingsController.settings().getHytaleBuild() > 0 + ? " build " + settingsController.settings().getHytaleBuild() + : ""; + feedback.log("Launched Hytale" + build + " as " + result.username() + "."); + feedback.showToast("Hytale ready", "Launching as " + result.username() + "."); + syncMetrics(); + }); + } + + private Node buildView() { + configureSelectors(); + + StackPane root = new StackPane(); + root.setUserData(LauncherView.PLAY); + root.getStyleClass().addAll("view", "play-view"); + root.setMinHeight(720); + root.setPrefHeight(720); + root.setMaxHeight(Double.MAX_VALUE); + + HBox shell = new HBox(34); + shell.getStyleClass().add("play-shell"); + shell.setAlignment(Pos.CENTER_LEFT); + shell.setFillHeight(true); + shell.setMaxHeight(Double.MAX_VALUE); + StackPane.setAlignment(shell, Pos.CENTER); + + StackPane stage = new StackPane(); + stage.getStyleClass().add("play-stage"); + stage.setMinWidth(0); + stage.setMaxWidth(Double.MAX_VALUE); + stage.setMinHeight(0); + stage.setMaxHeight(Double.MAX_VALUE); + Node catalog = catalogStage(); + if (catalog instanceof Region catalogRegion) { + catalogRegion.prefWidthProperty().bind(stage.widthProperty()); + } + stage.getChildren().add(catalog); + HBox.setHgrow(stage, Priority.ALWAYS); + + VBox dock = new VBox(0); + dock.getStyleClass().add("play-dock"); + dock.setAlignment(Pos.CENTER); + dock.setFillWidth(false); + dock.setMaxSize(Region.USE_PREF_SIZE, Region.USE_PREF_SIZE); + dock.getChildren().add(launchControl()); + + setupPopover = setupPopover(); + setVisibleManaged(setupPopover, false); + + Node sidebar = sidebarFrame(); + HBox.setHgrow(sidebar, Priority.NEVER); + shell.getChildren().addAll(stage, sidebar); + root.getChildren().addAll(shell, dock, setupPopover); + StackPane.setAlignment(dock, Pos.BOTTOM_CENTER); + StackPane.setMargin(dock, new Insets(0, 0, PLAY_DOCK_BOTTOM_MARGIN, 0)); + StackPane.setAlignment(setupPopover, Pos.BOTTOM_CENTER); + StackPane.setMargin(setupPopover, new Insets(0, 0, 184, 0)); + bindNewReleasesVisibility(root, dock); + syncMetrics(); + return root; + } + + private Node catalogStage() { + VBox content = new VBox(CATALOG_STAGE_GAP); + content.getStyleClass().add("play-catalog-stage"); + content.setAlignment(Pos.TOP_LEFT); + content.setFillWidth(true); + content.setMinWidth(0); + content.setMaxWidth(Double.MAX_VALUE); + Node trendingSection = catalogShelf(trendingShelf, "Trending"); + newReleasesSection = catalogShelf(newReleasesShelf, "New Releases"); + content.getChildren().addAll(trendingSection, newReleasesSection); + StackPane.setAlignment(content, Pos.TOP_LEFT); + StackPane.setMargin(content, new Insets(CATALOG_STAGE_TOP_MARGIN, 0, 0, CATALOG_STAGE_LEFT_OFFSET)); + return content; + } + + private Node catalogShelf(CatalogShelf shelf, String title) { + VBox section = new VBox(CATALOG_SECTION_GAP); + section.getStyleClass().add("play-catalog-section"); + section.setMinWidth(0); + section.setMaxWidth(Double.MAX_VALUE); + section.setFillWidth(true); + section.setAlignment(Pos.TOP_LEFT); + section.getChildren().addAll(catalogHeader(shelf, title), catalogBody(shelf)); + return section; + } + + private Node catalogHeader(CatalogShelf shelf, String title) { + HBox header = new HBox(10); + header.getStyleClass().add("play-catalog-header"); + header.setAlignment(Pos.CENTER_LEFT); + header.setMinWidth(0); + header.setMaxWidth(Double.MAX_VALUE); + + Label label = new Label(title); + label.getStyleClass().add("play-catalog-title"); + + Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + + Button browse = new Button("Browse All", LauncherIcons.icon(LauncherIcons.Glyph.EXTERNAL_LINK, 13)); + browse.getStyleClass().add("play-catalog-browse-all"); + browse.setTooltip(new Tooltip("Browse all " + title.toLowerCase())); + browse.setAccessibleText("Browse all " + title.toLowerCase()); + browse.setOnAction(event -> onBrowseCatalog.accept(shelf.sort)); + + header.getChildren().addAll(label, spacer, browse); + return header; + } + + private Node catalogBody(CatalogShelf shelf) { + StackPane body = new StackPane(); + body.getStyleClass().add("play-catalog-body"); + body.setAlignment(Pos.TOP_LEFT); + body.setMinWidth(0); + body.setMaxWidth(Double.MAX_VALUE); + + shelf.cardRow.getStyleClass().add("play-catalog-card-row"); + shelf.cardRow.setSpacing(CATALOG_GRID_GAP); + shelf.cardRow.setAlignment(Pos.TOP_LEFT); + + shelf.scroll.getStyleClass().add("play-catalog-scroll"); + shelf.scroll.setContent(shelf.cardRow); + shelf.scroll.setFitToHeight(true); + shelf.scroll.setFitToWidth(false); + shelf.scroll.setPannable(true); + shelf.scroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED); + shelf.scroll.setVbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + shelf.scroll.setMinSize(0, CATALOG_SCROLL_HEIGHT); + shelf.scroll.setPrefSize(0, CATALOG_SCROLL_HEIGHT); + shelf.scroll.setMaxSize(Double.MAX_VALUE, CATALOG_SCROLL_HEIGHT); + + shelf.message.getStyleClass().add("play-catalog-message"); + shelf.message.setWrapText(true); + shelf.message.setAlignment(Pos.CENTER); + shelf.message.setMaxWidth(Double.MAX_VALUE); + + body.getChildren().addAll(shelf.scroll, shelf.message); + showCatalogMessage(shelf, "Loading projects..."); + return body; + } + + private void bindNewReleasesVisibility(StackPane root, Region dock) { + if (newReleasesSection == null) { + return; + } + BooleanBinding hasVerticalRoom = Bindings.createBooleanBinding( + () -> root.getHeight() >= newReleasesRequiredHeight(dockHeight(dock)), + root.heightProperty(), + dock.layoutBoundsProperty() + ); + newReleasesSection.visibleProperty().bind(hasVerticalRoom); + newReleasesSection.managedProperty().bind(hasVerticalRoom); + } + + private static double newReleasesRequiredHeight(double dockHeight) { + return CATALOG_STAGE_TOP_MARGIN + + (catalogShelfHeight() * 2) + + CATALOG_STAGE_GAP + + PLAY_DOCK_BOTTOM_MARGIN + + dockHeight + + PLAY_DOCK_CLEARANCE; + } + + private static double catalogShelfHeight() { + return CATALOG_HEADER_HEIGHT + CATALOG_SECTION_GAP + CATALOG_SCROLL_HEIGHT; + } + + private static double dockHeight(Region dock) { + double height = dock.getLayoutBounds().getHeight(); + if (height <= 0 || Double.isNaN(height)) { + height = dock.prefHeight(-1); + } + return height <= 0 || Double.isNaN(height) ? 80 : height; + } + + private Node launchControl() { + VBox control = new VBox(9); + control.getStyleClass().add("play-launch-control"); + control.setAlignment(Pos.CENTER); + control.setMaxWidth(Region.USE_PREF_SIZE); + + HBox split = new HBox(0); + split.getStyleClass().add("play-launch-split"); + split.setAlignment(Pos.CENTER); + split.setMaxWidth(Region.USE_PREF_SIZE); + + Button play = new Button("Play"); + play.getStyleClass().add("play-launch-main"); + play.setFont(Font.font("Inter", FontWeight.EXTRA_BOLD, PLAY_BUTTON_FONT_SIZE)); + play.setOnAction(event -> launchHytale()); + + Button setup = new Button(null, LauncherIcons.icon(LauncherIcons.Glyph.CHEVRON_DOWN, 20)); + setup.getStyleClass().add("play-launch-arrow"); + setup.setTooltip(new Tooltip("Select launch build")); + setup.setOnAction(event -> showLaunchDropdown(setup)); + + split.getChildren().addAll(play, setup); + + HBox meta = new HBox(10); + meta.getStyleClass().add("play-launch-meta"); + meta.setAlignment(Pos.CENTER); + meta.getChildren().addAll( + launchMetaText("Version", buildMetric), + launchMetaText("Patchline", patchlineMetric), + launchMetaText("Playtime", playtimeMetric) + ); + + control.getChildren().addAll(split, meta); + return control; + } + + private Node launchMetaText(String label, Label valueLabel) { + HBox item = new HBox(5); + item.getStyleClass().add("play-launch-meta-item"); + item.setAlignment(Pos.CENTER_LEFT); + Label heading = new Label(label); + heading.getStyleClass().add("play-launch-meta-label"); + valueLabel.getStyleClass().add("play-launch-meta-value"); + item.getChildren().addAll(heading, valueLabel); + return item; + } + + private VBox setupPopover() { + VBox panel = new VBox(18); + panel.getStyleClass().add("play-setup-popover"); + panel.setAlignment(Pos.TOP_CENTER); + panel.setPrefWidth(430); + panel.setMaxWidth(430); + + Label title = new Label("Launch Setup"); + title.getStyleClass().add("play-panel-title"); + Label status = hytaleStatus; + status.getStyleClass().add("play-panel-status"); + + panel.getChildren().addAll(title, status, setupActions()); + return panel; + } + + private Node setupActions() { + Button stop = dangerButton("Stop"); + stop.setGraphic(LauncherIcons.icon(LauncherIcons.Glyph.X, 15)); + stop.setOnAction(event -> stopHytale()); + + FlowPane utilities = new FlowPane(10, 10, stop); + utilities.getStyleClass().add("play-utility-actions"); + utilities.setAlignment(Pos.CENTER); + return utilities; + } + + private Node sidebarFrame() { + StackPane frame = new StackPane(); + frame.getStyleClass().add("play-sidebar-frame"); + frame.setMinHeight(0); + frame.setMaxHeight(Double.MAX_VALUE); + Node scroll = sidebar(); + frame.getChildren().add(scroll); + return frame; + } + + private Node sidebar() { + VBox sidebar = new VBox(18); + sidebar.getStyleClass().add("play-sidebar"); + sidebar.getChildren().addAll( + identitySection(), + friendsSection(), + newsSection() + ); + + ScrollPane scroll = new ScrollPane(sidebar); + scroll.getStyleClass().add("play-sidebar-scroll"); + scroll.setFitToWidth(true); + scroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + scroll.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED); + scroll.setPannable(true); + scroll.setPrefWidth(SIDEBAR_WIDTH); + scroll.setMinWidth(SIDEBAR_WIDTH); + scroll.setMaxWidth(SIDEBAR_WIDTH); + scroll.setMinHeight(0); + scroll.setPrefHeight(SIDEBAR_PREF_HEIGHT); + scroll.setMaxHeight(Double.MAX_VALUE); + scroll.vvalueProperty().addListener((observable, previous, value) -> maybeAppendBlogPosts(false)); + scroll.viewportBoundsProperty().addListener((observable, previous, value) -> scheduleBlogFillCheck()); + sidebar.heightProperty().addListener((observable, previous, value) -> scheduleBlogFillCheck()); + sidebarScroll = scroll; + return scroll; + } + + private Node identitySection() { + VBox section = sidebarSection("Playing as"); + configureIdentityButton(); + section.getChildren().add(identityButton); + return section; + } + + private Node friendsSection() { + VBox section = sidebarSection(); + section.getChildren().add(sectionHeader("Friends", LauncherIcons.Glyph.REFRESH_CW, "Refresh Hytale friends", + () -> loadHytaleFriends(true))); + friendsList.getStyleClass().add("play-friends-list"); + section.getChildren().add(friendsList); + return section; + } + + private Node newsSection() { + VBox section = sidebarSection(); + section.getStyleClass().add("last"); + section.getChildren().add(sectionHeader("News", LauncherIcons.Glyph.EXTERNAL_LINK, "Open Hytale blog", + () -> LauncherExternalLinks.open("https://hytale.com/news", feedback::showToast))); + newsList.getStyleClass().add("play-news-list"); + section.getChildren().add(newsList); + return section; + } + + private VBox sidebarSection(String title) { + VBox section = new VBox(11); + section.getStyleClass().add("play-sidebar-section"); + Label heading = new Label(title); + heading.getStyleClass().add("play-sidebar-section-title"); + section.getChildren().add(heading); + return section; + } + + private VBox sidebarSection() { + VBox section = new VBox(11); + section.getStyleClass().add("play-sidebar-section"); + return section; + } + + private Node sectionHeader(String title, LauncherIcons.Glyph glyph, String tooltip, Runnable action) { + HBox header = new HBox(10); + header.getStyleClass().add("play-sidebar-header"); + header.setAlignment(Pos.CENTER_LEFT); + Label label = new Label(title); + label.getStyleClass().add("play-sidebar-section-title"); + Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + Button button = new Button(null, LauncherIcons.icon(glyph, 15)); + button.getStyleClass().addAll("icon-btn", "play-sidebar-icon-button"); + button.setTooltip(new Tooltip(tooltip)); + button.setAccessibleText(tooltip); + button.setOnAction(event -> action.run()); + header.getChildren().addAll(label, spacer, button); + return header; + } + + private void configureIdentityButton() { + if (identityButton.getGraphic() != null) { + return; + } + identityButton.getStyleClass().add("play-identity-button"); + identityButton.setAccessibleText("Manage Hytale account and game profile"); + identityButton.setTooltip(new Tooltip("Manage Hytale account and game profile")); + identityButton.setContentDisplay(ContentDisplay.GRAPHIC_ONLY); + identityButton.setMaxWidth(Double.MAX_VALUE); + + HBox content = new HBox(11); + content.getStyleClass().add("play-identity-button-content"); + content.setAlignment(Pos.CENTER_LEFT); + identityAvatar.getStyleClass().add("play-identity-avatar"); + sizeSquare(identityAvatar, IDENTITY_AVATAR_SIZE); + updateImageAvatar(identityAvatar, "Hytale", IDENTITY_AVATAR_SIZE, PROFILE_AVATAR_RADIUS, ""); + + VBox copy = new VBox(3); + identityTitle.getStyleClass().add("play-identity-title"); + identitySubtitle.getStyleClass().add("play-identity-subtitle"); + identityTitle.setMaxWidth(Double.MAX_VALUE); + identitySubtitle.setMaxWidth(Double.MAX_VALUE); + copy.getChildren().addAll(identityTitle, identitySubtitle); + HBox.setHgrow(copy, Priority.ALWAYS); + + Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + StackPane chevron = new StackPane(LauncherIcons.icon(LauncherIcons.Glyph.CHEVRON_DOWN, 16)); + chevron.getStyleClass().add("play-identity-chevron"); + content.getChildren().addAll(identityAvatar, copy, spacer, chevron); + identityButton.setGraphic(content); + identityButton.setOnAction(event -> toggleIdentityMenu(identityButton)); + } + + private void configureSelectors() { + LauncherSettingsForm form = settingsController.form(); + form.hytaleBranchCombo().setPromptText("Channel"); + form.hytaleVersionCombo().setPromptText("Load builds"); + form.hytaleVersionCombo().setConverter(new StringConverter<>() { + @Override + public String toString(HytaleVersion version) { + return version == null ? "" : versionSwitchLabel(version); + } + + @Override + public HytaleVersion fromString(String value) { + return form.hytaleVersionCombo().getItems().stream() + .filter(version -> versionSwitchLabel(version).equals(value)) + .findFirst() + .orElse(null); + } + }); + form.hytaleVersionCombo().valueProperty().addListener((observable, previous, selected) -> { + if (selected != null) { + buildMetric.setText(versionSwitchLabel(selected)); + } else { + buildMetric.setText(selectedVersionLabel(settingsController.settings())); + } + }); + form.hytaleBranchCombo().valueProperty().addListener((observable, previous, selected) -> { + if (!suppressBranchVersionLoad && selected != null && !selected.equals(previous)) { + loadedVersionsKey = ""; + syncPatchlineMetric(selected); + maybeLoadHytaleVersions(); + } + }); + } + + private void syncSidebarData(LauncherSettings settings, HytaleAuthSession session) { + if (session == null) { + loadedFriendsKey = ""; + setFriendsMessage("Sign in with Hytale to see friends."); + } else { + String friendsKey = LauncherSettings.hytaleAccountId(session); + if (!friendsKey.equals(loadedFriendsKey) && !friendsLoading) { + loadHytaleFriends(false); + } + } + if (!blogPostsLoaded && !blogPostsLoading) { + loadBlogPosts(); + } + } + + private void syncCatalogShelves(boolean force) { + if (!modtaleApiAvailable.getAsBoolean()) { + showCatalogSignInMessage(); + return; + } + loadCatalogShelf(newReleasesShelf, force); + loadCatalogShelf(trendingShelf, force); + } + + private void loadCatalogShelf(CatalogShelf shelf, boolean force) { + if (!modtaleApiAvailable.getAsBoolean()) { + resetCatalogShelf(shelf); + showCatalogMessage(shelf, "Sign in with Modtale to see " + shelf.sort.title().toLowerCase() + "."); + return; + } + if (shelf.loading && !force) { + return; + } + if (shelf.loaded && !force) { + return; + } + shelf.loading = true; + long requestId = ++shelf.requestId; + showCatalogMessage(shelf, "Loading projects..."); + CompletableFuture.supplyAsync(() -> apiClient.searchProjects(catalogQuery(shelf.sort)), executor) + .whenComplete((page, error) -> Platform.runLater(() -> { + if (requestId != shelf.requestId) { + return; + } + shelf.loading = false; + if (error != null) { + Throwable cause = unwrap(error); + String detail = cause == null || cause.getMessage() == null || cause.getMessage().isBlank() + ? "Try refreshing in a moment." + : cause.getMessage(); + showCatalogMessage(shelf, "Could not load " + shelf.sort.title().toLowerCase() + ". " + detail); + return; + } + shelf.loaded = true; + renderCatalogShelf(shelf, page == null ? List.of() : page.content()); + })); + } + + private ProjectSearchQuery catalogQuery(ProjectBrowseSort sort) { + return new ProjectSearchQuery( + "", + null, + null, + sort.apiValue(), + 0, + CATALOG_SHELF_LIMIT, + null, + null, + null, + null, + null, + null + ); + } + + private void renderCatalogShelf(CatalogShelf shelf, List projects) { + shelf.cardRow.getChildren().clear(); + if (projects.isEmpty()) { + showCatalogMessage(shelf, "No " + shelf.sort.title().toLowerCase() + " found."); + return; + } + setVisibleManaged(shelf.message, false); + setVisibleManaged(shelf.scroll, true); + int cardCount = Math.min(projects.size(), CATALOG_SHELF_LIMIT); + String selectedGameVersion = gameVersion.get(); + for (int index = 0; index < cardCount; index++) { + ProjectSummary project = projects.get(index); + shelf.cardRow.getChildren().add(catalogProjectCard(project, selectedGameVersion)); + } + shelf.scroll.setHvalue(shelf.scroll.getHmin()); + } + + private Node catalogProjectCard(ProjectSummary project, String selectedGameVersion) { + return projectCardFactory.create( + project, + ProjectCardViewStyle.GRID, + selectedGameVersion, + Boolean.TRUE.equals(favoriteResolver.apply(project.id())), + onInstall, + onOpenPage, + onOpenCreator, + onToggleFavorite, + CATALOG_CARD_WIDTH, + CATALOG_CARD_HEIGHT + ); + } + + private void showCatalogSignInMessage() { + showCatalogMessage(newReleasesShelf, "Sign in with Modtale to see new releases."); + showCatalogMessage(trendingShelf, "Sign in with Modtale to see trending projects."); + } + + private void showCatalogMessage(CatalogShelf shelf, String message) { + shelf.cardRow.getChildren().clear(); + shelf.message.setText(message == null ? "" : message); + setVisibleManaged(shelf.scroll, false); + setVisibleManaged(shelf.message, true); + } + + private void resetCatalogShelf(CatalogShelf shelf) { + shelf.requestId++; + shelf.loading = false; + shelf.loaded = false; + shelf.cardRow.getChildren().clear(); + } + + private void syncIdentitySummary(LauncherSettings settings, HytaleAuthSession session) { + if (session == null) { + hytaleStatus.setText("Signed out"); + identityTitle.setText("Signed out"); + identitySubtitle.setText("Add Hytale account"); + updateImageAvatar(identityAvatar, "Hytale", IDENTITY_AVATAR_SIZE, PROFILE_AVATAR_RADIUS, ""); + return; + } + + List profiles = profilesFor(session); + HytaleProfile selectedProfile = selectedProfileFor(session); + String selectedProfileName = selectedProfile.displayName(); + updateHytaleProfileAvatar(identityAvatar, selectedProfileName, IDENTITY_AVATAR_SIZE); + hytaleStatus.setText("Ready as " + selectedProfileName); + identityTitle.setText(selectedProfileName); + String account = accountLabel(session); + String profileCount = profiles.size() <= 1 ? "Hytale account" : profiles.size() + " profiles"; + identitySubtitle.setText(account.equals(selectedProfileName) ? profileCount : account + " - " + profileCount); + } + + private String formatPlaytime(long seconds) { + if (seconds <= 0) { + return "No playtime yet"; + } + long minutes = Math.max(1, seconds / 60); + long hours = minutes / 60; + long days = hours / 24; + if (days > 0) { + return days + "d " + (hours % 24) + "h played"; + } + if (hours > 0) { + return hours + "h " + (minutes % 60) + "m played"; + } + return minutes + "m played"; + } + + private void syncPlaytimeMetric(HytaleAuthSession session) { + if (session == null || !session.hasRefreshToken()) { + loadedPlaytimeKey = ""; + playtimeMetric.setText("No playtime yet"); + return; + } + if (playtimeLoading) { + playtimeMetric.setText("Loading playtime..."); + return; + } + playtimeMetric.setText(formatPlaytime(selectedProfileFor(session).playtimeSeconds())); + } + + private void loadHytalePlaytime(boolean force) { + LauncherSettings settings = settingsController.settings(); + HytaleAuthSession session = settings.getHytaleAuthSession(); + if (session == null || !session.hasRefreshToken()) { + loadedPlaytimeKey = ""; + playtimeMetric.setText("No playtime yet"); + return; + } + String playtimeKey = playtimeKey(session); + if (playtimeLoading || (!force && playtimeKey.equals(loadedPlaytimeKey))) { + return; + } + + playtimeLoading = true; + playtimeMetric.setText("Loading playtime..."); + CompletableFuture.supplyAsync(() -> hytaleAuthService.getProfilePlaytimeSeconds(settingsController.settings()), executor) + .whenComplete((seconds, error) -> Platform.runLater(() -> { + playtimeLoading = false; + HytaleAuthSession activeSession = settingsController.settings().getHytaleAuthSession(); + String activeKey = activeSession == null ? "" : playtimeKey(activeSession); + if (error != null && requiresHytaleSignIn(error)) { + loadedPlaytimeKey = ""; + settingsController.reloadControls(); + syncMetrics(); + onHytaleAccountsChanged.run(); + return; + } + if (!playtimeKey.equals(activeKey)) { + return; + } + if (error != null) { + loadedPlaytimeKey = playtimeKey; + playtimeMetric.setText(playtimeErrorMessage(error)); + return; + } + loadedPlaytimeKey = playtimeKey; + playtimeMetric.setText(formatPlaytime(seconds == null ? 0 : seconds)); + })); + } + + private void loadHytaleFriends(boolean force) { + LauncherSettings settings = settingsController.settings(); + HytaleAuthSession session = settings.getHytaleAuthSession(); + if (session == null || !session.hasRefreshToken()) { + loadedFriendsKey = ""; + setFriendsMessage("Sign in with Hytale to see friends."); + return; + } + String friendsKey = LauncherSettings.hytaleAccountId(session); + if (friendsLoading || (!force && friendsKey.equals(loadedFriendsKey))) { + return; + } + friendsLoading = true; + setFriendsMessage("Loading Hytale friends..."); + CompletableFuture.supplyAsync(() -> hytaleAuthService.getFriends(settingsController.settings()), executor) + .whenComplete((friends, error) -> Platform.runLater(() -> { + friendsLoading = false; + HytaleAuthSession activeSession = settingsController.settings().getHytaleAuthSession(); + String activeKey = activeSession == null ? "" : LauncherSettings.hytaleAccountId(activeSession); + if (error != null && requiresHytaleSignIn(error)) { + loadedFriendsKey = ""; + settingsController.reloadControls(); + syncMetrics(); + onHytaleAccountsChanged.run(); + return; + } + if (!friendsKey.equals(activeKey)) { + return; + } + if (error != null) { + setFriendsMessage(friendsErrorMessage(error)); + return; + } + loadedFriendsKey = friendsKey; + renderFriends(friends == null ? List.of() : friends); + })); + } + + private void renderFriends(List friends) { + friendsList.getChildren().clear(); + if (friends.isEmpty()) { + friendsList.getChildren().add(messageRow("No Hytale friends were returned for this account.")); + return; + } + friends.stream() + .limit(FRIEND_LIMIT) + .map(this::friendRow) + .forEach(friendsList.getChildren()::add); + if (friends.size() > FRIEND_LIMIT) { + friendsList.getChildren().add(messageRow("+" + (friends.size() - FRIEND_LIMIT) + " more in-game.")); + } + } + + private Node friendRow(HytaleFriend friend) { + HBox row = new HBox(10); + row.getStyleClass().add("play-friend-row"); + row.setAlignment(Pos.CENTER_LEFT); + + StackPane avatar = friend.username().isBlank() + ? avatar(friend.displayName(), FRIEND_AVATAR_SIZE, "play-friend-avatar", friend.avatarUrl()) + : hytaleProfileAvatar(friend.username(), FRIEND_AVATAR_SIZE, "play-friend-avatar"); + Region presence = new Region(); + presence.getStyleClass().addAll("play-friend-presence", friend.online() ? "online" : "offline"); + StackPane.setAlignment(presence, Pos.BOTTOM_RIGHT); + avatar.getChildren().add(presence); + + VBox copy = new VBox(2); + Label name = new Label(friend.displayName()); + name.getStyleClass().add("play-friend-name"); + Label status = new Label(friend.displayStatus()); + status.getStyleClass().add("play-friend-status"); + copy.getChildren().addAll(name, status); + HBox.setHgrow(copy, Priority.ALWAYS); + + row.getChildren().addAll(avatar, copy); + return row; + } + + private void setFriendsMessage(String message) { + friendsList.getChildren().setAll(messageRow(message)); + } + + private static String friendsErrorMessage(Throwable error) { + Throwable cause = unwrap(error); + if (cause instanceof HytaleApiException hytaleEx && hytaleEx.requiresSignIn()) { + return "Sign in with Hytale to see friends."; + } + return "Friends are available in-game. Modtale could not read a launcher friend list yet."; + } + + private static String playtimeErrorMessage(Throwable error) { + Throwable cause = unwrap(error); + if (cause instanceof HytaleApiException hytaleEx && hytaleEx.requiresSignIn()) { + return "Sign in required"; + } + return "Playtime unavailable"; + } + + private static String hytaleBuildLoadErrorMessage(Throwable error) { + Throwable cause = unwrap(error); + if (cause instanceof HytaleApiException hytaleEx && hytaleEx.statusCode() == 429) { + long seconds = Math.max(1, versionLoadRetryDelayMillis(error) / 1000); + return "Hytale build API is rate limited. Waiting " + seconds + "s before refreshing builds again."; + } + String message = cause == null ? "" : cause.getMessage(); + return message == null || message.isBlank() + ? "Could not load Hytale builds." + : "Could not load Hytale builds: " + message; + } + + private static long versionLoadRetryDelayMillis(Throwable error) { + Throwable cause = unwrap(error); + if (cause instanceof HytaleApiException hytaleEx && hytaleEx.statusCode() == 429) { + return hytaleEx.retryAfterMillis() > 0 ? hytaleEx.retryAfterMillis() : 60_000; + } + return 15_000; + } + + private static boolean requiresHytaleSignIn(Throwable error) { + Throwable cause = unwrap(error); + return cause instanceof HytaleApiException hytaleEx && hytaleEx.requiresSignIn(); + } + + private static Throwable unwrap(Throwable error) { + Throwable cause = error; + while (cause instanceof CompletionException && cause.getCause() != null) { + cause = cause.getCause(); + } + return cause == null ? error : cause; + } + + private void loadBlogPosts() { + blogPostsLoading = true; + blogPostsComplete = false; + newsList.getChildren().setAll(messageRow("Loading Hytale posts...")); + CompletableFuture.supplyAsync(hytaleAuthService::getAllBlogPosts, executor) + .whenComplete((posts, error) -> Platform.runLater(() -> { + blogPostsLoading = false; + if (error != null) { + newsList.getChildren().setAll(messageRow("Could not load the Hytale blog.")); + return; + } + blogPostsLoaded = true; + renderInitialBlogPosts(posts == null ? List.of() : posts); + })); + } + + private void renderInitialBlogPosts(List posts) { + blogPosts = posts; + renderedBlogPosts = 0; + blogPostsComplete = posts.isEmpty(); + newsList.getChildren().clear(); + if (posts.isEmpty()) { + newsList.getChildren().add(messageRow("No Hytale blog posts found.")); + return; + } + appendNextBlogPosts(); + scheduleBlogFillCheck(); + } + + private void maybeAppendBlogPosts(boolean fillViewport) { + if (blogPostsLoading || !blogPostsLoaded || blogPostsComplete || sidebarScroll == null) { + return; + } + if (renderedBlogPosts >= blogPosts.size()) { + markBlogPostsComplete(); + return; + } + double viewportHeight = sidebarScroll.getViewportBounds().getHeight(); + double contentHeight = sidebarScroll.getContent() == null + ? 0 + : sidebarScroll.getContent().getBoundsInLocal().getHeight(); + boolean contentShort = viewportHeight <= 0 || contentHeight <= viewportHeight + BLOG_POST_FILL_PADDING; + boolean nearBottom = sidebarScroll.getVvalue() >= BLOG_POST_LOAD_THRESHOLD; + if (!fillViewport && !nearBottom) { + return; + } + if (fillViewport && !contentShort && !nearBottom) { + return; + } + appendNextBlogPosts(); + if (fillViewport) { + scheduleBlogFillCheck(); + } + } + + private void appendNextBlogPosts() { + int nextCount = Math.min(renderedBlogPosts + BLOG_POST_PAGE_SIZE, blogPosts.size()); + for (int index = renderedBlogPosts; index < nextCount; index++) { + newsList.getChildren().add(blogPostRow(blogPosts.get(index))); + } + renderedBlogPosts = nextCount; + if (renderedBlogPosts >= blogPosts.size()) { + markBlogPostsComplete(); + } + } + + private void markBlogPostsComplete() { + if (blogPostsComplete) { + return; + } + blogPostsComplete = true; + newsList.getChildren().add(messageRow("End of Hytale RSS feed.")); + } + + private void scheduleBlogFillCheck() { + if (blogFillCheckScheduled) { + return; + } + blogFillCheckScheduled = true; + Platform.runLater(() -> { + blogFillCheckScheduled = false; + maybeAppendBlogPosts(true); + }); + } + + private Node blogPostRow(HytaleBlogPost post) { + VBox row = new VBox(7); + row.getStyleClass().add("play-news-card"); + row.setAlignment(Pos.TOP_LEFT); + row.setOnMouseClicked(event -> LauncherExternalLinks.open(post.url(), feedback::showToast)); + + StackPane thumbnail = newsThumbnail(post); + + Label title = new Label(value(post.title(), "Hytale Blog")); + title.getStyleClass().add("play-news-title"); + title.setWrapText(true); + Label date = new Label(formatBlogDate(post.publishedAt())); + date.getStyleClass().add("play-news-date"); + + row.getChildren().addAll(thumbnail, title, date); + return row; + } + + private StackPane newsThumbnail(HytaleBlogPost post) { + Label initial = new Label(initialFor(post.title())); + initial.getStyleClass().add("play-news-initial"); + StackPane thumbnail = new StackPane(initial); + thumbnail.getStyleClass().add("play-news-thumbnail"); + thumbnail.setMinSize(NEWS_THUMBNAIL_WIDTH, NEWS_THUMBNAIL_HEIGHT); + thumbnail.setPrefSize(NEWS_THUMBNAIL_WIDTH, NEWS_THUMBNAIL_HEIGHT); + thumbnail.setMaxSize(NEWS_THUMBNAIL_WIDTH, NEWS_THUMBNAIL_HEIGHT); + if (post.imageUrl() != null && !post.imageUrl().isBlank()) { + ImageView image = containedImageView(post.imageUrl(), NEWS_THUMBNAIL_WIDTH, NEWS_THUMBNAIL_HEIGHT); + image.getStyleClass().add("play-news-image"); + Rectangle clip = new Rectangle(NEWS_THUMBNAIL_WIDTH, NEWS_THUMBNAIL_HEIGHT); + clip.setArcWidth(14); + clip.setArcHeight(14); + image.setClip(clip); + thumbnail.getChildren().add(image); + } + return thumbnail; + } + + private ImageView containedImageView(String imageUrl, double width, double height) { + Image image = cachedImage(imageUrl, 0, 0, true, false); + ImageView imageView = new ImageView(image); + imageView.setFitWidth(width); + imageView.setFitHeight(height); + imageView.setPreserveRatio(true); + imageView.setSmooth(true); + return imageView; + } + + private Node messageRow(String message) { + Label label = new Label(message == null ? "" : message); + label.getStyleClass().add("play-sidebar-message"); + label.setWrapText(true); + return label; + } + + private StackPane avatar(String name, double size, String styleClass, String imageUrl) { + StackPane avatar = new StackPane(); + avatar.getStyleClass().add(styleClass); + sizeSquare(avatar, size); + updateImageAvatar(avatar, name, size, size / 2.0, imageUrl); + return avatar; + } + + private StackPane hytaleProfileAvatar(String username, double size, String styleClass) { + StackPane avatar = new StackPane(); + avatar.getStyleClass().add(styleClass); + sizeSquare(avatar, size); + updateHytaleProfileAvatar(avatar, username, size); + return avatar; + } + + private void updateHytaleProfileAvatar(StackPane avatar, String username, double size) { + updateImageAvatar(avatar, username, size, PROFILE_AVATAR_RADIUS, hyvatarUrl(username)); + } + + private void updateImageAvatar(StackPane avatar, String name, double size, double radius, String imageUrl) { + avatar.getChildren().clear(); + Label initial = new Label(initialFor(name)); + initial.getStyleClass().add("play-avatar-initial"); + avatar.getChildren().add(initial); + if (imageUrl == null || imageUrl.isBlank()) { + return; + } + Image image = cachedImage(imageUrl, size, size, true, true); + ImageView imageView = new ImageView(image); + imageView.setFitWidth(size); + imageView.setFitHeight(size); + imageView.setSmooth(true); + Rectangle clip = new Rectangle(size, size); + double arc = Math.min(size, radius * 2.0); + clip.setArcWidth(arc); + clip.setArcHeight(arc); + imageView.setClip(clip); + avatar.getChildren().add(imageView); + } + + private void sizeSquare(Region node, double size) { + node.setMinSize(size, size); + node.setPrefSize(size, size); + node.setMaxSize(size, size); + } + + private void sizeRegion(Region node, double width, double height) { + node.setMinSize(width, height); + node.setPrefSize(width, height); + node.setMaxSize(width, height); + } + + private Image cachedImage(String imageUrl, double requestedWidth, double requestedHeight, boolean preserveRatio, boolean resize) { + if (imageUrl == null || imageUrl.isBlank()) { + return null; + } + String key = imageUrl + "|" + requestedWidth + "x" + requestedHeight + "|" + preserveRatio + "|" + resize; + return imageCache.computeIfAbsent(key, ignored -> { + Image image = resize + ? new Image(imageUrl, requestedWidth, requestedHeight, preserveRatio, true, true) + : new Image(imageUrl, true); + image.errorProperty().addListener((observable, previous, failed) -> { + if (Boolean.TRUE.equals(failed)) { + imageCache.remove(key, image); + } + }); + return image; + }); + } + + private String hyvatarUrl(String username) { + String encoded = URLEncoder.encode(value(username, "Hytale"), StandardCharsets.UTF_8).replace("+", "%20"); + return "https://hyvatar.io/render/" + encoded + "?size=" + HYVATAR_RENDER_SIZE; + } + + private String initialFor(String value) { + String text = value(value, "H"); + return text.substring(0, 1).toUpperCase(); + } + + private String formatBlogDate(Instant publishedAt) { + if (publishedAt == null || publishedAt.equals(Instant.EPOCH)) { + return "Hytale Blog"; + } + int postYear = publishedAt.atZone(ZoneId.systemDefault()).getYear(); + if (postYear == Year.now().getValue()) { + return BLOG_DATE.format(publishedAt); + } + return BLOG_DATE_WITH_YEAR.format(publishedAt); + } + + private void toggleIdentityMenu(Node owner) { + if (identityMenu != null && identityMenu.isShowing()) { + identityMenu.hide(); + return; + } + if (System.currentTimeMillis() - identityMenuHiddenAtMillis < 180) { + return; + } + showIdentityMenu(owner); + } + + private void showIdentityMenu(Node owner) { + if (identityMenu != null) { + identityMenu.hide(); + } + ContextMenu menu = new ContextMenu(); + identityMenu = menu; + menu.getStyleClass().add("play-identity-menu"); + menu.setOnHidden(event -> { + identityMenuHiddenAtMillis = System.currentTimeMillis(); + if (identityMenu == menu) { + identityMenu = null; + } + }); + + LauncherSettings settings = settingsController.settings(); + HytaleAuthSession activeSession = settings.getHytaleAuthSession(); + String activeAccountId = activeSession == null ? "" : LauncherSettings.hytaleAccountId(activeSession); + String activeProfileId = activeSession == null ? "" : value(activeSession.getUuid(), ""); + List sessions = settings.getHytaleAuthSessions(); + for (HytaleAuthSession session : sessions) { + CustomMenuItem header = new CustomMenuItem(identityMenuHeader(session, menu), false); + header.setHideOnClick(false); + menu.getItems().add(header); + + List profiles = profilesFor(session); + if (profiles.isEmpty()) { + CustomMenuItem empty = new CustomMenuItem(identityMenuMessage("No game profiles found"), false); + empty.setHideOnClick(false); + menu.getItems().add(empty); + continue; + } + String accountId = LauncherSettings.hytaleAccountId(session); + for (HytaleProfile profile : profiles) { + boolean selected = accountId.equals(activeAccountId) && profile.uuid().equals(activeProfileId); + CustomMenuItem item = new CustomMenuItem(identityMenuProfileRow(profile, selected), true); + item.setOnAction(event -> selectIdentity(session, profile)); + menu.getItems().add(item); + } + } + + CustomMenuItem addAccount = new CustomMenuItem(identityMenuActionRow( + LauncherIcons.Glyph.USER, + "Add Hytale account", + "Open browser sign-in", + false + ), true); + addAccount.setOnAction(event -> signInHytale()); + menu.getItems().add(addAccount); + + menu.show(owner, Side.BOTTOM, 0, 8); + } + + private Node identityMenuHeader(HytaleAuthSession session, ContextMenu menu) { + HBox row = new HBox(9); + row.getStyleClass().add("play-identity-menu-header"); + row.setAlignment(Pos.CENTER_LEFT); + HytaleProfile selectedProfile = selectedProfileFor(session); + StackPane icon = hytaleProfileAvatar(selectedProfile.displayName(), IDENTITY_MENU_AVATAR_SIZE, "play-identity-menu-avatar"); + + VBox copy = new VBox(2); + Label name = new Label(accountLabel(session)); + name.getStyleClass().add("play-identity-menu-title"); + int profileCount = profilesFor(session).size(); + Label detail = new Label(profileCount + " game profile" + plural(profileCount)); + detail.getStyleClass().add("play-identity-menu-subtitle"); + copy.getChildren().addAll(name, detail); + HBox.setHgrow(copy, Priority.ALWAYS); + + Button logout = new Button(null, LauncherIcons.icon(LauncherIcons.Glyph.TRASH, 13)); + logout.getStyleClass().addAll("icon-btn", "play-identity-menu-logout"); + logout.setTooltip(new Tooltip("Log out Hytale account")); + logout.setAccessibleText("Log out " + accountLabel(session)); + logout.setOnAction(event -> { + event.consume(); + menu.hide(); + logoutHytaleAccount(session); + }); + + row.getChildren().addAll(icon, copy, logout); + return row; + } + + private Node identityMenuProfileRow(HytaleProfile profile, boolean selected) { + HBox row = new HBox(9); + row.getStyleClass().add("play-identity-menu-profile"); + if (selected) { + row.getStyleClass().add("selected"); + } + row.setAlignment(Pos.CENTER_LEFT); + StackPane profileAvatar = hytaleProfileAvatar(profile.displayName(), IDENTITY_MENU_AVATAR_SIZE, "play-identity-menu-avatar"); + if (selected) { + StackPane badge = new StackPane(LauncherIcons.icon(LauncherIcons.Glyph.CHECK, 8)); + badge.getStyleClass().add("play-identity-menu-avatar-badge"); + StackPane.setAlignment(badge, Pos.BOTTOM_RIGHT); + StackPane.setMargin(badge, new Insets(0, -2, -2, 0)); + profileAvatar.getChildren().add(badge); + } + + VBox copy = new VBox(2); + Label name = new Label(value(profile.displayName(), "Game profile")); + name.getStyleClass().add("play-identity-menu-title"); + Label detail = new Label(selected ? "Selected game profile" : "Use this game profile"); + detail.getStyleClass().add("play-identity-menu-subtitle"); + copy.getChildren().addAll(name, detail); + HBox.setHgrow(copy, Priority.ALWAYS); + row.getChildren().addAll(profileAvatar, copy); + return row; + } + + private Node identityMenuActionRow(LauncherIcons.Glyph glyph, String title, String subtitle, boolean danger) { + HBox row = new HBox(9); + row.getStyleClass().add("play-identity-menu-action"); + if (danger) { + row.getStyleClass().add("danger"); + } + row.setAlignment(Pos.CENTER_LEFT); + StackPane icon = new StackPane(LauncherIcons.icon(glyph, 15)); + icon.getStyleClass().add("play-identity-menu-icon"); + VBox copy = new VBox(2); + Label titleLabel = new Label(title); + titleLabel.getStyleClass().add("play-identity-menu-title"); + Label subtitleLabel = new Label(subtitle); + subtitleLabel.getStyleClass().add("play-identity-menu-subtitle"); + copy.getChildren().addAll(titleLabel, subtitleLabel); + HBox.setHgrow(copy, Priority.ALWAYS); + row.getChildren().addAll(icon, copy); + return row; + } + + private Node identityMenuMessage(String message) { + Label label = new Label(message); + label.getStyleClass().add("play-identity-menu-message"); + return label; + } + + private void selectIdentity(HytaleAuthSession session, HytaleProfile profile) { + if (session == null || profile == null) { + return; + } + String accountId = LauncherSettings.hytaleAccountId(session); + boolean accountChanged = !accountId.equals(settingsController.settings().getActiveHytaleAccountId()); + if (accountChanged) { + hytaleAuthService.selectAccount(settingsController.settings(), accountId); + settingsController.reloadFromStore(); + } + HytaleAuthSession active = settingsController.settings().getHytaleAuthSession(); + if (active == null || !profile.uuid().equals(active.getUuid())) { + hytaleAuthService.selectProfile(settingsController.settings(), profile); + settingsController.reloadFromStore(); + } + feedback.log("Selected Hytale profile " + profile.displayName() + "."); + syncMetrics(); + } + + private List profilesFor(HytaleAuthSession session) { + if (session == null) { + return List.of(); + } + List profiles = session.getProfiles(); + if (!profiles.isEmpty()) { + return profiles; + } + if (session.getUuid() == null || session.getUuid().isBlank()) { + return List.of(); + } + return List.of(new HytaleProfile(session.getUsername(), session.getUuid(), session.getAccountOwnerId())); + } + + private HytaleProfile selectedProfileFor(HytaleAuthSession session) { + if (session == null) { + return new HytaleProfile("", "", ""); + } + List profiles = profilesFor(session); + if (profiles.isEmpty()) { + return new HytaleProfile(session.getUsername(), session.getUuid(), session.getAccountOwnerId()); + } + String selectedUuid = value(session.getUuid(), ""); + return profiles.stream() + .filter(profile -> profile.uuid().equals(selectedUuid)) + .findFirst() + .orElseGet(() -> profiles.get(0)); + } + + private String accountLabel(HytaleAuthSession session) { + return value(session == null ? "" : session.getUsername(), "Hytale account"); + } + + private String selectedVersionLabel(LauncherSettings settings) { + LauncherSettingsForm form = settingsController.form(); + HytaleVersion selected = form.hytaleVersionCombo().getValue(); + if (selected != null) { + return versionSwitchLabel(selected); + } + if (settings.getHytaleBuild() > 0) { + for (HytaleVersion version : form.hytaleVersionCombo().getItems()) { + if (version.build() == settings.getHytaleBuild()) { + return versionSwitchLabel(version); + } + } + Map labels = HytaleGameVersionResolver.resolveBuildVersions(settings); + String label = labels.get(settings.getHytaleBuild()); + return label == null || label.isBlank() ? "Build " + settings.getHytaleBuild() : label; + } + return "Unset"; + } + + private String versionSwitchLabel(HytaleVersion version) { + return version == null ? "" : version.displayVersion(); + } + + private void syncPatchlineMetric(String selectedPatchline) { + String patchline = HytaleApiClient.normalizeBranch(selectedPatchline); + boolean showPatchline = !"release".equals(patchline); + patchlineMetric.setText(launchPatchlineLabel(patchline)); + Node container = patchlineMetric.getParent(); + setVisibleManaged(container == null ? patchlineMetric : container, showPatchline); + } + + private String launchPatchlineLabel(String patchline) { + String normalized = HytaleApiClient.normalizeBranch(patchline); + return switch (normalized) { + case "release" -> "Latest release"; + case "pre-release" -> "Pre-release"; + default -> normalized; + }; + } + + private void signInHytale() { + settingsController.saveFromFields(false); + feedback.runAsync("Opening Hytale sign-in in your browser...", () -> + hytaleAuthService.loginAndSave(settingsController.settings()), session -> { + settingsController.reloadFromStore(); + loadedPlaytimeKey = ""; + feedback.log("Signed in with Hytale as " + session + "."); + feedback.showToast("Hytale ready", "Signed in as " + session + "."); + syncMetrics(); + onHytaleAccountsChanged.run(); + }); + } + + private void logoutHytaleAccount(HytaleAuthSession session) { + if (isHytaleRunning()) { + feedback.showToast("Hytale is running", "Stop Hytale before logging out of this account."); + return; + } + if (session == null) { + return; + } + LauncherSettings settings = settingsController.settings(); + String accountId = LauncherSettings.hytaleAccountId(session); + boolean activeAccount = accountId.equals(settings.getActiveHytaleAccountId()); + hytaleAuthService.logoutAccount(settings, accountId); + if (activeAccount) { + settingsController.form().hytaleVersionCombo().getItems().clear(); + loadedVersionsKey = ""; + loadedFriendsKey = ""; + loadedPlaytimeKey = ""; + } + settingsController.reloadFromStore(); + feedback.log("Logged out of Hytale account " + accountLabel(session) + "."); + syncMetrics(); + onHytaleAccountsChanged.run(); + } + + private void stopHytale() { + Process process = hytaleProcess; + if (process == null || !process.isAlive()) { + hytaleProcess = null; + feedback.showToast("Not running", "Hytale is not running."); + return; + } + process.destroy(); + feedback.log("Asked Hytale to stop."); + } + + private void monitorHytaleProcess(HytaleLaunchResult result, long startedAtMillis) { + Process process = result.process(); + CompletableFuture.runAsync(() -> { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + feedback.log("[Hytale] " + line); + } + } catch (IOException ex) { + LOG.warn("Could not read Hytale output.", ex); + feedback.log("Could not read Hytale output: " + ex.getMessage()); + } + try { + int exitCode = process.waitFor(); + if (hytaleProcess == process) { + hytaleProcess = null; + } + long elapsedSeconds = Math.max(0, (System.currentTimeMillis() - startedAtMillis) / 1000); + if (elapsedSeconds > 0) { + LauncherSettings settings = settingsController.settings(); + settings.addHytalePlaytimeSeconds(elapsedSeconds); + settingsController.saveCurrentSettings(); + } + feedback.log("Hytale exited with code " + exitCode + "."); + discordRichPresence.showLauncher(); + javafx.application.Platform.runLater(() -> { + loadedPlaytimeKey = ""; + syncMetrics(); + }); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + LOG.warn("Interrupted while waiting for Hytale process.", ex); + } + }, executor); + } + + private boolean isHytaleRunning() { + Process process = hytaleProcess; + return process != null && process.isAlive(); + } + + private void showLaunchDropdown(Node owner) { + LauncherSettingsForm form = settingsController.form(); + ContextMenu menu = new ContextMenu(); + menu.getStyleClass().add("play-launch-menu"); + + List patchlines = form.hytaleBranchCombo().getItems().isEmpty() + ? List.of("release", "pre-release") + : List.copyOf(form.hytaleBranchCombo().getItems()); + menu.getItems().add(patchlineMenu(patchlines, HytaleApiClient.normalizeBranch(form.hytaleBranchCombo().getValue()))); + menu.getItems().add(buildMenu(form)); + menu.show(owner, Side.BOTTOM, 0, 8); + } + + private Menu patchlineMenu(List patchlines, String selectedPatchline) { + String selected = HytaleApiClient.normalizeBranch(selectedPatchline); + Menu menu = new Menu("Patchline: " + LauncherSettingsForm.hytalePatchlineLabel(selected), + LauncherIcons.icon(LauncherIcons.Glyph.SLIDERS, 14)); + for (String patchline : dropdownPatchlines(patchlines)) { + String normalized = HytaleApiClient.normalizeBranch(patchline); + menu.getItems().add(launchMenuItem( + LauncherSettingsForm.hytalePatchlineLabel(normalized), + LauncherIcons.Glyph.SLIDERS, + normalized.equals(selected), + () -> selectLaunchPatchline(normalized) + )); + } + return menu; + } + + private List dropdownPatchlines(List patchlines) { + List normalized = patchlines == null ? List.of() : patchlines.stream() + .map(HytaleApiClient::normalizeBranch) + .distinct() + .toList(); + List ordered = new ArrayList<>(); + if (normalized.contains("pre-release")) { + ordered.add("pre-release"); + } + if (normalized.contains("release")) { + ordered.add("release"); + } + normalized.stream().filter(patchline -> patchline.startsWith("v")).forEach(ordered::add); + normalized.stream() + .filter(patchline -> !ordered.contains(patchline)) + .forEach(ordered::add); + return ordered; + } + + private List orderedPatchlines(List patchlines) { + List normalized = patchlines == null ? List.of() : patchlines.stream() + .map(HytaleApiClient::normalizeBranch) + .distinct() + .toList(); + List ordered = new ArrayList<>(); + normalized.stream().filter(patchline -> patchline.startsWith("v")).forEach(ordered::add); + if (normalized.contains("release")) { + ordered.add("release"); + } + if (normalized.contains("pre-release")) { + ordered.add("pre-release"); + } + normalized.stream() + .filter(patchline -> !ordered.contains(patchline)) + .forEach(ordered::add); + return ordered; + } + + private Menu buildMenu(LauncherSettingsForm form) { + String label = versionsLoading ? "Versions: loading" : "Version: " + selectedVersionLabel(settingsController.settings()); + Menu buildMenu = new Menu(label, LauncherIcons.icon(LauncherIcons.Glyph.ZAP, 14)); + List versions = List.copyOf(form.hytaleVersionCombo().getItems()); + if (versionsLoading || versions.isEmpty()) { + MenuItem message = new MenuItem(versionsLoading ? "Loading versions..." : "Waiting for version refresh"); + message.setDisable(true); + buildMenu.getItems().add(message); + return buildMenu; + } + + HytaleVersion selectedVersion = form.hytaleVersionCombo().getValue(); + for (HytaleVersion version : versions) { + boolean selected = selectedVersion != null && selectedVersion.build() == version.build(); + buildMenu.getItems().add(launchMenuItem(versionSwitchLabel(version), LauncherIcons.Glyph.ZAP, + selected, () -> selectLaunchBuild(version))); + } + return buildMenu; + } + + private MenuItem launchMenuItem(String label, LauncherIcons.Glyph glyph, boolean selected, Runnable action) { + MenuItem item = new MenuItem(label, LauncherIcons.icon(selected ? LauncherIcons.Glyph.CHECK : glyph, 14)); + item.setOnAction(event -> action.run()); + return item; + } + + private void selectLaunchPatchline(String patchline) { + LauncherSettingsForm form = settingsController.form(); + String normalized = HytaleApiClient.normalizeBranch(patchline); + if (normalized.equals(HytaleApiClient.normalizeBranch(form.hytaleBranchCombo().getValue()))) { + return; + } + form.hytaleVersionCombo().setValue(null); + form.hytaleVersionCombo().getItems().clear(); + suppressBranchVersionLoad = true; + try { + form.hytaleBranchCombo().setValue(normalized); + } finally { + suppressBranchVersionLoad = false; + } + settingsController.applyFromFields(); + settingsController.settings().setHytaleBuild(0); + settingsController.saveCurrentSettings(); + loadedVersionsKey = ""; + buildMetric.setText("Loading"); + loadHytaleVersions(false); + syncMetrics(); + } + + private void selectLaunchBuild(HytaleVersion version) { + if (version == null) { + return; + } + settingsController.form().hytaleVersionCombo().setValue(version); + settingsController.saveFromFields(false); + syncMetrics(); + } + + private void maybeLoadHytaleVersions() { + loadHytaleVersions(false); + } + + private void maybeLoadHytalePlaytime() { + loadHytalePlaytime(false); + } + + private String versionsKey(LauncherSettings settings, HytaleAuthSession session) { + return LauncherSettings.hytaleAccountId(session) + ":" + settings.getHytaleBranch(); + } + + private String selectedPatchline(String requestedPatchline, List availablePatchlines) { + String normalized = HytaleApiClient.normalizeBranch(requestedPatchline); + if (availablePatchlines != null && availablePatchlines.contains(normalized)) { + return normalized; + } + return "release"; + } + + private String playtimeKey(HytaleAuthSession session) { + return LauncherSettings.hytaleAccountId(session) + ":" + selectedProfileFor(session).uuid(); + } + + private String plural(int count) { + return count == 1 ? "" : "s"; + } + + private record HytaleVersionsPayload( + List patchlines, + String selectedPatchline, + Map> versionsByPatchline, + List pendingPatchlines, + HytaleApiException rateLimit + ) { + + private List versionsForSelectedPatchline() { + return versionsByPatchline.getOrDefault(selectedPatchline, List.of()); + } + } + + private static final class CatalogShelf { + private final ProjectBrowseSort sort; + private final HBox cardRow = new HBox(); + private final ScrollPane scroll = new ScrollPane(); + private final Label message = new Label(); + + private boolean loading; + private boolean loaded; + private long requestId; + + private CatalogShelf(ProjectBrowseSort sort) { + this.sort = sort; + } + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/project/FixedAspectPane.java b/launcher/src/main/java/net/modtale/launcher/ui/project/FixedAspectPane.java new file mode 100644 index 00000000..228c54fd --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/project/FixedAspectPane.java @@ -0,0 +1,56 @@ +package net.modtale.launcher.ui.project; + +import javafx.scene.layout.StackPane; + +final class FixedAspectPane extends StackPane { + + private static final double FALLBACK_WIDTH = 640; + + private final double aspectRatio; + + FixedAspectPane(double aspectRatio) { + if (!Double.isFinite(aspectRatio) || aspectRatio <= 0) { + throw new IllegalArgumentException("aspectRatio must be positive"); + } + this.aspectRatio = aspectRatio; + setMinWidth(0); + } + + @Override + protected double computeMinHeight(double width) { + return computePrefHeight(width); + } + + @Override + protected double computePrefHeight(double width) { + return effectiveWidth(width) / aspectRatio; + } + + @Override + protected double computeMaxHeight(double width) { + return computePrefHeight(width); + } + + @Override + protected double computePrefWidth(double height) { + if (Double.isFinite(height) && height > 0) { + return height * aspectRatio; + } + double prefWidth = getPrefWidth(); + if (Double.isFinite(prefWidth) && prefWidth > 0) { + return prefWidth; + } + return FALLBACK_WIDTH; + } + + private double effectiveWidth(double width) { + if (Double.isFinite(width) && width > 0) { + return width; + } + double prefWidth = getPrefWidth(); + if (Double.isFinite(prefWidth) && prefWidth > 0) { + return prefWidth; + } + return FALLBACK_WIDTH; + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/project/LauncherProjectActions.java b/launcher/src/main/java/net/modtale/launcher/ui/project/LauncherProjectActions.java new file mode 100644 index 00000000..0eb0b2c2 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/project/LauncherProjectActions.java @@ -0,0 +1,239 @@ +package net.modtale.launcher.ui.project; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.function.Consumer; +import java.util.function.Supplier; +import javafx.application.Platform; +import javafx.scene.layout.StackPane; +import net.modtale.launcher.api.ModtaleApiClient; +import net.modtale.launcher.model.project.GameVersionCatalog; +import net.modtale.launcher.model.project.ProjectDependency; +import net.modtale.launcher.model.project.ProjectDetail; +import net.modtale.launcher.model.project.ProjectSummary; +import net.modtale.launcher.model.project.ProjectVersion; +import net.modtale.launcher.model.user.CurrentUser; +import net.modtale.launcher.ui.account.LauncherAccountController; +import net.modtale.launcher.ui.browse.ProjectBrowseController; +import net.modtale.launcher.ui.common.CachedImageLoader; +import net.modtale.launcher.ui.feedback.LauncherFeedback; +import net.modtale.launcher.ui.library.LauncherLibraryController; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +public final class LauncherProjectActions { + + private static final Logger LOG = LogManager.getLogger(LauncherProjectActions.class); + + private final ModtaleApiClient apiClient; + private final LauncherAccountController accountController; + private final LauncherLibraryController libraryController; + private final LauncherFeedback feedback; + private final Supplier gameVersion; + private final Executor executor; + private final CachedImageLoader imageLoader; + + private ProjectBrowseController browseController; + private NativeDownloadModal downloadModal; + private NativeDependencyModal dependencyModal; + private Consumer viewHistory = detail -> { + }; + + public LauncherProjectActions( + ModtaleApiClient apiClient, + LauncherAccountController accountController, + LauncherLibraryController libraryController, + LauncherFeedback feedback, + Supplier gameVersion, + Executor executor, + CachedImageLoader imageLoader + ) { + this.apiClient = apiClient; + this.accountController = accountController; + this.libraryController = libraryController; + this.feedback = feedback; + this.gameVersion = gameVersion == null ? () -> "" : gameVersion; + this.executor = executor; + this.imageLoader = imageLoader; + } + + public void attachBrowse(ProjectBrowseController browseController) { + this.browseController = browseController; + } + + public void attachOverlay(Supplier overlayHost) { + downloadModal = new NativeDownloadModal( + overlayHost, + gameVersion, + this::installSelectedProjectVersion, + detail -> viewHistory.accept(detail) + ); + dependencyModal = new NativeDependencyModal( + overlayHost, + apiClient, + executor, + imageLoader, + this::installSelectedProjectVersion + ); + } + + public void setViewHistoryAction(Consumer viewHistory) { + this.viewHistory = viewHistory == null ? detail -> { + } : viewHistory; + } + + public void installSelectedProject(ProjectSummary summary) { + if (summary == null) { + return; + } + if (downloadModal == null) { + libraryController.installSelectedProject(summary); + return; + } + ProjectDetail initialProject = detailFromSummary(summary); + GameVersionCatalog initialCatalog = catalogFromProject(initialProject); + if (initialProject.versions().isEmpty()) { + downloadModal.showLoading(initialProject, initialCatalog); + } else { + downloadModal.show(initialProject, initialCatalog); + } + refreshDownloadOptions(summary, initialProject); + } + + private void refreshDownloadOptions(ProjectSummary summary, ProjectDetail initialProject) { + feedback.log("Refreshing download options for " + summary + "..."); + CompletableFuture.supplyAsync(() -> { + ProjectDetail project = loadProjectWithVersions(summary); + return new DownloadOptions(project, loadGameVersions(project)); + }, executor).whenComplete((options, error) -> Platform.runLater(() -> { + if (error != null) { + Throwable cause = error.getCause() == null ? error : error.getCause(); + feedback.log("Could not refresh download options for " + summary + ": " + cause.getMessage()); + if (initialProject.versions().isEmpty() && downloadModal.isShowing(initialProject)) { + feedback.showToast("Download options unavailable", cause.getMessage()); + } + return; + } + downloadModal.refresh(options.project(), options.catalog()); + })); + } + + private void installSelectedProjectVersion(NativeDownloadModal.DownloadSelection selection) { + if (selection.selectedDependencies() != null) { + installExactDependencySelection( + selection.project(), + selection.version(), + selection.gameVersion(), + selection.selectedDependencies() + ); + return; + } + installSelectedProjectVersion(selection.project(), selection.version(), selection.gameVersion()); + } + + private void installSelectedProjectVersion(NativeDependencyModal.DependencySelection selection) { + installExactDependencySelection( + selection.project(), + selection.version(), + selection.gameVersion(), + selection.selectedDependencies() + ); + } + + public void installSelectedProjectVersion(ProjectDetail project, ProjectVersion version, String gameVersion) { + List dependencies = NativeDependencyModal.selectableDependencies(version); + if (dependencyModal != null && !dependencies.isEmpty()) { + dependencyModal.show(project, version, gameVersion); + return; + } + installExactDependencySelection(project, version, gameVersion, dependencies); + } + + private void installExactDependencySelection( + ProjectDetail project, + ProjectVersion version, + String gameVersion, + List selectedDependencies + ) { + libraryController.installSelectedProjectVersion(project, version, gameVersion, selectedDependencies); + } + + public void toggleFavorite(ProjectSummary project) { + feedback.runAsync((accountController.isSignedIn() ? "Updating like for " : "Signing in to like ") + project + "...", () -> { + CurrentUser user = accountController.ensureSignedIn(); + boolean wasFavorite = user.likesProject(project.id()); + apiClient.toggleFavorite(project.id()); + CurrentUser updatedUser = apiClient.currentUser(); + boolean isFavorite = updatedUser.likesProject(project.id()); + return new FavoriteToggleResult(project.id(), wasFavorite, isFavorite, updatedUser); + }, result -> { + accountController.setCurrentUser(result.user()); + int delta = (result.isFavorite() ? 1 : 0) - (result.wasFavorite() ? 1 : 0); + if (browseController != null) { + browseController.applyFavoriteDelta(result.projectId(), delta); + browseController.renderProjects(); + } + feedback.showToast(result.isFavorite() ? "Liked project" : "Removed like", "Updated your Modtale favorites."); + }); + } + + private record FavoriteToggleResult(String projectId, boolean wasFavorite, boolean isFavorite, CurrentUser user) { + } + + private GameVersionCatalog loadGameVersions(ProjectDetail project) { + try { + return apiClient.getGameVersionCatalog(); + } catch (RuntimeException ex) { + LOG.warn("Could not load game version catalog; falling back to project versions.", ex); + return catalogFromProject(project); + } + } + + private ProjectDetail loadProjectWithVersions(ProjectSummary summary) { + ProjectDetail project = apiClient.getProject(summary.routeKey()); + return ProjectVersionHydrator.hydrateOrThrow(project, summary, apiClient::getProjectVersions); + } + + private static ProjectDetail detailFromSummary(ProjectSummary summary) { + return new ProjectDetail( + summary.id(), + summary.slug(), + summary.title(), + null, + summary.description(), + summary.authorId(), + summary.author(), + summary.imageUrl(), + summary.bannerUrl(), + summary.classification(), + summary.downloadCount(), + summary.favoriteCount(), + summary.updatedAt(), + null, + null, + Map.of(), + List.of(), + List.of(), + Map.of(), + null, + false, + null, + summary.versions() + ); + } + + private static GameVersionCatalog catalogFromProject(ProjectDetail project) { + List versions = project == null + ? List.of() + : project.versions().stream() + .flatMap(version -> version.gameVersions().stream()) + .distinct() + .toList(); + return GameVersionCatalog.fromVersions(versions); + } + + private record DownloadOptions(ProjectDetail project, GameVersionCatalog catalog) { + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/project/NativeBannerScrollEffect.java b/launcher/src/main/java/net/modtale/launcher/ui/project/NativeBannerScrollEffect.java new file mode 100644 index 00000000..bd8c26f3 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/project/NativeBannerScrollEffect.java @@ -0,0 +1,60 @@ +package net.modtale.launcher.ui.project; + +import javafx.beans.InvalidationListener; +import javafx.beans.property.ReadOnlyDoubleProperty; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import net.modtale.launcher.LauncherPerformanceProbe; + +final class NativeBannerScrollEffect { + + private static final double SCROLL_CAP = 1500; + private static final double PARALLAX_MAX_OFFSET = 500; + private static final double PARALLAX_DECAY = 600; + + private NativeBannerScrollEffect() { + } + + static void bind(Node media, Region fade, ReadOnlyDoubleProperty scrollPixels, double baseFadeHeight) { + fade.setMaxWidth(Double.MAX_VALUE); + fade.setMinHeight(baseFadeHeight); + fade.setPrefHeight(baseFadeHeight); + fade.setMaxHeight(baseFadeHeight); + StackPane.setAlignment(fade, Pos.BOTTOM_CENTER); + + InvalidationListener listener = ignored -> apply(media, fade, scrollPixels.get(), baseFadeHeight); + scrollPixels.addListener(listener); + media.sceneProperty().addListener((observable, previous, current) -> { + if (previous != null && current == null) { + scrollPixels.removeListener(listener); + } + }); + apply(media, fade, scrollPixels.get(), baseFadeHeight); + } + + private static void apply(Node media, Region fade, double scrollPixels, double baseFadeHeight) { + long operationStart = LauncherPerformanceProbe.operationStartNanos(); + try { + double offset = parallaxOffset(scrollPixels); + media.setTranslateY(offset); + fade.setTranslateY(offset / 2.0); + fade.setScaleY(fadeScale(offset, baseFadeHeight)); + } finally { + LauncherPerformanceProbe.recordOperation("banner.scrollEffect", operationStart); + } + } + + static double parallaxOffset(double scrollPixels) { + double scroll = Math.min(Math.max(0, scrollPixels), SCROLL_CAP); + return PARALLAX_MAX_OFFSET * (1 - Math.exp(-scroll / PARALLAX_DECAY)); + } + + static double fadeScale(double offset, double baseFadeHeight) { + if (!Double.isFinite(baseFadeHeight) || baseFadeHeight <= 0) { + return 1; + } + return (baseFadeHeight + Math.max(0, offset)) / baseFadeHeight; + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/project/NativeCommentSection.java b/launcher/src/main/java/net/modtale/launcher/ui/project/NativeCommentSection.java new file mode 100644 index 00000000..7c3f4aa1 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/project/NativeCommentSection.java @@ -0,0 +1,581 @@ +package net.modtale.launcher.ui.project; + +import static net.modtale.launcher.ui.browse.card.ProjectCardFormatter.timeAgo; +import static net.modtale.launcher.ui.common.LauncherUi.primaryButton; +import static net.modtale.launcher.ui.common.LauncherUi.secondaryButton; +import static net.modtale.launcher.ui.common.LauncherUi.value; + +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.Supplier; +import javafx.css.PseudoClass; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Cursor; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.TextArea; +import javafx.scene.image.ImageView; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import javafx.scene.shape.Rectangle; +import net.modtale.launcher.model.project.ProjectComment; +import net.modtale.launcher.model.project.ProjectDetail; +import net.modtale.launcher.model.project.ProjectSummary; +import net.modtale.launcher.model.user.CurrentUser; +import net.modtale.launcher.model.user.UserSummary; +import net.modtale.launcher.ui.common.CachedImageLoader; +import net.modtale.launcher.ui.common.LauncherIcons; + +final class NativeCommentSection { + + private static final String OWNER_ROLE_COLOR = "#f97316"; + private static final PseudoClass UPVOTED = PseudoClass.getPseudoClass("upvoted"); + private static final PseudoClass DOWNVOTED = PseudoClass.getPseudoClass("downvoted"); + + interface Actions { + void submitComment(String editingCommentId, String content); + + void deleteComment(ProjectComment comment); + + void submitReply(String commentId, String content); + + void vote(String commentId, boolean reply, boolean upvote); + + void report(String commentId); + } + + private final CachedImageLoader imageLoader; + private final NativeMarkdownRenderer markdownRenderer; + private final Supplier currentUser; + private final Runnable signIn; + private final BiConsumer openProfile; + private final Actions actions; + private final Runnable requestRender; + + private String composerText = ""; + private String editingCommentId; + private String replyingCommentId; + private String replyText = ""; + + NativeCommentSection( + CachedImageLoader imageLoader, + NativeMarkdownRenderer markdownRenderer, + Supplier currentUser, + Runnable signIn, + BiConsumer openProfile, + Actions actions, + Runnable requestRender + ) { + this.imageLoader = imageLoader; + this.markdownRenderer = markdownRenderer; + this.currentUser = currentUser == null ? () -> null : currentUser; + this.signIn = signIn == null ? () -> { + } : signIn; + this.openProfile = openProfile == null ? (id, username) -> { + } : openProfile; + this.actions = actions; + this.requestRender = requestRender == null ? () -> { + } : requestRender; + } + + Node render( + ProjectSummary summary, + ProjectDetail detail, + List comments, + Map userProfiles, + boolean loading, + boolean submitting + ) { + CurrentUser user = currentUser.get(); + boolean creator = isCreator(user, summary, detail); + boolean disabled = detail != null && Boolean.FALSE.equals(detail.allowComments()); + if (disabled && !creator) { + return null; + } + + List safeComments = comments == null ? List.of() : comments; + Map safeProfiles = userProfiles == null ? Map.of() : userProfiles; + + VBox section = new VBox(0); + section.setId("comments"); + section.getStyleClass().add("project-comments-section"); + section.setMaxWidth(Double.MAX_VALUE); + + HBox heading = new HBox(12); + heading.getStyleClass().add("project-comments-heading"); + heading.setAlignment(Pos.CENTER_LEFT); + Label title = new Label(safeComments.size() + " Comments"); + title.getStyleClass().add("project-comments-title"); + heading.getChildren().addAll(LauncherIcons.icon(LauncherIcons.Glyph.MESSAGE_SQUARE, 24), title); + section.getChildren().add(heading); + + if (disabled) { + section.getChildren().add(disabledNotice()); + } + + section.getChildren().add(user == null ? signInPrompt() : composer(user, submitting)); + + VBox list = new VBox(16); + list.getStyleClass().add("project-comments-list"); + if (loading) { + list.getChildren().add(stateCard(NativeSpinner.inline(20))); + } else if (safeComments.isEmpty()) { + list.getChildren().add(stateCard("No comments yet. Be the first to share your thoughts!")); + } else { + for (ProjectComment comment : safeComments) { + list.getChildren().add(commentCard(comment, summary, detail, safeProfiles, creator, submitting)); + } + } + section.getChildren().add(list); + return section; + } + + void clearComposer() { + composerText = ""; + editingCommentId = null; + } + + void clearReply() { + replyText = ""; + replyingCommentId = null; + } + + void clearState() { + clearComposer(); + clearReply(); + } + + private Node disabledNotice() { + HBox notice = new HBox(8); + notice.getStyleClass().add("project-comments-disabled-notice"); + notice.setAlignment(Pos.CENTER_LEFT); + Label label = new Label("Comments are currently disabled. Only you can see them."); + label.getStyleClass().add("project-comments-disabled-text"); + notice.getChildren().addAll(LauncherIcons.icon(LauncherIcons.Glyph.FLAG, 16), label); + return notice; + } + + private Node signInPrompt() { + Button signInButton = new Button("Log in to join the conversation."); + signInButton.getStyleClass().add("project-comments-signin"); + signInButton.setMaxWidth(Double.MAX_VALUE); + signInButton.setOnAction(event -> signIn.run()); + return signInButton; + } + + private Node composer(CurrentUser user, boolean submitting) { + VBox shell = new VBox(14); + shell.getStyleClass().add("project-comments-composer"); + shell.setMaxWidth(Double.MAX_VALUE); + + HBox top = new HBox(12); + top.setAlignment(Pos.CENTER_LEFT); + Label title = new Label(editingCommentId == null ? "Leave a comment" : "Edit your comment"); + title.getStyleClass().add("project-comments-composer-title"); + Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + top.getChildren().addAll(title, spacer); + if (editingCommentId != null) { + Button cancel = new Button("Cancel"); + cancel.getStyleClass().add("project-comments-text-action-danger"); + cancel.setOnAction(event -> { + clearComposer(); + requestRender.run(); + }); + top.getChildren().add(cancel); + } + + HBox editRow = new HBox(12); + editRow.setAlignment(Pos.TOP_LEFT); + TextArea text = new TextArea(composerText); + text.getStyleClass().add("project-comments-textarea"); + text.setPromptText("What are your thoughts?"); + text.setWrapText(true); + text.setPrefRowCount(3); + text.setMinHeight(72); + text.setDisable(submitting); + text.textProperty().addListener((observable, previous, value) -> composerText = value == null ? "" : value); + HBox.setHgrow(text, Priority.ALWAYS); + editRow.getChildren().addAll(avatar(36, user.id(), user.username(), user.avatarUrl(), false), text); + + HBox footer = new HBox(); + footer.setAlignment(Pos.CENTER_RIGHT); + Button submit = primaryButton(editingCommentId == null ? "Post Comment" : "Update"); + submit.getStyleClass().add("project-comments-submit"); + submit.setGraphic(LauncherIcons.icon(LauncherIcons.Glyph.SEND, 14)); + submit.setDisable(submitting || composerText.isBlank()); + submit.setOnAction(event -> actions.submitComment(editingCommentId, composerText)); + footer.getChildren().add(submit); + + shell.getChildren().addAll(top, editRow, footer); + return shell; + } + + private Node commentCard( + ProjectComment comment, + ProjectSummary summary, + ProjectDetail detail, + Map profiles, + boolean creator, + boolean submitting + ) { + CurrentUser user = currentUser.get(); + String userId = value(comment.userId(), ""); + CommentIdentity identity = identity(userId, comment.user(), comment.author(), profiles, comment.date()); + boolean owner = user != null && (value(user.id(), "").equals(userId) + || value(user.username(), "").equalsIgnoreCase(identity.username())); + + HBox card = new HBox(16); + card.getStyleClass().add("project-comment-card"); + card.setAlignment(Pos.TOP_LEFT); + card.getChildren().add(voteWidget(comment.id(), false, comment.score(), comment.userVoteFor(user == null ? null : user.id()), submitting)); + + VBox body = new VBox(0); + body.setMinWidth(0); + HBox.setHgrow(body, Priority.ALWAYS); + body.getChildren().add(commentHeader(identity, roleBadge(userId, summary, detail), 40)); + Node markdown = markdown(comment.content()); + VBox.setMargin(markdown, new Insets(8, 0, 0, 0)); + body.getChildren().add(markdown); + body.getChildren().add(actionsRow(comment, creator, owner)); + + if (replyingCommentId != null && replyingCommentId.equals(comment.id())) { + body.getChildren().add(replyForm(comment.id(), submitting)); + } else if (comment.developerReply() != null) { + body.getChildren().add(developerReply(comment, profiles, summary, detail, submitting)); + } + + card.getChildren().add(body); + return card; + } + + private Node commentHeader(CommentIdentity identity, RoleBadge roleBadge, double avatarSize) { + HBox header = new HBox(12); + header.getStyleClass().add("project-comment-header"); + header.setAlignment(Pos.CENTER_LEFT); + Node avatar = avatar(avatarSize, identity.userId(), identity.username(), identity.avatarUrl(), true); + VBox meta = new VBox(2); + meta.getStyleClass().add("project-comment-meta"); + Label name = new Label(identity.username()); + name.getStyleClass().add("project-comment-author"); + if (!identity.userId().isBlank()) { + name.setCursor(Cursor.HAND); + name.setOnMouseClicked(event -> openProfile.accept(identity.userId(), identity.username())); + } + meta.getChildren().add(name); + if (roleBadge != null) { + meta.getChildren().add(roleBadge(roleBadge)); + } + Label date = new Label(timeAgo(identity.date())); + date.getStyleClass().add("project-comment-date"); + meta.getChildren().add(date); + header.getChildren().addAll(avatar, meta); + return header; + } + + private Node actionsRow(ProjectComment comment, boolean creator, boolean owner) { + HBox row = new HBox(16); + row.getStyleClass().add("project-comment-actions"); + row.setAlignment(Pos.CENTER_LEFT); + VBox.setMargin(row, new Insets(12, 0, 0, 0)); + if (creator && comment.developerReply() == null) { + row.getChildren().add(actionButton("Reply", LauncherIcons.Glyph.MESSAGE_SQUARE, () -> { + replyingCommentId = comment.id(); + replyText = ""; + requestRender.run(); + })); + } + if (creator && comment.developerReply() != null) { + row.getChildren().add(actionButton("Edit Reply", LauncherIcons.Glyph.MESSAGE_SQUARE, () -> { + replyingCommentId = comment.id(); + replyText = value(comment.developerReply().content(), ""); + requestRender.run(); + })); + } + CurrentUser user = currentUser.get(); + if (user != null && !owner) { + row.getChildren().add(actionButton("Report", LauncherIcons.Glyph.FLAG, () -> actions.report(comment.id()))); + } + if (owner) { + row.getChildren().add(actionButton("Edit", LauncherIcons.Glyph.EDIT, () -> { + composerText = value(comment.content(), ""); + editingCommentId = comment.id(); + replyingCommentId = null; + requestRender.run(); + })); + } + if (creator || owner) { + Button delete = actionButton("Delete", LauncherIcons.Glyph.TRASH, () -> actions.deleteComment(comment)); + delete.getStyleClass().add("danger"); + row.getChildren().add(delete); + } + return row; + } + + private Button actionButton(String text, LauncherIcons.Glyph glyph, Runnable action) { + Button button = new Button(text, LauncherIcons.icon(glyph, 15)); + button.getStyleClass().add("project-comment-action"); + button.setOnAction(event -> action.run()); + return button; + } + + private Node replyForm(String commentId, boolean submitting) { + VBox form = new VBox(10); + form.getStyleClass().add("project-comment-reply-form"); + VBox.setMargin(form, new Insets(16, 0, 0, 0)); + TextArea text = new TextArea(replyText); + text.getStyleClass().add("project-comment-reply-textarea"); + text.setPromptText("Write a reply..."); + text.setWrapText(true); + text.setPrefRowCount(3); + text.setDisable(submitting); + text.textProperty().addListener((observable, previous, value) -> replyText = value == null ? "" : value); + HBox footer = new HBox(8); + footer.setAlignment(Pos.CENTER_RIGHT); + Button cancel = secondaryButton("Cancel"); + cancel.getStyleClass().add("project-comment-reply-cancel"); + cancel.setOnAction(event -> { + clearReply(); + requestRender.run(); + }); + Button submit = primaryButton("Post Reply"); + submit.getStyleClass().add("project-comment-reply-submit"); + submit.setGraphic(LauncherIcons.icon(LauncherIcons.Glyph.CORNER_DOWN_RIGHT, 15)); + submit.setDisable(submitting || replyText.isBlank()); + submit.setOnAction(event -> actions.submitReply(commentId, replyText)); + footer.getChildren().addAll(cancel, submit); + form.getChildren().addAll(text, footer); + return form; + } + + private Node developerReply( + ProjectComment comment, + Map profiles, + ProjectSummary summary, + ProjectDetail detail, + boolean submitting + ) { + ProjectComment.Reply reply = comment.developerReply(); + String replyUserId = value(reply.userId(), ""); + CommentIdentity identity = identity(replyUserId, reply.user(), reply.author(), profiles, reply.date()); + + HBox row = new HBox(12); + row.getStyleClass().add("project-comment-developer-reply-row"); + VBox.setMargin(row, new Insets(12, 0, 0, 0)); + row.getChildren().add(replyConnector()); + row.getChildren().add(voteWidget(comment.id(), true, reply.score(), reply.userVoteFor(currentUserId()), submitting)); + + VBox card = new VBox(0); + card.getStyleClass().add("project-comment-developer-reply"); + card.setMinWidth(0); + HBox.setHgrow(card, Priority.ALWAYS); + card.getChildren().add(commentHeader(identity, roleBadge(replyUserId, summary, detail), 32)); + Node markdown = markdown(reply.content()); + VBox.setMargin(markdown, new Insets(8, 0, 0, 0)); + card.getChildren().add(markdown); + + HBox actionsRow = new HBox(16); + actionsRow.getStyleClass().add("project-comment-actions"); + actionsRow.setAlignment(Pos.CENTER_LEFT); + VBox.setMargin(actionsRow, new Insets(12, 0, 0, 0)); + CurrentUser user = currentUser.get(); + if (user != null && !value(user.id(), "").equals(replyUserId)) { + actionsRow.getChildren().add(actionButton("Report", LauncherIcons.Glyph.FLAG, () -> actions.report(comment.id()))); + } + card.getChildren().add(actionsRow); + row.getChildren().add(card); + return row; + } + + private Node replyConnector() { + StackPane connector = new StackPane(); + connector.getStyleClass().add("project-comment-reply-connector"); + connector.setMinWidth(28); + connector.setPrefWidth(28); + connector.setMaxWidth(28); + Region vertical = new Region(); + vertical.getStyleClass().add("project-comment-reply-line-vertical"); + Region horizontal = new Region(); + horizontal.getStyleClass().add("project-comment-reply-line-horizontal"); + StackPane.setAlignment(vertical, Pos.TOP_CENTER); + StackPane.setAlignment(horizontal, Pos.TOP_RIGHT); + StackPane.setMargin(horizontal, new Insets(17, 0, 0, 0)); + connector.getChildren().addAll(vertical, horizontal); + return connector; + } + + private Node voteWidget(String commentId, boolean reply, int score, String userVote, boolean submitting) { + VBox vote = new VBox(0); + vote.getStyleClass().add("project-comment-vote"); + vote.setAlignment(Pos.TOP_CENTER); + Button up = voteButton(LauncherIcons.Glyph.ARROW_BIG_UP, () -> actions.vote(commentId, reply, true)); + up.pseudoClassStateChanged(UPVOTED, "up".equals(userVote)); + Button down = voteButton(LauncherIcons.Glyph.ARROW_BIG_DOWN, () -> actions.vote(commentId, reply, false)); + down.pseudoClassStateChanged(DOWNVOTED, "down".equals(userVote)); + up.setDisable(submitting); + down.setDisable(submitting); + Label scoreLabel = new Label(score > 0 ? "+" + score : Integer.toString(score)); + scoreLabel.getStyleClass().add("project-comment-score"); + if ("up".equals(userVote)) { + scoreLabel.getStyleClass().add("upvoted"); + } else if ("down".equals(userVote)) { + scoreLabel.getStyleClass().add("downvoted"); + } + vote.getChildren().addAll(up, scoreLabel, down); + return vote; + } + + private Button voteButton(LauncherIcons.Glyph glyph, Runnable action) { + Button button = new Button(null, LauncherIcons.icon(glyph, 24)); + button.getStyleClass().add("project-comment-vote-button"); + button.setOnAction(event -> { + if (currentUser.get() == null) { + signIn.run(); + return; + } + action.run(); + }); + return button; + } + + private Node markdown(String content) { + Node rendered = markdownRenderer.render(value(content, "")); + rendered.getStyleClass().add("project-comment-markdown"); + return rendered; + } + + private Node stateCard(String message) { + Label state = new Label(message); + state.getStyleClass().add("project-comments-state-label"); + return stateCard(state); + } + + private Node stateCard(Node content) { + StackPane state = new StackPane(content); + state.getStyleClass().add("project-comments-state"); + state.setMaxWidth(Double.MAX_VALUE); + return state; + } + + private Node avatar(double size, String userId, String username, String avatarUrl, boolean clickable) { + StackPane avatar = new StackPane(); + avatar.getStyleClass().add("project-comment-avatar"); + avatar.setMinSize(size, size); + avatar.setPrefSize(size, size); + avatar.setMaxSize(size, size); + Rectangle clip = new Rectangle(size, size); + clip.setArcWidth(size); + clip.setArcHeight(size); + avatar.setClip(clip); + String resolvedAvatar = value(avatarUrl, ""); + if (!resolvedAvatar.isBlank() && !"null".equalsIgnoreCase(resolvedAvatar)) { + ImageView image = new ImageView(); + image.getStyleClass().add("project-comment-avatar-image"); + image.setFitWidth(size); + image.setFitHeight(size); + image.setPreserveRatio(false); + image.setSmooth(true); + imageLoader.loadInto(image, resolvedAvatar, size * 2, size * 2); + avatar.getChildren().add(image); + } else { + Label initial = new Label(initial(username)); + initial.getStyleClass().add("project-comment-avatar-initial"); + avatar.getChildren().add(initial); + } + if (clickable && !value(userId, "").isBlank()) { + avatar.setCursor(Cursor.HAND); + avatar.setOnMouseClicked(event -> openProfile.accept(userId, username)); + } + return avatar; + } + + private Node roleBadge(RoleBadge badge) { + Label label = new Label(badge.label()); + label.getStyleClass().add("project-comment-role-badge"); + String color = badge.color(); + label.setStyle("-fx-text-fill: " + color + ";" + + "-fx-background-color: " + color + "1A;" + + "-fx-border-color: " + color + "33;"); + return label; + } + + private RoleBadge roleBadge(String userId, ProjectSummary summary, ProjectDetail detail) { + String authorId = value(detail == null ? null : detail.authorId(), summary == null ? null : summary.authorId()); + if (!value(userId, "").isBlank() && userId.equals(authorId)) { + return new RoleBadge("Owner", OWNER_ROLE_COLOR); + } + return null; + } + + private CommentIdentity identity( + String userId, + String username, + ProjectComment.Author author, + Map profiles + ) { + return identity(userId, username, author, profiles, ""); + } + + private CommentIdentity identity( + String userId, + String username, + ProjectComment.Author author, + Map profiles, + String date + ) { + String id = value(userId, author == null ? "" : author.id()); + UserSummary profile = id.isBlank() ? null : profiles.get(id); + String resolvedName = first( + profile == null ? null : profile.username(), + author == null ? null : author.username(), + username, + "Unknown" + ); + String resolvedAvatar = first(profile == null ? null : profile.avatarUrl(), author == null ? null : author.avatarUrl(), ""); + return new CommentIdentity(id, resolvedName, resolvedAvatar, date); + } + + private static boolean isCreator(CurrentUser user, ProjectSummary summary, ProjectDetail detail) { + if (user == null) { + return false; + } + String userId = value(user.id(), ""); + String userName = value(user.username(), ""); + String authorId = value(detail == null ? null : detail.authorId(), summary == null ? null : summary.authorId()); + String authorName = value(detail == null ? null : detail.author(), summary == null ? null : summary.author()); + return (!userId.isBlank() && userId.equals(authorId)) + || (!userName.isBlank() && userName.equalsIgnoreCase(authorName)); + } + + private String currentUserId() { + CurrentUser user = currentUser.get(); + return user == null ? null : user.id(); + } + + private static String initial(String username) { + String normalized = value(username, "?").trim(); + return normalized.isBlank() ? "?" : normalized.substring(0, 1).toUpperCase(Locale.ROOT); + } + + private static String first(String... values) { + for (String candidate : values) { + if (candidate != null && !candidate.isBlank() && !"null".equalsIgnoreCase(candidate)) { + return candidate; + } + } + return ""; + } + + private record CommentIdentity(String userId, String username, String avatarUrl, String date) { + } + + private record RoleBadge(String label, String color) { + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/project/NativeCreatorProfileView.java b/launcher/src/main/java/net/modtale/launcher/ui/project/NativeCreatorProfileView.java new file mode 100644 index 00000000..58ad99ef --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/project/NativeCreatorProfileView.java @@ -0,0 +1,640 @@ +package net.modtale.launcher.ui.project; + +import static net.modtale.launcher.ui.browse.card.ProjectCardFormatter.number; +import static net.modtale.launcher.ui.common.LauncherUi.value; + +import java.util.List; +import java.util.Locale; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; +import javafx.beans.binding.Bindings; +import javafx.beans.property.ReadOnlyDoubleProperty; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Cursor; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.OverrunStyle; +import javafx.scene.image.ImageView; +import javafx.scene.layout.FlowPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import javafx.scene.shape.Rectangle; +import net.modtale.launcher.model.project.ProjectPage; +import net.modtale.launcher.model.project.ProjectSummary; +import net.modtale.launcher.model.user.CreatorProfile; +import net.modtale.launcher.model.user.CurrentUser; +import net.modtale.launcher.ui.browse.card.ProjectCardFactory; +import net.modtale.launcher.ui.browse.card.ProjectCardViewStyle; +import net.modtale.launcher.ui.common.CachedImageLoader; +import net.modtale.launcher.ui.common.LauncherIcons; +import net.modtale.launcher.ui.common.LauncherLayout; + +final class NativeCreatorProfileView { + + private static final double CONTENT_MAX_WIDTH = 1568; + private static final double DESKTOP_NAV_OVERLAP = 91; + private static final double PROFILE_CARD_HEIGHT = 316; + private static final double PROFILE_CARD_LIFT = -32; + private static final double AVATAR_SIZE = 224; + private static final double PROJECT_GRID_GAP = 24; + private static final double BANNER_FADE_BASE_HEIGHT = 128; + private static final int DESKTOP_PROJECT_COLUMNS = 4; + + private final CachedImageLoader imageLoader; + private final ProjectCardFactory projectCardFactory; + private final Supplier gameVersion; + private final Supplier currentUser; + private final Function favoriteResolver; + private final Consumer installProject; + private final Consumer openProject; + private final Consumer openCreator; + private final Consumer toggleFavorite; + private final Runnable showDiscover; + private final Runnable toggleFollow; + private final Runnable copyCreatorId; + private final Consumer reportCreator; + private final Consumer openUrl; + private final ReadOnlyDoubleProperty scrollPixels; + + NativeCreatorProfileView( + CachedImageLoader imageLoader, + ProjectCardFactory projectCardFactory, + Supplier gameVersion, + Supplier currentUser, + Function favoriteResolver, + Consumer installProject, + Consumer openProject, + Consumer openCreator, + Consumer toggleFavorite, + Runnable showDiscover, + Runnable toggleFollow, + Runnable copyCreatorId, + Consumer reportCreator, + Consumer openUrl, + ReadOnlyDoubleProperty scrollPixels + ) { + this.imageLoader = imageLoader; + this.projectCardFactory = projectCardFactory; + this.gameVersion = gameVersion; + this.currentUser = currentUser; + this.favoriteResolver = favoriteResolver; + this.installProject = installProject; + this.openProject = openProject; + this.openCreator = openCreator; + this.toggleFavorite = toggleFavorite; + this.showDiscover = showDiscover; + this.toggleFollow = toggleFollow; + this.copyCreatorId = copyCreatorId; + this.reportCreator = reportCreator == null ? profile -> { + } : reportCreator; + this.openUrl = openUrl; + this.scrollPixels = scrollPixels; + } + + Node render(CreatorProfile profile, ProjectPage projects, List relatedProfiles, boolean loading, boolean compact) { + VBox page = new VBox(0); + page.getStyleClass().add("creator-profile-page"); + page.setAlignment(Pos.TOP_CENTER); + page.setFillWidth(true); + page.setMinWidth(0); + page.setMaxWidth(Double.MAX_VALUE); + + StackPane hero = hero(profile); + hero.prefHeightProperty().bind(Bindings.createDoubleBinding( + () -> Math.max(compact ? 280 : 360, page.getWidth() / 3.0 - (compact ? 0 : DESKTOP_NAV_OVERLAP)), + page.widthProperty() + )); + hero.minHeightProperty().bind(hero.prefHeightProperty()); + + Node card = loading ? loadingCard() : profileCard(profile, projects); + VBox.setMargin(card, LauncherLayout.launcherPageInsets(PROFILE_CARD_LIFT, 0)); + + VBox body = profileBody(profile, projects, relatedProfiles, loading, compact); + VBox.setMargin(body, compact + ? LauncherLayout.launcherPageInsets(36, 80) + : LauncherLayout.launcherPageInsets(64, 80)); + + page.getChildren().addAll(hero, card, body); + page.minHeightProperty().bind(Bindings.createDoubleBinding( + () -> hero.getPrefHeight() + PROFILE_CARD_LIFT + PROFILE_CARD_HEIGHT + + (compact ? 36 : 64) + body.prefHeight(-1) + 80, + hero.prefHeightProperty(), + body.heightProperty() + )); + page.prefHeightProperty().bind(page.minHeightProperty()); + return page; + } + + private StackPane hero(CreatorProfile profile) { + StackPane hero = new StackPane(); + hero.getStyleClass().add("creator-profile-hero"); + hero.setMaxWidth(Double.MAX_VALUE); + + StackPane media = new StackPane(); + media.getStyleClass().add("creator-profile-banner"); + media.setMaxSize(Double.MAX_VALUE, Double.MAX_VALUE); + String bannerUrl = profile == null ? "" : value(profile.bannerUrl(), ""); + if (bannerUrl.isBlank()) { + media.getChildren().add(fallbackBanner()); + } else { + media.getStyleClass().add("letterboxed"); + media.setClip(rectangleClip(media)); + ImageView image = containedImage(bannerUrl, media, 2400, 800); + image.getStyleClass().add("creator-profile-banner-image"); + media.getChildren().add(image); + } + + Region fade = new Region(); + fade.getStyleClass().add("creator-profile-banner-fade"); + fade.setMouseTransparent(true); + NativeBannerScrollEffect.bind(media, fade, scrollPixels, BANNER_FADE_BASE_HEIGHT); + + HBox backLayer = new HBox(); + backLayer.setAlignment(Pos.TOP_LEFT); + backLayer.setMaxWidth(Double.MAX_VALUE); + backLayer.setMouseTransparent(false); + StackPane.setAlignment(backLayer, Pos.TOP_CENTER); + StackPane.setMargin(backLayer, LauncherLayout.launcherPageInsets(25, 0)); + Button back = new Button("Back", LauncherIcons.icon(LauncherIcons.Glyph.CHEVRON_LEFT, 16)); + back.getStyleClass().add("creator-profile-back"); + back.setOnAction(event -> showDiscover.run()); + backLayer.getChildren().add(back); + + hero.getChildren().addAll(media, fade, backLayer); + return hero; + } + + private Region fallbackBanner() { + Region fallback = new Region(); + fallback.getStyleClass().add("creator-profile-banner-fallback"); + fallback.setMaxSize(Double.MAX_VALUE, Double.MAX_VALUE); + return fallback; + } + + private HBox loadingCard() { + HBox card = new HBox(40); + card.getStyleClass().addAll("creator-profile-card", "creator-profile-loading-card"); + card.setAlignment(Pos.TOP_LEFT); + card.setMaxWidth(Double.MAX_VALUE); + card.setMinHeight(PROFILE_CARD_HEIGHT); + card.setPrefHeight(PROFILE_CARD_HEIGHT); + card.getChildren().add(NativeSpinner.centered()); + return card; + } + + private HBox profileCard(CreatorProfile profile, ProjectPage projects) { + HBox card = new HBox(40); + card.getStyleClass().add("creator-profile-card"); + card.setAlignment(Pos.TOP_LEFT); + card.setMaxWidth(Double.MAX_VALUE); + card.setMinHeight(PROFILE_CARD_HEIGHT); + card.setPrefHeight(PROFILE_CARD_HEIGHT); + + StackPane avatar = avatar(profile); + HBox.setMargin(avatar, new Insets(-96, 0, 0, 8)); + + VBox copy = new VBox(0); + copy.getStyleClass().add("creator-profile-copy"); + copy.setTranslateY(-15); + copy.setMinWidth(0); + HBox.setHgrow(copy, Priority.ALWAYS); + copy.getChildren().addAll( + heading(profile), + actionRow(profile), + bio(profile), + statDivider(), + statRow(profile, projects) + ); + + card.getChildren().addAll(avatar, copy); + return card; + } + + private StackPane avatar(CreatorProfile profile) { + StackPane frame = new StackPane(); + frame.getStyleClass().add("creator-profile-avatar"); + lock(frame, AVATAR_SIZE, AVATAR_SIZE); + + StackPane media = new StackPane(); + media.getStyleClass().add("creator-profile-avatar-media"); + double mediaSize = AVATAR_SIZE - 16; + lock(media, mediaSize, mediaSize); + Rectangle clip = new Rectangle(mediaSize, mediaSize); + clip.setArcWidth(40); + clip.setArcHeight(40); + media.setClip(clip); + + String avatarUrl = profile == null ? "" : value(profile.avatarUrl(), ""); + if (!avatarUrl.isBlank()) { + ImageView image = coverImage(avatarUrl, media, AVATAR_SIZE, AVATAR_SIZE); + image.getStyleClass().add("creator-profile-avatar-image"); + media.getChildren().add(image); + } else { + Label initial = new Label(initial(profile == null ? "" : profile.username())); + initial.getStyleClass().add("creator-profile-avatar-initial"); + media.getChildren().add(initial); + } + frame.getChildren().add(media); + return frame; + } + + private HBox heading(CreatorProfile profile) { + HBox row = new HBox(10); + row.getStyleClass().add("creator-profile-heading-row"); + row.setAlignment(Pos.CENTER_LEFT); + Label title = new Label(value(profile.username(), "Creator")); + title.getStyleClass().add("creator-profile-title"); + title.setTextOverrun(OverrunStyle.ELLIPSIS); + row.getChildren().add(title); + + if (profile.organization()) { + row.getChildren().add(badge("Organization", "organization", LauncherIcons.Glyph.BOX)); + } + for (String badge : profile.badges()) { + String normalized = value(badge, "").toUpperCase(Locale.ROOT); + if (normalized.equals("OG") || normalized.equals("VERIFIED")) { + row.getChildren().add(badge(normalized.equals("OG") ? "OG" : "Verified", normalized.toLowerCase(Locale.ROOT), null)); + } + } + return row; + } + + private Node badge(String text, String style, LauncherIcons.Glyph glyph) { + HBox badge = new HBox(5); + badge.getStyleClass().addAll("creator-profile-badge", style); + badge.setAlignment(Pos.CENTER); + if (glyph != null) { + badge.getChildren().add(LauncherIcons.icon(glyph, 13)); + } + badge.getChildren().add(new Label(text)); + return badge; + } + + private HBox actionRow(CreatorProfile profile) { + HBox row = new HBox(10); + row.getStyleClass().add("creator-profile-action-row"); + row.setAlignment(Pos.CENTER_LEFT); + VBox.setMargin(row, new Insets(7, 0, 0, 0)); + + CurrentUser user = currentUser.get(); + boolean self = user != null && value(user.id(), "").equals(value(profile.id(), "")); + boolean signedIn = user != null; + boolean following = signedIn && user.followsUser(profile.id()); + + Button follow = new Button(self ? "Manage Profile" : signedIn ? following ? "Following" : "Follow" : "Sign in to follow"); + follow.getStyleClass().addAll("creator-profile-follow", following ? "following" : "primary"); + follow.setGraphic(LauncherIcons.icon(self ? LauncherIcons.Glyph.GEAR + : signedIn ? following ? LauncherIcons.Glyph.CHECK : LauncherIcons.Glyph.USER + : LauncherIcons.Glyph.LOG_OUT, 18)); + follow.setOnAction(event -> toggleFollow.run()); + + Button copy = iconAction(LauncherIcons.Glyph.COPY, "Copy ID"); + copy.setOnAction(event -> copyCreatorId.run()); + + Button report = iconAction(LauncherIcons.Glyph.FLAG, "Report User"); + report.getStyleClass().add("report"); + report.setOnAction(event -> reportCreator.accept(profile)); + + row.getChildren().addAll(follow, copy); + if (!self) { + row.getChildren().add(report); + } + return row; + } + + private Button iconAction(LauncherIcons.Glyph glyph, String accessibleText) { + Button button = new Button(null, LauncherIcons.icon(glyph, 20)); + button.getStyleClass().add("creator-profile-icon-action"); + button.setAccessibleText(accessibleText); + return button; + } + + private Label bio(CreatorProfile profile) { + Label bio = new Label(value(profile.bio(), "")); + bio.getStyleClass().add("creator-profile-bio"); + bio.setWrapText(true); + bio.setMinHeight(Region.USE_PREF_SIZE); + bio.setVisible(!bio.getText().isBlank()); + bio.setManaged(!bio.getText().isBlank()); + VBox.setMargin(bio, new Insets(20, 0, 0, 0)); + return bio; + } + + private Region statDivider() { + Region line = new Region(); + line.getStyleClass().add("creator-profile-divider"); + line.setMaxWidth(Double.MAX_VALUE); + line.setMinHeight(1); + line.setPrefHeight(1); + VBox.setMargin(line, new Insets(26, 0, 0, 0)); + return line; + } + + private HBox statRow(CreatorProfile profile, ProjectPage projects) { + HBox row = new HBox(0); + row.getStyleClass().add("creator-profile-stat-row"); + row.setAlignment(Pos.CENTER_LEFT); + VBox.setMargin(row, new Insets(23, 0, 0, 0)); + row.getChildren().add(stats(profile, projects)); + + Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + row.getChildren().add(spacer); + + HBox socials = socials(profile); + row.getChildren().add(socials); + return row; + } + + private HBox stats(CreatorProfile profile, ProjectPage projects) { + int downloads = 0; + int favorites = 0; + List content = projects == null ? List.of() : projects.content(); + for (ProjectSummary project : content) { + downloads += project.downloadCount(); + favorites += project.favoriteCount(); + } + HBox row = new HBox(34); + row.setAlignment(Pos.CENTER_LEFT); + row.getChildren().addAll( + stat(number(downloads), "Downloads"), + stat(number(favorites), "Favorites"), + stat(number(followerCount(profile)), "Followers"), + stat(number(projects == null ? content.size() : Math.toIntExact(Math.min(Integer.MAX_VALUE, projects.totalElements()))), "Projects") + ); + return row; + } + + private VBox stat(String value, String label) { + VBox stat = new VBox(1); + stat.getStyleClass().add("creator-profile-stat"); + Label number = new Label(value); + number.getStyleClass().add("creator-profile-stat-number"); + Label caption = new Label(label.toUpperCase(Locale.ROOT)); + caption.getStyleClass().add("creator-profile-stat-caption"); + stat.getChildren().addAll(number, caption); + return stat; + } + + private HBox socials(CreatorProfile profile) { + HBox row = new HBox(8); + row.getStyleClass().add("creator-profile-socials"); + row.setAlignment(Pos.CENTER_RIGHT); + for (CreatorProfile.ConnectedAccount account : profile.connectedAccounts()) { + if (account.isVisible()) { + row.getChildren().add(socialButton(account)); + } + } + return row; + } + + private Button socialButton(CreatorProfile.ConnectedAccount account) { + String provider = value(account.provider(), "website").toLowerCase(Locale.ROOT); + Button button = new Button(null, socialIcon(provider)); + button.getStyleClass().addAll("creator-profile-social-button", provider); + button.setAccessibleText(provider + " profile"); + String url = socialUrl(account); + button.setOnAction(event -> openUrl.accept(url)); + return button; + } + + private Node socialIcon(String provider) { + return switch (provider) { + case "discord" -> LauncherIcons.brandIcon(LauncherIcons.BrandGlyph.DISCORD, 18); + case "github" -> LauncherIcons.brandIcon(LauncherIcons.BrandGlyph.GITHUB, 18); + case "gitlab" -> LauncherIcons.brandIcon(LauncherIcons.BrandGlyph.GITLAB, 18); + default -> LauncherIcons.icon(LauncherIcons.Glyph.GLOBE, 18); + }; + } + + private String socialUrl(CreatorProfile.ConnectedAccount account) { + String provider = value(account.provider(), "").toLowerCase(Locale.ROOT); + if (provider.equals("discord") && !value(account.providerId(), "").isBlank()) { + return "https://discord.com/users/" + account.providerId(); + } + return value(account.profileUrl(), ""); + } + + private VBox profileBody( + CreatorProfile profile, + ProjectPage projects, + List relatedProfiles, + boolean loading, + boolean compact + ) { + VBox body = new VBox(0); + body.getStyleClass().add("creator-profile-body"); + body.setMaxWidth(Double.MAX_VALUE); + body.setMinWidth(0); + + if (!loading && relatedProfiles != null && !relatedProfiles.isEmpty()) { + body.getChildren().add(relatedProfiles(profile, relatedProfiles)); + } + + Label heading = new Label("Published Work"); + heading.getStyleClass().add("creator-profile-section-title"); + body.getChildren().add(heading); + + Node content = loading ? projectSkeletonGrid(compact) : projects(projects, compact); + VBox.setMargin(content, new Insets(24, 0, 0, 0)); + body.getChildren().add(content); + return body; + } + + private VBox relatedProfiles(CreatorProfile profile, List relatedProfiles) { + VBox section = new VBox(14); + section.getStyleClass().add("creator-profile-related-section"); + Label title = new Label(profile.organization() ? "Organization Members" : "Member Organizations"); + title.getStyleClass().add("creator-profile-section-title"); + FlowPane chips = new FlowPane(16, 12); + chips.getStyleClass().add("creator-profile-related-grid"); + for (CreatorProfile related : relatedProfiles) { + chips.getChildren().add(profileChip(related)); + } + section.getChildren().addAll(title, chips); + VBox.setMargin(section, new Insets(0, 0, 44, 0)); + return section; + } + + private HBox profileChip(CreatorProfile profile) { + HBox chip = new HBox(12); + chip.getStyleClass().add("creator-profile-related-chip"); + chip.setAlignment(Pos.CENTER_LEFT); + chip.setOnMouseClicked(event -> openUrl.accept("https://modtale.net/creator/" + value(profile.username(), profile.id()))); + chip.setCursor(Cursor.HAND); + StackPane avatar = smallAvatar(profile); + VBox copy = new VBox(1); + Label name = new Label(value(profile.username(), "Creator")); + name.getStyleClass().add("creator-profile-related-name"); + Label role = new Label(profile.organization() ? "Organization" : "Member"); + role.getStyleClass().add("creator-profile-related-role"); + copy.getChildren().addAll(name, role); + chip.getChildren().addAll(avatar, copy); + return chip; + } + + private StackPane smallAvatar(CreatorProfile profile) { + StackPane frame = new StackPane(); + frame.getStyleClass().add("creator-profile-related-avatar"); + lock(frame, 40, 40); + String avatarUrl = value(profile.avatarUrl(), ""); + if (!avatarUrl.isBlank()) { + frame.setClip(roundedClip(frame, 8)); + ImageView image = coverImage(avatarUrl, frame, 80, 80); + frame.getChildren().add(image); + } else { + Label initial = new Label(initial(profile.username())); + initial.getStyleClass().add("creator-profile-related-initial"); + frame.getChildren().add(initial); + } + return frame; + } + + private Node projectSkeletonGrid(boolean compact) { + FlowPane grid = projectGrid(); + int count = compact ? 3 : 8; + for (int i = 0; i < count; i++) { + Region skeleton = new Region(); + skeleton.getStyleClass().add("creator-profile-project-skeleton"); + lock(skeleton, compact ? 320 : projectCardWidth(), compact ? 160 : projectCardWidth()); + grid.getChildren().add(skeleton); + } + return grid; + } + + private Node projects(ProjectPage projects, boolean compact) { + List content = projects == null ? List.of() : projects.content(); + if (content.isEmpty()) { + VBox empty = new VBox(10); + empty.getStyleClass().add("creator-profile-empty"); + empty.setAlignment(Pos.CENTER); + empty.getChildren().addAll(LauncherIcons.icon(LauncherIcons.Glyph.BOX, 36), + new Label("No projects found"), + new Label("This user hasn't published any projects yet.")); + return empty; + } + + FlowPane grid = projectGrid(); + double width = compact ? 320 : projectCardWidth(); + for (ProjectSummary project : content) { + Node card = projectCardFactory.create( + project, + compact ? ProjectCardViewStyle.LIST : ProjectCardViewStyle.GRID, + gameVersion.get(), + Boolean.TRUE.equals(favoriteResolver.apply(project.id())), + installProject, + openProject, + openCreator, + toggleFavorite, + width, + compact ? 160 : width + ); + grid.getChildren().add(card); + } + return grid; + } + + private FlowPane projectGrid() { + FlowPane grid = new FlowPane(PROJECT_GRID_GAP, PROJECT_GRID_GAP); + grid.getStyleClass().add("creator-profile-project-grid"); + grid.setMaxWidth(CONTENT_MAX_WIDTH); + return grid; + } + + private double projectCardWidth() { + return Math.floor((CONTENT_MAX_WIDTH - PROJECT_GRID_GAP * (DESKTOP_PROJECT_COLUMNS - 1)) / DESKTOP_PROJECT_COLUMNS); + } + + private ImageView coverImage(String url, Region box, double requestedWidth, double requestedHeight) { + ImageView image = new ImageView(); + image.setSmooth(true); + image.setPreserveRatio(false); + imageLoader.loadInto(image, url, requestedWidth, requestedHeight, true); + Runnable update = () -> { + double width = box.getWidth(); + double height = box.getHeight(); + javafx.scene.image.Image loaded = image.getImage(); + if (!Double.isFinite(width) || width <= 1 || !Double.isFinite(height) || height <= 1 || loaded == null) { + image.setFitWidth(requestedWidth); + image.setFitHeight(requestedHeight); + return; + } + double imageWidth = loaded.getWidth(); + double imageHeight = loaded.getHeight(); + if (!Double.isFinite(imageWidth) || imageWidth <= 0 || !Double.isFinite(imageHeight) || imageHeight <= 0) { + return; + } + double imageRatio = imageWidth / imageHeight; + double boxRatio = width / height; + if (imageRatio > boxRatio) { + image.setFitWidth(height * imageRatio); + image.setFitHeight(height); + } else { + image.setFitWidth(width); + image.setFitHeight(width / imageRatio); + } + }; + box.widthProperty().addListener((observable, previous, current) -> update.run()); + box.heightProperty().addListener((observable, previous, current) -> update.run()); + image.imageProperty().addListener((observable, previous, current) -> update.run()); + return image; + } + + private ImageView containedImage(String url, Region box, double requestedWidth, double requestedHeight) { + ImageView image = new ImageView(); + image.setSmooth(true); + image.setPreserveRatio(true); + image.fitWidthProperty().bind(box.widthProperty()); + image.fitHeightProperty().bind(box.heightProperty()); + imageLoader.loadInto(image, url, requestedWidth, requestedHeight, true); + return image; + } + + private Rectangle rectangleClip(Region owner) { + Rectangle clip = new Rectangle(); + clip.widthProperty().bind(owner.widthProperty()); + clip.heightProperty().bind(owner.heightProperty()); + return clip; + } + + private Rectangle roundedClip(Region owner, double radius) { + Rectangle clip = rectangleClip(owner); + clip.setArcWidth(radius * 2); + clip.setArcHeight(radius * 2); + return clip; + } + + private int followerCount(CreatorProfile profile) { + CurrentUser user = currentUser.get(); + int count = profile.followerIds().size(); + if (user == null || value(user.id(), "").isBlank()) { + return count; + } + boolean actual = user.followsUser(profile.id()); + boolean recorded = profile.followerIds().contains(user.id()); + if (actual && !recorded) { + return count + 1; + } + if (!actual && recorded) { + return Math.max(0, count - 1); + } + return count; + } + + private static void lock(Region region, double width, double height) { + region.setMinSize(width, height); + region.setPrefSize(width, height); + region.setMaxSize(width, height); + } + + private static String initial(String value) { + String normalized = value(value, "C").trim(); + return normalized.isEmpty() ? "C" : normalized.substring(0, 1).toUpperCase(Locale.ROOT); + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/project/NativeDependencyModal.java b/launcher/src/main/java/net/modtale/launcher/ui/project/NativeDependencyModal.java new file mode 100644 index 00000000..8f7cc80d --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/project/NativeDependencyModal.java @@ -0,0 +1,512 @@ +package net.modtale.launcher.ui.project; + +import java.util.IdentityHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; +import java.util.function.Consumer; +import java.util.function.Supplier; +import javafx.application.Platform; +import javafx.css.PseudoClass; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.Parent; +import javafx.scene.control.Button; +import javafx.scene.control.ContentDisplay; +import javafx.scene.control.Label; +import javafx.scene.control.OverrunStyle; +import javafx.scene.control.ScrollPane; +import javafx.scene.effect.Effect; +import javafx.scene.effect.GaussianBlur; +import javafx.scene.image.ImageView; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import javafx.scene.shape.Rectangle; +import net.modtale.launcher.api.ModtaleApiClient; +import net.modtale.launcher.model.project.ProjectDependency; +import net.modtale.launcher.model.project.ProjectDetail; +import net.modtale.launcher.model.project.ProjectMeta; +import net.modtale.launcher.model.project.ProjectVersion; +import net.modtale.launcher.ui.common.CachedImageLoader; +import net.modtale.launcher.ui.common.LauncherIcons; + +final class NativeDependencyModal { + + private static final double MODAL_WIDTH = 512; + private static final double MODAL_MAX_HEIGHT = 720; + private static final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected"); + private static final PseudoClass MISSING = PseudoClass.getPseudoClass("missing"); + + private final Supplier host; + private final ModtaleApiClient apiClient; + private final Executor executor; + private final CachedImageLoader imageLoader; + private final Consumer install; + private final Map backdropEffects = new IdentityHashMap<>(); + private final Map metadata = new ConcurrentHashMap<>(); + private final Set requestedMetadataIds = ConcurrentHashMap.newKeySet(); + private final Set selectedDependencyIds = new LinkedHashSet<>(); + + private StackPane overlay; + private ProjectDetail project; + private ProjectVersion version; + private String gameVersion; + private List dependencies = List.of(); + + NativeDependencyModal( + Supplier host, + ModtaleApiClient apiClient, + Executor executor, + CachedImageLoader imageLoader, + Consumer install + ) { + this.host = host == null ? () -> null : host; + this.apiClient = apiClient; + this.executor = executor; + this.imageLoader = imageLoader; + this.install = install == null ? ignored -> { + } : install; + } + + void show(ProjectDetail project, ProjectVersion version, String gameVersion) { + this.project = project; + this.version = version; + this.gameVersion = gameVersion; + this.dependencies = selectableDependencies(version); + this.selectedDependencyIds.clear(); + this.dependencies.stream() + .map(ProjectDependency::projectId) + .filter(id -> id != null && !id.isBlank()) + .forEach(selectedDependencyIds::add); + rebuildOverlay(); + requestDependencyMetadata(); + } + + void hide() { + if (overlay == null) { + return; + } + Parent parent = overlay.getParent(); + if (parent instanceof StackPane stack) { + stack.getChildren().remove(overlay); + } + overlay = null; + restoreBackdrop(); + } + + private void rebuildOverlay() { + StackPane hostPane = host.get(); + if (hostPane == null || project == null || version == null) { + return; + } + if (overlay == null) { + overlay = overlayShell(); + blurBackdrop(hostPane); + hostPane.getChildren().add(overlay); + } + overlay.getChildren().setAll(modal()); + Platform.runLater(overlay::requestFocus); + } + + private StackPane overlayShell() { + StackPane shell = new StackPane(); + shell.getStyleClass().add("dependency-modal-overlay"); + shell.setMaxSize(Double.MAX_VALUE, Double.MAX_VALUE); + shell.setFocusTraversable(true); + shell.addEventHandler(KeyEvent.KEY_PRESSED, event -> { + if (event.getCode() == KeyCode.ESCAPE) { + hide(); + event.consume(); + } + }); + shell.setOnMouseClicked(event -> { + if (event.getTarget() == shell) { + hide(); + } + }); + return shell; + } + + private VBox modal() { + VBox modal = new VBox(0); + modal.getStyleClass().add("dependency-modal"); + modal.setMaxWidth(MODAL_WIDTH); + modal.setPrefWidth(MODAL_WIDTH); + modal.setMaxHeight(MODAL_MAX_HEIGHT); + modal.setOnMouseClicked(event -> event.consume()); + + ScrollPane bodyScroll = new ScrollPane(body()); + bodyScroll.getStyleClass().add("dependency-modal-scroll"); + bodyScroll.setFitToWidth(true); + bodyScroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + bodyScroll.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED); + VBox.setVgrow(bodyScroll, Priority.ALWAYS); + + modal.getChildren().addAll(header(), bodyScroll, footer()); + return modal; + } + + private HBox header() { + HBox header = new HBox(16); + header.getStyleClass().add("dependency-modal-header"); + header.setAlignment(Pos.CENTER_LEFT); + + HBox title = new HBox(8, LauncherIcons.icon(LauncherIcons.Glyph.LINK, 20), new Label("Dependencies")); + title.getStyleClass().add("dependency-modal-title"); + title.setAlignment(Pos.CENTER_LEFT); + HBox.setHgrow(title, Priority.ALWAYS); + + Button close = new Button(null, LauncherIcons.icon(LauncherIcons.Glyph.X, 18)); + close.getStyleClass().add("dependency-modal-close"); + close.setOnAction(event -> hide()); + header.getChildren().addAll(title, close); + return header; + } + + private VBox body() { + VBox body = new VBox(16); + body.getStyleClass().add("dependency-modal-body"); + body.getChildren().addAll(summaryRow(), dependencyList()); + Node warning = missingRequiredWarning(); + if (warning != null) { + body.getChildren().add(warning); + } + return body; + } + + private HBox summaryRow() { + HBox row = new HBox(12); + row.setAlignment(Pos.TOP_LEFT); + VBox copy = new VBox(2); + HBox.setHgrow(copy, Priority.ALWAYS); + Label description = new Label("Select dependencies to include in your bundle download."); + description.getStyleClass().add("dependency-modal-description"); + description.setWrapText(true); + copy.getChildren().add(description); + + Button toggle = new Button(selectedDependencyIds.size() == dependencies.size() ? "Deselect All" : "Select All"); + toggle.getStyleClass().add("dependency-modal-toggle-all"); + toggle.setOnAction(event -> toggleAll()); + row.getChildren().addAll(copy, toggle); + return row; + } + + private VBox dependencyList() { + VBox list = new VBox(8); + list.getStyleClass().add("dependency-modal-list"); + for (ProjectDependency dependency : dependencies) { + list.getChildren().add(dependencyRow(dependency)); + } + return list; + } + + private Node dependencyRow(ProjectDependency dependency) { + boolean selected = selectedDependencyIds.contains(dependency.projectId()); + boolean missingRequired = !dependency.isOptional() && !selected; + + HBox row = new HBox(16); + row.getStyleClass().add("dependency-modal-row"); + row.pseudoClassStateChanged(SELECTED, selected); + row.pseudoClassStateChanged(MISSING, missingRequired); + row.setAlignment(Pos.CENTER_LEFT); + row.setOnMouseClicked(event -> toggleDependency(dependency)); + + HBox left = new HBox(16); + left.setAlignment(Pos.CENTER_LEFT); + left.setMinWidth(0); + HBox.setHgrow(left, Priority.ALWAYS); + left.getChildren().addAll(selectionState(selected, missingRequired), dependencyIcon(dependency), dependencyCopy(dependency)); + + Label badge = dependencyBadge(dependency, missingRequired); + row.getChildren().addAll(left, badge); + return row; + } + + private StackPane selectionState(boolean selected, boolean missingRequired) { + StackPane state = new StackPane(); + state.getStyleClass().add("dependency-modal-state"); + if (selected) { + state.getStyleClass().add("selected"); + state.getChildren().add(LauncherIcons.icon(LauncherIcons.Glyph.CHECK, 13)); + } else if (missingRequired) { + state.getStyleClass().add("missing"); + Label bang = new Label("!"); + bang.getStyleClass().add("dependency-modal-state-warning"); + state.getChildren().add(bang); + } + return state; + } + + private StackPane dependencyIcon(ProjectDependency dependency) { + StackPane icon = new StackPane(); + icon.getStyleClass().add("dependency-modal-icon"); + String iconUrl = dependencyIconUrl(dependency); + if (imageLoader != null && iconUrl != null && !iconUrl.isBlank()) { + ImageView image = new ImageView(); + image.setFitWidth(44); + image.setFitHeight(44); + image.setPreserveRatio(false); + image.setSmooth(true); + Rectangle clip = new Rectangle(44, 44); + clip.setArcWidth(8); + clip.setArcHeight(8); + image.setClip(clip); + imageLoader.loadInto(image, iconUrl, 88, 88); + icon.getChildren().add(image); + } else { + icon.getChildren().add(LauncherIcons.icon(LauncherIcons.Glyph.BOX, 20)); + } + return icon; + } + + private VBox dependencyCopy(ProjectDependency dependency) { + VBox copy = new VBox(4); + copy.setMinWidth(0); + HBox.setHgrow(copy, Priority.ALWAYS); + + Label title = new Label(dependencyTitle(dependency)); + title.getStyleClass().add("dependency-modal-dependency-title"); + title.setTextOverrun(OverrunStyle.ELLIPSIS); + title.setMaxWidth(Double.MAX_VALUE); + + HBox meta = new HBox(7); + meta.getStyleClass().add("dependency-modal-meta"); + meta.setAlignment(Pos.CENTER_LEFT); + Label author = new Label("by " + dependencyAuthor(dependency)); + author.getStyleClass().add("dependency-modal-author"); + meta.getChildren().add(author); + if (!isBlank(dependency.versionNumber())) { + Region dot = new Region(); + dot.getStyleClass().add("dependency-modal-dot"); + Label versionLabel = new Label("v" + dependency.versionNumber()); + versionLabel.getStyleClass().add("dependency-modal-version"); + meta.getChildren().addAll(dot, versionLabel); + } + + copy.getChildren().addAll(title, meta); + return copy; + } + + private Label dependencyBadge(ProjectDependency dependency, boolean missingRequired) { + Label badge = new Label(dependency.isOptional() ? "OPTIONAL" : "REQUIRED"); + badge.getStyleClass().add("dependency-modal-badge"); + if (missingRequired) { + badge.getStyleClass().add("missing"); + badge.setGraphic(LauncherIcons.icon(LauncherIcons.Glyph.ALERT_CIRCLE, 12)); + } else if (dependency.isOptional()) { + badge.getStyleClass().add("optional"); + } else { + badge.getStyleClass().add("required"); + } + return badge; + } + + private Node missingRequiredWarning() { + if (!missingRequired()) { + return null; + } + HBox warning = new HBox(8); + warning.getStyleClass().add("dependency-modal-warning"); + warning.setAlignment(Pos.TOP_LEFT); + Label copy = new Label("You have unselected Required dependencies. The project may not function correctly without them."); + copy.getStyleClass().add("dependency-modal-warning-copy"); + copy.setWrapText(true); + HBox.setHgrow(copy, Priority.ALWAYS); + warning.getChildren().addAll(LauncherIcons.icon(LauncherIcons.Glyph.ALERT_CIRCLE, 16), copy); + return warning; + } + + private HBox footer() { + HBox footer = new HBox(); + footer.getStyleClass().add("dependency-modal-footer"); + footer.setAlignment(Pos.CENTER); + Button download = new Button(); + download.getStyleClass().add("dependency-modal-download"); + download.setMaxWidth(Double.MAX_VALUE); + download.setAlignment(Pos.CENTER); + download.setContentDisplay(ContentDisplay.GRAPHIC_ONLY); + download.setGraphic(downloadButtonContent()); + download.setOnAction(event -> installSelected()); + HBox.setHgrow(download, Priority.ALWAYS); + footer.getChildren().add(download); + return footer; + } + + private VBox downloadButtonContent() { + VBox content = new VBox(3); + content.setAlignment(Pos.CENTER); + content.setMaxWidth(Double.MAX_VALUE); + HBox title = new HBox(8, LauncherIcons.icon(LauncherIcons.Glyph.DOWNLOAD, 20), + new Label(selectedDependencyIds.isEmpty() ? "Download Project Only" : "Download Bundle")); + title.getStyleClass().add("dependency-modal-download-title"); + title.setAlignment(Pos.CENTER); + title.setMaxWidth(Double.MAX_VALUE); + content.getChildren().add(title); + if (!selectedDependencyIds.isEmpty()) { + Label subtitle = new Label("Includes project + " + selectedDependencyIds.size() + + " dependenc" + (selectedDependencyIds.size() == 1 ? "y" : "ies")); + subtitle.getStyleClass().add("dependency-modal-download-subtitle"); + subtitle.setAlignment(Pos.CENTER); + subtitle.setMaxWidth(Double.MAX_VALUE); + content.getChildren().add(subtitle); + } + return content; + } + + private void toggleDependency(ProjectDependency dependency) { + String id = dependency.projectId(); + if (id == null || id.isBlank()) { + return; + } + if (selectedDependencyIds.contains(id)) { + selectedDependencyIds.remove(id); + } else { + selectedDependencyIds.add(id); + } + rebuildOverlay(); + } + + private void toggleAll() { + if (selectedDependencyIds.size() == dependencies.size()) { + selectedDependencyIds.clear(); + } else { + selectedDependencyIds.clear(); + dependencies.stream() + .map(ProjectDependency::projectId) + .filter(id -> id != null && !id.isBlank()) + .forEach(selectedDependencyIds::add); + } + rebuildOverlay(); + } + + private boolean missingRequired() { + return dependencies.stream() + .anyMatch(dependency -> !dependency.isOptional() && !selectedDependencyIds.contains(dependency.projectId())); + } + + private void installSelected() { + ProjectDetail selectedProject = project; + ProjectVersion selectedVersion = version; + String selectedGameVersion = gameVersion; + List selectedDependencies = dependencies.stream() + .filter(dependency -> selectedDependencyIds.contains(dependency.projectId())) + .toList(); + hide(); + install.accept(new DependencySelection(selectedProject, selectedVersion, selectedGameVersion, selectedDependencies)); + } + + private void requestDependencyMetadata() { + if (apiClient == null || executor == null || dependencies.isEmpty()) { + return; + } + List missing = dependencies.stream() + .map(ProjectDependency::projectId) + .filter(id -> id != null && !id.isBlank()) + .filter(id -> !metadata.containsKey(id) && requestedMetadataIds.add(id)) + .distinct() + .toList(); + if (missing.isEmpty()) { + return; + } + ProjectDetail expectedProject = project; + ProjectVersion expectedVersion = version; + CompletableFuture.supplyAsync(() -> apiClient.getProjectMetaBatch(missing), executor) + .whenComplete((result, error) -> Platform.runLater(() -> { + Map next = error == null && result != null ? result : Map.of(); + for (String id : missing) { + metadata.put(id, next.getOrDefault(id, fallbackMeta(id))); + } + if (project == expectedProject && version == expectedVersion && overlay != null) { + rebuildOverlay(); + } + })); + } + + private void blurBackdrop(StackPane hostPane) { + restoreBackdrop(); + for (Node child : hostPane.getChildren()) { + backdropEffects.put(child, child.getEffect()); + child.setEffect(new GaussianBlur(6)); + } + } + + private void restoreBackdrop() { + backdropEffects.forEach(Node::setEffect); + backdropEffects.clear(); + } + + static List selectableDependencies(ProjectVersion version) { + if (version == null || version.dependencies() == null) { + return List.of(); + } + return version.dependencies().stream() + .filter(dependency -> dependency != null + && !dependency.isEmbedded() + && !dependency.isExternal() + && !isBlank(dependency.projectId())) + .toList(); + } + + private String dependencyTitle(ProjectDependency dependency) { + ProjectMeta meta = dependencyMeta(dependency); + return firstNonBlank( + meta == null ? null : meta.title(), + dependency.title(), + dependency.projectTitle(), + dependency.projectId(), + dependency.id(), + "Dependency" + ); + } + + private String dependencyAuthor(ProjectDependency dependency) { + ProjectMeta meta = dependencyMeta(dependency); + return firstNonBlank(meta == null ? null : meta.author(), "..."); + } + + private String dependencyIconUrl(ProjectDependency dependency) { + ProjectMeta meta = dependencyMeta(dependency); + return firstNonBlank(meta == null ? null : meta.icon(), dependency.icon()); + } + + private ProjectMeta dependencyMeta(ProjectDependency dependency) { + if (dependency == null || isBlank(dependency.projectId())) { + return null; + } + return metadata.get(dependency.projectId()); + } + + private static ProjectMeta fallbackMeta(String projectId) { + return new ProjectMeta("", "", "", "...", "", 0, "", ""); + } + + private static String firstNonBlank(String... values) { + for (String candidate : values) { + if (!isBlank(candidate)) { + return candidate; + } + } + return ""; + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + record DependencySelection( + ProjectDetail project, + ProjectVersion version, + String gameVersion, + List selectedDependencies + ) { + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/project/NativeDownloadModal.java b/launcher/src/main/java/net/modtale/launcher/ui/project/NativeDownloadModal.java new file mode 100644 index 00000000..49698ca2 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/project/NativeDownloadModal.java @@ -0,0 +1,853 @@ +package net.modtale.launcher.ui.project; + +import static net.modtale.launcher.ui.browse.card.ProjectCardFormatter.timeAgo; +import static net.modtale.launcher.ui.common.LauncherUi.value; + +import java.time.Instant; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Supplier; +import javafx.application.Platform; +import javafx.css.PseudoClass; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.Parent; +import javafx.scene.control.Button; +import javafx.scene.control.ContentDisplay; +import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import net.modtale.launcher.model.project.GameVersionCatalog; +import net.modtale.launcher.model.project.ProjectClassification; +import net.modtale.launcher.model.project.ProjectDetail; +import net.modtale.launcher.model.project.ProjectDependency; +import net.modtale.launcher.model.project.ProjectVersion; +import net.modtale.launcher.ui.common.GameVersionDropdown; +import net.modtale.launcher.ui.common.GameVersionGroups; +import net.modtale.launcher.ui.common.LauncherIcons; + +final class NativeDownloadModal { + + private static final double MODAL_WIDTH = 672; + private static final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected"); + + private final Supplier host; + private final Supplier preferredGameVersion; + private final Consumer install; + private final Consumer viewHistory; + + private StackPane overlay; + private ProjectDetail project; + private GameVersionCatalog catalog; + private List selectedGameVersions = List.of(); + private boolean showExperimental; + private boolean showPreReleaseGameVersions; + private boolean listExpanded; + private boolean loading; + private boolean gameVersionDropdownOpen; + + NativeDownloadModal( + Supplier host, + Supplier preferredGameVersion, + Consumer install, + Consumer viewHistory + ) { + this.host = host == null ? () -> null : host; + this.preferredGameVersion = preferredGameVersion == null ? () -> "" : preferredGameVersion; + this.install = install == null ? selection -> { + } : install; + this.viewHistory = viewHistory == null ? ignored -> { + } : viewHistory; + } + + void show(ProjectDetail project, GameVersionCatalog catalog) { + this.project = project; + this.catalog = catalog == null ? GameVersionCatalog.fromVersions(List.of()) : catalog; + this.showExperimental = false; + this.showPreReleaseGameVersions = forceShowPreReleaseGameVersions(); + this.listExpanded = false; + this.loading = false; + this.gameVersionDropdownOpen = false; + this.selectedGameVersions = preferredVisibleGameVersions(); + rebuildOverlay(); + } + + void showLoading(ProjectDetail project, GameVersionCatalog catalog) { + this.project = project; + this.catalog = catalog == null ? GameVersionCatalog.fromVersions(List.of()) : catalog; + this.showExperimental = false; + this.showPreReleaseGameVersions = forceShowPreReleaseGameVersions(); + this.listExpanded = false; + this.loading = true; + this.gameVersionDropdownOpen = false; + this.selectedGameVersions = preferredVisibleGameVersions(); + rebuildOverlay(); + } + + void refresh(ProjectDetail project, GameVersionCatalog catalog) { + if (overlay == null || project == null || !sameProject(this.project, project)) { + return; + } + this.project = project; + this.catalog = catalog == null ? GameVersionCatalog.fromVersions(List.of()) : catalog; + this.loading = false; + List availableGameVersions = gameVersions(); + if (selectedGameVersions.isEmpty() && !availableGameVersions.isEmpty()) { + selectedGameVersions = preferredVisibleGameVersions(); + listExpanded = false; + } else if (!selectedGameVersions.isEmpty() && !availableGameVersions.containsAll(selectedGameVersions)) { + List validSelections = selectedGameVersions.stream() + .filter(availableGameVersions::contains) + .toList(); + selectedGameVersions = validSelections.isEmpty() ? preferredVisibleGameVersions() : validSelections; + listExpanded = false; + } + rebuildOverlay(); + } + + boolean isShowing(ProjectDetail project) { + return overlay != null && project != null && sameProject(this.project, project); + } + + void hide() { + if (overlay == null) { + return; + } + Parent parent = overlay.getParent(); + if (parent instanceof StackPane stack) { + stack.getChildren().remove(overlay); + } + overlay = null; + } + + private void rebuildOverlay() { + StackPane hostPane = host.get(); + if (hostPane == null || project == null) { + return; + } + if (overlay == null) { + overlay = overlayShell(); + hostPane.getChildren().add(overlay); + } + overlay.getChildren().setAll(modal(hostPane)); + Platform.runLater(overlay::requestFocus); + } + + private StackPane overlayShell() { + StackPane shell = new StackPane(); + shell.getStyleClass().add("download-modal-overlay"); + shell.setMaxSize(Double.MAX_VALUE, Double.MAX_VALUE); + shell.setFocusTraversable(true); + shell.addEventHandler(KeyEvent.KEY_PRESSED, event -> { + if (event.getCode() == KeyCode.ESCAPE) { + hide(); + event.consume(); + } + }); + shell.setOnMouseClicked(event -> { + if (event.getTarget() == shell) { + hide(); + } + }); + return shell; + } + + private VBox modal(StackPane hostPane) { + VBox modal = new VBox(0); + modal.getStyleClass().add("download-modal"); + modal.setMaxWidth(MODAL_WIDTH); + modal.setPrefWidth(MODAL_WIDTH); + modal.setMaxHeight(Region.USE_PREF_SIZE); + modal.setOnMouseClicked(event -> event.consume()); + + ScrollPane bodyScroll = new ScrollPane(body()); + bodyScroll.getStyleClass().add("download-modal-scroll"); + bodyScroll.setFitToWidth(true); + bodyScroll.setFitToHeight(false); + bodyScroll.setMaxHeight(Region.USE_PREF_SIZE); + bodyScroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + bodyScroll.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED); + VBox.setVgrow(bodyScroll, Priority.NEVER); + + modal.getChildren().addAll(header(), bodyScroll, footer()); + return modal; + } + + private HBox header() { + HBox header = new HBox(16); + header.getStyleClass().add("download-modal-header"); + header.setAlignment(Pos.CENTER_LEFT); + + VBox copy = new VBox(4); + copy.setMinWidth(0); + HBox.setHgrow(copy, Priority.ALWAYS); + HBox title = new HBox(9); + title.getStyleClass().add("download-modal-title-row"); + title.setAlignment(Pos.CENTER_LEFT); + Label label = new Label("Download"); + label.getStyleClass().add("download-modal-title"); + title.getChildren().addAll(LauncherIcons.icon(LauncherIcons.Glyph.DOWNLOAD, 20), label); + copy.getChildren().add(title); + + if (showPreReleaseToggle()) { + copy.getChildren().add(toggleRow( + "Show Pre-Release Game Versions", + effectiveShowPreReleaseGameVersions(), + () -> { + showPreReleaseGameVersions = !showPreReleaseGameVersions; + selectedGameVersions = preferredVisibleGameVersions(); + listExpanded = false; + gameVersionDropdownOpen = false; + rebuildOverlay(); + } + )); + } + if (showAlphaBetaToggle()) { + copy.getChildren().add(toggleRow( + "Show Beta/Alpha", + effectiveShowExperimental(), + () -> { + showExperimental = !showExperimental; + selectedGameVersions = preferredVisibleGameVersions(); + listExpanded = false; + gameVersionDropdownOpen = false; + rebuildOverlay(); + } + )); + } + + Button close = new Button(null, LauncherIcons.icon(LauncherIcons.Glyph.X, 18)); + close.getStyleClass().add("download-modal-close"); + close.setOnAction(event -> hide()); + header.getChildren().addAll(copy, close); + return header; + } + + private HBox toggleRow(String text, boolean selected, Runnable action) { + HBox row = new HBox(8); + row.getStyleClass().add("download-modal-toggle-row"); + row.setAlignment(Pos.CENTER_LEFT); + row.setOnMouseClicked(event -> action.run()); + StackPane switchTrack = new StackPane(); + switchTrack.getStyleClass().add("download-modal-toggle"); + switchTrack.pseudoClassStateChanged(SELECTED, selected); + Region knob = new Region(); + knob.getStyleClass().add("download-modal-toggle-knob"); + switchTrack.getChildren().add(knob); + StackPane.setAlignment(knob, selected ? Pos.CENTER_RIGHT : Pos.CENTER_LEFT); + Label label = new Label(text.toUpperCase(Locale.ROOT)); + label.getStyleClass().add("download-modal-toggle-label"); + row.getChildren().addAll(switchTrack, label); + return row; + } + + private VBox body() { + VBox body = new VBox(0); + body.getStyleClass().add("download-modal-body"); + + if (loading && project.versions().isEmpty()) { + body.getChildren().add(loadingState()); + return body; + } + + VBox versionBlock = new VBox(8); + versionBlock.getStyleClass().add("download-modal-version-block"); + Label versionLabel = new Label("GAME VERSION"); + versionLabel.getStyleClass().add("download-modal-field-label"); + GameVersionDropdown versions = GameVersionDropdown.multiSelect(); + versions.getStyleClass().add("download-game-version-dropdown"); + versions.setAllowEmptySelection(false); + versions.setEmptyText("No compatible game versions"); + versions.setMaxListHeight(224); + versions.setVersions(gameVersions()); + versions.setSelectedVersions(activeSelectedGameVersions()); + versions.setOnOpenChange(open -> gameVersionDropdownOpen = open); + versions.setOnSelectionChange(next -> { + selectedGameVersions = next.isEmpty() ? preferredVisibleGameVersions() : List.copyOf(next); + listExpanded = false; + rebuildOverlay(); + }); + versions.setOpen(gameVersionDropdownOpen); + versionBlock.getChildren().addAll(versionLabel, versions); + body.getChildren().add(versionBlock); + + List sortedVersions = sortedVisibleVersions(); + VersionEntry latest = sortedVersions.isEmpty() ? null : sortedVersions.getFirst(); + if (latest == null) { + body.getChildren().add(emptyState()); + return body; + } + + Button latestButton = latestButton(latest); + Node latestExternalNotice = externalDependencyNotice(latest.version()); + VBox.setMargin(latestButton, new Insets(0, 0, latestExternalNotice == null ? 24 : 12, 0)); + body.getChildren().add(latestButton); + if (latestExternalNotice != null) { + VBox.setMargin(latestExternalNotice, new Insets(0, 0, 24, 0)); + body.getChildren().add(latestExternalNotice); + } + + body.getChildren().add(otherVersionsDivider()); + body.getChildren().add(expandVersionsButton()); + if (listExpanded) { + VBox list = new VBox(8); + list.getStyleClass().add("download-modal-version-list"); + for (VersionEntry entry : sortedVersions) { + list.getChildren().add(versionRow(entry)); + } + body.getChildren().add(list); + } + return body; + } + + private Node loadingState() { + VBox state = new VBox(12); + state.getStyleClass().add("download-modal-empty"); + state.setAlignment(Pos.CENTER); + state.getChildren().addAll( + NativeSpinner.inline("Loading download options", 20), + new Label("Fetching files and compatible game versions.") + ); + return state; + } + + private Button latestButton(VersionEntry entry) { + Button button = new Button(); + button.getStyleClass().addAll("download-modal-latest", channelStyle(entry.version().channel())); + button.setMaxWidth(Double.MAX_VALUE); + button.setGraphic(latestButtonContent(entry)); + button.setOnAction(event -> install(entry)); + return button; + } + + private VBox latestButtonContent(VersionEntry entry) { + ProjectVersion version = entry.version(); + VBox box = new VBox(6); + box.setAlignment(Pos.CENTER); + box.setFillWidth(false); + HBox headline = new HBox(8); + headline.setAlignment(Pos.CENTER); + Label text = new Label("Download Latest"); + text.getStyleClass().add("download-modal-latest-title"); + headline.getChildren().addAll(LauncherIcons.icon(LauncherIcons.Glyph.DOWNLOAD, 24), text); + HBox badge = versionBadge(version); + badge.setMaxWidth(Region.USE_PREF_SIZE); + box.getChildren().addAll(headline, badge); + if (shouldShowEntryGameVersion()) { + Label forVersion = new Label("For " + entry.gameVersion()); + forVersion.getStyleClass().add("download-modal-file-date"); + box.getChildren().add(forVersion); + } + List others = otherCompatibleVersions(version, entry.gameVersion()); + if (!others.isEmpty()) { + Label supports = new Label("Also supports: " + String.join(", ", others)); + supports.getStyleClass().add("download-modal-also-supports"); + box.getChildren().add(supports); + } + return box; + } + + private HBox versionBadge(ProjectVersion version) { + HBox badge = new HBox(8); + badge.getStyleClass().addAll("download-modal-version-badge", channelStyle(version.channel())); + badge.setAlignment(Pos.CENTER); + Label number = new Label("v" + value(version.versionNumber(), "unknown")); + number.getStyleClass().add("download-modal-version-number"); + badge.getChildren().add(number); + if (!isRelease(version.channel())) { + Label channel = new Label(value(version.channel(), "RELEASE").toUpperCase(Locale.ROOT)); + channel.getStyleClass().add("download-modal-version-channel"); + badge.getChildren().add(channel); + } + return badge; + } + + private Node otherVersionsDivider() { + StackPane divider = new StackPane(); + divider.getStyleClass().add("download-modal-divider-wrap"); + Region line = new Region(); + line.getStyleClass().add("download-modal-divider-line"); + Label label = new Label("OTHER VERSIONS"); + label.getStyleClass().add("download-modal-divider-label"); + divider.getChildren().addAll(line, label); + return divider; + } + + private Button expandVersionsButton() { + Button button = new Button(); + button.getStyleClass().add("download-modal-expand"); + button.setMaxWidth(Double.MAX_VALUE); + + HBox content = new HBox(8); + content.getStyleClass().add("download-modal-expand-content"); + content.setAlignment(Pos.CENTER_LEFT); + content.setMinWidth(0); + content.prefWidthProperty().bind(button.widthProperty().subtract(24)); + Label text = new Label("View all files for " + selectedGameVersionLabel()); + text.getStyleClass().add("download-modal-expand-text"); + Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + Node icon = LauncherIcons.icon(LauncherIcons.Glyph.CHEVRON_DOWN, 16); + icon.getStyleClass().add("download-modal-expand-icon"); + icon.setRotate(listExpanded ? 180 : 0); + content.getChildren().addAll(text, spacer, icon); + + button.setGraphic(content); + button.setContentDisplay(ContentDisplay.GRAPHIC_ONLY); + button.setOnAction(event -> { + listExpanded = !listExpanded; + rebuildOverlay(); + }); + return button; + } + + private Node versionRow(VersionEntry entry) { + ProjectVersion version = entry.version(); + VBox row = new VBox(10); + row.getStyleClass().add("download-modal-file-row"); + HBox main = new HBox(14); + main.getStyleClass().add("download-modal-file-row-main"); + main.setAlignment(Pos.CENTER_LEFT); + + StackPane icon = new StackPane(LauncherIcons.icon(LauncherIcons.Glyph.FILE_CODE, 20)); + icon.getStyleClass().add("download-modal-file-icon"); + VBox copy = new VBox(3); + copy.setMinWidth(0); + HBox.setHgrow(copy, Priority.ALWAYS); + HBox title = new HBox(8); + title.setAlignment(Pos.CENTER_LEFT); + Label versionText = new Label("v" + value(version.versionNumber(), "unknown")); + versionText.getStyleClass().add("download-modal-file-version"); + title.getChildren().add(versionText); + if (!isRelease(version.channel())) { + title.getChildren().add(channelBadge(version.channel())); + } + Label date = new Label(timeAgo(version.releaseDate())); + date.getStyleClass().add("download-modal-file-date"); + if (shouldShowEntryGameVersion()) { + date.setText(date.getText() + " · " + entry.gameVersion()); + } + copy.getChildren().addAll(title, date); + List others = otherCompatibleVersions(version, entry.gameVersion()); + if (!others.isEmpty()) { + Label supports = new Label("Also supports: " + String.join(", ", others)); + supports.getStyleClass().add("download-modal-file-supports"); + copy.getChildren().add(supports); + } + if (!externalDependencies(version).isEmpty()) { + HBox external = new HBox(4, LauncherIcons.icon(LauncherIcons.Glyph.ALERT_CIRCLE, 12), new Label("EXTERNAL MODS")); + external.getStyleClass().add("download-modal-file-external-badge"); + external.setAlignment(Pos.CENTER_LEFT); + copy.getChildren().add(external); + } + + Button download = new Button(null, LauncherIcons.icon(LauncherIcons.Glyph.DOWNLOAD, 16)); + download.getStyleClass().add("download-modal-file-download"); + download.setOnAction(event -> install(entry)); + + main.getChildren().addAll(icon, copy, download); + row.getChildren().add(main); + return row; + } + + private Label channelBadge(String channel) { + Label badge = new Label(value(channel, "RELEASE").toUpperCase(Locale.ROOT)); + badge.getStyleClass().addAll("download-modal-channel-badge", channelStyle(channel)); + return badge; + } + + private Node externalDependencyNotice(ProjectVersion version) { + if (!ProjectClassification.isModpack(project.classification())) { + return null; + } + List external = externalDependencies(version); + if (external.isEmpty()) { + return null; + } + VBox notice = new VBox(4); + notice.getStyleClass().add("download-modal-external-dependency-notice"); + HBox title = new HBox(7, LauncherIcons.icon(LauncherIcons.Glyph.ALERT_CIRCLE, 14), new Label("This modpack uses external mods")); + title.getStyleClass().add("download-modal-external-dependency-title"); + title.setAlignment(Pos.CENTER_LEFT); + Label copy = new Label("%s %s from outside Modtale. Check the linked source pages if the download or install flow asks for them separately.".formatted( + externalDependencyNames(external), + external.size() == 1 ? "comes" : "come" + )); + copy.getStyleClass().add("download-modal-external-dependency-copy"); + copy.setWrapText(true); + notice.getChildren().addAll(title, copy); + return notice; + } + + private String externalDependencyNames(List dependencies) { + List names = dependencies.stream() + .map(this::dependencyTitle) + .filter(name -> !isBlank(name)) + .limit(3) + .toList(); + int remaining = dependencies.size() - names.size(); + return String.join(", ", names) + (remaining > 0 ? ", +" + remaining + " more" : ""); + } + + private List externalDependencies(ProjectVersion version) { + if (version == null || version.dependencies() == null) { + return List.of(); + } + return version.dependencies().stream() + .filter(dependency -> dependency != null && dependency.isExternal() && !dependency.isEmbedded()) + .toList(); + } + + private String dependencyTitle(ProjectDependency dependency) { + return firstNonBlank( + dependency.title(), + dependency.projectTitle(), + dependency.projectId(), + dependency.externalId(), + dependency.id(), + "Dependency" + ); + } + + private static String firstNonBlank(String... values) { + for (String candidate : values) { + if (!isBlank(candidate)) { + return candidate; + } + } + return ""; + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + private static boolean sameProject(ProjectDetail left, ProjectDetail right) { + if (left == right) { + return true; + } + if (left == null || right == null) { + return false; + } + String leftId = value(left.id(), ""); + String rightId = value(right.id(), ""); + if (!leftId.isBlank() && !rightId.isBlank()) { + return leftId.equals(rightId); + } + return value(left.routeKey(), "").equals(value(right.routeKey(), "")); + } + + private Node emptyState() { + VBox empty = new VBox(10); + empty.getStyleClass().add("download-modal-empty"); + empty.setAlignment(Pos.CENTER); + empty.getChildren().addAll( + LauncherIcons.icon(LauncherIcons.Glyph.ALERT_CIRCLE, 30), + new Label("No compatible versions.") + ); + if (!effectiveShowExperimental() && !currentVersions().isEmpty()) { + Button show = new Button("Show experimental"); + show.getStyleClass().add("download-modal-show-experimental"); + show.setOnAction(event -> { + showExperimental = true; + rebuildOverlay(); + }); + empty.getChildren().add(show); + } + return empty; + } + + private HBox footer() { + HBox footer = new HBox(); + footer.getStyleClass().add("download-modal-footer"); + footer.setAlignment(Pos.CENTER); + Button history = new Button("VIEW FULL CHANGELOG", LauncherIcons.icon(LauncherIcons.Glyph.CHEVRON_RIGHT, 13)); + history.getStyleClass().add("download-modal-history"); + history.setContentDisplay(javafx.scene.control.ContentDisplay.RIGHT); + history.setOnAction(event -> { + ProjectDetail selectedProject = project; + hide(); + viewHistory.accept(selectedProject); + }); + footer.getChildren().add(history); + return footer; + } + + private void install(VersionEntry entry) { + ProjectDetail selectedProject = project; + ProjectVersion version = entry.version(); + String gameVersion = entry.gameVersion(); + hide(); + install.accept(new DownloadSelection(selectedProject, version, gameVersion, null)); + } + + private List gameVersions() { + Map> byGame = versionsByGame(); + Set preRelease = preReleaseGameVersionSet(); + boolean effectivePreRelease = effectiveShowPreReleaseGameVersions(); + return orderedGameVersions().stream() + .filter(byGame::containsKey) + .filter(version -> effectivePreRelease || !preRelease.contains(version)) + .toList(); + } + + private List activeSelectedGameVersions() { + if (selectedGameVersions.isEmpty()) { + return preferredVisibleGameVersions(); + } + List versions = gameVersions(); + List validSelections = selectedGameVersions.stream() + .filter(versions::contains) + .toList(); + return validSelections.isEmpty() ? preferredVisibleGameVersions() : validSelections; + } + + private List selectedVersionEntries() { + Map entries = new LinkedHashMap<>(); + Map> byGame = versionsByGame(); + for (String gameVersion : activeSelectedGameVersions()) { + for (ProjectVersion version : byGame.getOrDefault(gameVersion, List.of())) { + entries.putIfAbsent(versionKey(version), new VersionEntry(version, gameVersion)); + } + } + return List.copyOf(entries.values()); + } + + private List currentVersions() { + return selectedVersionEntries().stream() + .map(VersionEntry::version) + .toList(); + } + + private List sortedVisibleVersions() { + boolean effectiveExperimental = effectiveShowExperimental(); + return selectedVersionEntries().stream() + .filter(entry -> effectiveExperimental || isRelease(entry.version().channel())) + .sorted(versionEntryComparator()) + .toList(); + } + + private List preferredVisibleGameVersions() { + List versions = gameVersions(); + if (versions.isEmpty()) { + return List.of(); + } + if (!selectedGameVersions.isEmpty()) { + List validSelections = selectedGameVersions.stream() + .filter(versions::contains) + .toList(); + if (!validSelections.isEmpty()) { + return validSelections; + } + } + return List.of(versions.getFirst()); + } + + private Map> versionsByGame() { + Map> grouped = new LinkedHashMap<>(); + for (String gameVersion : orderedGameVersions()) { + grouped.put(gameVersion, new ArrayList<>()); + } + for (ProjectVersion version : project.versions()) { + List gameVersions = version.gameVersions().isEmpty() + ? List.of(value(preferredGameVersion.get(), "")) + : version.gameVersions(); + for (String gameVersion : gameVersions) { + if (gameVersion == null || gameVersion.isBlank()) { + continue; + } + grouped.computeIfAbsent(gameVersion, ignored -> new ArrayList<>()).add(version); + } + } + grouped.entrySet().removeIf(entry -> entry.getValue().isEmpty()); + return grouped; + } + + private List orderedGameVersions() { + LinkedHashSet ordered = new LinkedHashSet<>(); + if (catalog != null) { + ordered.addAll(catalog.allVersions()); + if (ordered.isEmpty()) { + ordered.addAll(catalog.releaseVersions()); + ordered.addAll(catalog.preReleaseVersions()); + } + } + project.versions().stream() + .flatMap(version -> version.gameVersions().stream()) + .filter(version -> version != null && !version.isBlank()) + .sorted((left, right) -> compareSemver(right, left)) + .forEach(ordered::add); + return List.copyOf(ordered); + } + + private Set preReleaseGameVersionSet() { + return catalog == null ? Set.of() : Set.copyOf(catalog.preReleaseVersions()); + } + + private boolean effectiveShowPreReleaseGameVersions() { + return showPreReleaseGameVersions || forceShowPreReleaseGameVersions(); + } + + private boolean forceShowPreReleaseGameVersions() { + Map> byGame = versionsByGame(); + Set preRelease = preReleaseGameVersionSet(); + boolean hasPreRelease = byGame.keySet().stream().anyMatch(preRelease::contains); + boolean hasRelease = byGame.keySet().stream().anyMatch(version -> !preRelease.contains(version)); + return hasPreRelease && !hasRelease; + } + + private boolean showPreReleaseToggle() { + Map> byGame = versionsByGame(); + Set preRelease = preReleaseGameVersionSet(); + boolean hasPreRelease = byGame.keySet().stream().anyMatch(preRelease::contains); + boolean hasRelease = byGame.keySet().stream().anyMatch(version -> !preRelease.contains(version)); + return hasPreRelease && hasRelease; + } + + private boolean effectiveShowExperimental() { + List versions = currentVersions(); + boolean hasRelease = versions.stream().anyMatch(version -> isRelease(version.channel())); + boolean hasExperimental = versions.stream().anyMatch(version -> !isRelease(version.channel())); + return showExperimental || (hasExperimental && !hasRelease); + } + + private boolean showAlphaBetaToggle() { + boolean hasExperimental = project.versions().stream().anyMatch(version -> !isRelease(version.channel())); + boolean hasRelease = project.versions().stream().anyMatch(version -> isRelease(version.channel())); + return hasExperimental && hasRelease; + } + + private boolean shouldShowEntryGameVersion() { + return activeSelectedGameVersions().size() > 1; + } + + private String selectedGameVersionLabel() { + return GameVersionGroups.displayLabel(activeSelectedGameVersions(), gameVersions(), "selected version"); + } + + private List otherCompatibleVersions(ProjectVersion version, String selectedGameVersion) { + return version.gameVersions().stream() + .filter(gameVersion -> !gameVersion.equals(selectedGameVersion)) + .toList(); + } + + private static boolean isRelease(String channel) { + return channel == null || channel.isBlank() || "RELEASE".equalsIgnoreCase(channel); + } + + private static String channelStyle(String channel) { + if ("ALPHA".equalsIgnoreCase(channel)) { + return "alpha"; + } + if ("BETA".equalsIgnoreCase(channel)) { + return "beta"; + } + return "release"; + } + + private static Comparator versionComparator() { + return (left, right) -> { + int date = compareReleaseDate(right.releaseDate(), left.releaseDate()); + if (date != 0) { + return date; + } + return compareSemver(right.versionNumber(), left.versionNumber()); + }; + } + + private static Comparator versionEntryComparator() { + return (left, right) -> versionComparator().compare(left.version(), right.version()); + } + + private static String versionKey(ProjectVersion version) { + return firstNonBlank( + version.id(), + value(version.versionNumber(), "unknown") + "-" + value(version.fileUrl(), "") + "-" + value(version.releaseDate(), "") + ); + } + + private static int compareReleaseDate(String left, String right) { + Instant leftInstant = parseInstant(left); + Instant rightInstant = parseInstant(right); + if (leftInstant == null && rightInstant == null) { + return 0; + } + if (leftInstant == null) { + return -1; + } + if (rightInstant == null) { + return 1; + } + return leftInstant.compareTo(rightInstant); + } + + private static Instant parseInstant(String value) { + if (value == null || value.isBlank()) { + return null; + } + try { + return Instant.parse(value); + } catch (DateTimeParseException ignored) { + return null; + } + } + + private static int compareSemver(String left, String right) { + List leftParts = semverParts(left); + List rightParts = semverParts(right); + int length = Math.max(leftParts.size(), rightParts.size()); + for (int i = 0; i < length; i++) { + int leftPart = i < leftParts.size() ? leftParts.get(i) : 0; + int rightPart = i < rightParts.size() ? rightParts.get(i) : 0; + int comparison = Integer.compare(leftPart, rightPart); + if (comparison != 0) { + return comparison; + } + } + return value(left, "").compareToIgnoreCase(value(right, "")); + } + + private static List semverParts(String value) { + if (value == null || value.isBlank()) { + return List.of(); + } + List parts = new ArrayList<>(); + for (String part : value.split("[^0-9]+")) { + if (part.isBlank()) { + continue; + } + try { + parts.add(Integer.parseInt(part)); + } catch (NumberFormatException ignored) { + parts.add(0); + } + } + return parts; + } + + record DownloadSelection( + ProjectDetail project, + ProjectVersion version, + String gameVersion, + List selectedDependencies + ) { + } + + private record VersionEntry(ProjectVersion version, String gameVersion) { + } +} diff --git a/launcher/src/main/java/net/modtale/launcher/ui/project/NativeGalleryCarousel.java b/launcher/src/main/java/net/modtale/launcher/ui/project/NativeGalleryCarousel.java new file mode 100644 index 00000000..6e639ad3 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/ui/project/NativeGalleryCarousel.java @@ -0,0 +1,428 @@ +package net.modtale.launcher.ui.project; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.function.Consumer; +import javafx.animation.Interpolator; +import javafx.animation.KeyFrame; +import javafx.animation.KeyValue; +import javafx.animation.Timeline; +import javafx.css.PseudoClass; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Cursor; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; +import javafx.scene.image.ImageView; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Pane; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import javafx.scene.shape.Rectangle; +import javafx.scene.transform.Scale; +import javafx.util.Duration; +import net.modtale.launcher.ui.common.CachedImageLoader; +import net.modtale.launcher.ui.common.LauncherIcons; + +final class NativeGalleryCarousel { + + private static final double MEDIA_ASPECT_RATIO = 16.0 / 9.0; + private static final double MODAL_REQUESTED_WIDTH = 1800; + private static final double MODAL_REQUESTED_HEIGHT = 1100; + private static final double INLINE_REQUESTED_WIDTH = 1200; + private static final double INLINE_REQUESTED_HEIGHT = 720; + private static final double THUMBNAIL_WIDTH = 144; + private static final double THUMBNAIL_HEIGHT = 80; + private static final double THUMBNAIL_REQUESTED_WIDTH = 256; + private static final double THUMBNAIL_REQUESTED_HEIGHT = 144; + private static final Duration AUTO_ADVANCE_DURATION = Duration.seconds(8); + private static final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected"); + private static final String THUMBNAIL_LOADED_PROPERTY = NativeGalleryCarousel.class.getName() + ".thumbnailLoaded"; + + enum Variant { + MODAL, + INLINE + } + + private final CachedImageLoader imageLoader; + private final Consumer openUrl; + + NativeGalleryCarousel(CachedImageLoader imageLoader, Consumer openUrl) { + this.imageLoader = imageLoader; + this.openUrl = openUrl; + } + + Node render(List images, int initialIndex, Variant variant) { + return new CarouselView(images, initialIndex, variant).root(); + } + + private final class CarouselView { + private final List images; + private final Variant variant; + private final VBox root = new VBox(0); + private final StackPane media = new FixedAspectPane(MEDIA_ASPECT_RATIO); + private final ImageView image = new ImageView(); + private final Button previous = arrow(LauncherIcons.Glyph.CHEVRON_LEFT, "Previous image"); + private final Button next = arrow(LauncherIcons.Glyph.CHEVRON_RIGHT, "Next image"); + private final Label caption = new Label(); + private final HBox thumbnails = new HBox(12); + private final ScrollPane thumbnailScroller = new ScrollPane(thumbnails); + private final Region progressFill = new Region(); + private final Scale progressScale = new Scale(0, 1, 0, 0); + private final List thumbnailViews = new ArrayList<>(); + private final List